initial: bootstrap from BukidBountyApp base
This commit is contained in:
130
resources/js/Components/Core/Search/SearchBar.vue
Normal file
130
resources/js/Components/Core/Search/SearchBar.vue
Normal file
@@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<div class="box-search mt-3">
|
||||
<div class="input-field">
|
||||
<!-- Left icon - only show one -->
|
||||
<IconImage v-if="leftIcon" :src="leftIcon" :width="iconWidth" :height="iconHeight" />
|
||||
<i v-else class="fas fa-search search-icon"></i>
|
||||
|
||||
<input
|
||||
:id="id"
|
||||
class="search-field value_input"
|
||||
:placeholder="placeholder"
|
||||
type="text"
|
||||
:value="modelValue"
|
||||
@input="$emit('update:modelValue', $event.target.value)"
|
||||
/>
|
||||
<i
|
||||
v-if="modelValue"
|
||||
class="fas fa-times clear-icon"
|
||||
:id="`clear-${id}`"
|
||||
@click="$emit('update:modelValue', ''); $emit('clear')"
|
||||
></i>
|
||||
</div>
|
||||
<!-- Right icon -->
|
||||
<IconImage
|
||||
v-if="rightIcon"
|
||||
:src="rightIcon"
|
||||
:width="iconWidth"
|
||||
:height="iconHeight"
|
||||
:id="`${id}-rightsearchicon`"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import IconImage from '../IconImage.vue'
|
||||
|
||||
defineProps({
|
||||
id: { type: String, default: 'search' },
|
||||
placeholder: { type: String, default: 'Search' },
|
||||
modelValue: { type: String, default: '' },
|
||||
leftIcon: { type: String, default: '' },
|
||||
rightIcon: { type: String, default: '' },
|
||||
iconWidth: { type: [String, Number], default: 30 },
|
||||
iconHeight: { type: [String, Number], default: 30 },
|
||||
})
|
||||
|
||||
defineEmits(['update:modelValue', 'clear'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.box-search {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--bg-secondary, #f5f5f5);
|
||||
border-radius: 12px;
|
||||
padding: 10px 16px;
|
||||
gap: 10px;
|
||||
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.08));
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.input-field:focus-within {
|
||||
border-color: var(--accent-color, #533dea);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft, rgba(83, 61, 234, 0.1));
|
||||
}
|
||||
|
||||
.search-field {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-primary, #1e1e1e);
|
||||
outline: none;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.search-field::placeholder {
|
||||
color: var(--text-muted, #a0a0a0);
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
color: var(--text-muted, #717171);
|
||||
font-size: 0.9rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.clear-icon {
|
||||
color: var(--text-muted, #717171);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.6;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.clear-icon:hover {
|
||||
opacity: 1;
|
||||
color: var(--text-primary, #1e1e1e);
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
:global(.dark-mode) .input-field {
|
||||
background: var(--bg-secondary, #1a1c22);
|
||||
border-color: var(--border-color, rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
|
||||
:global(.dark-mode) .search-field {
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
|
||||
:global(.dark-mode) .search-icon {
|
||||
color: var(--text-muted, #b0b0b0);
|
||||
}
|
||||
|
||||
:global(.dark-mode) .clear-icon {
|
||||
color: var(--text-muted, #b0b0b0);
|
||||
}
|
||||
|
||||
:global(.dark-mode) .clear-icon:hover {
|
||||
color: var(--text-primary, #e0e0e0);
|
||||
}
|
||||
</style>
|
||||
133
resources/js/Components/Core/Search/SearchableList.vue
Normal file
133
resources/js/Components/Core/Search/SearchableList.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Search bar (optional) -->
|
||||
<SearchBar
|
||||
v-if="searchable"
|
||||
v-model="searchQuery"
|
||||
:id="searchId"
|
||||
:placeholder="searchPlaceholder"
|
||||
:left-icon="searchLeftIcon"
|
||||
:right-icon="searchRightIcon"
|
||||
@clear="searchQuery = ''"
|
||||
/>
|
||||
|
||||
<!-- Title + View All header -->
|
||||
<h3 class="fw_6 d-flex justify-content-between mt-3 align-items-center">
|
||||
<span>{{ title }}</span>
|
||||
<a
|
||||
v-if="viewAllText"
|
||||
href="javascript:void(0);"
|
||||
class="small fw_4 text-primary"
|
||||
@click="$emit('view-all')"
|
||||
>
|
||||
{{ viewAllText }}
|
||||
</a>
|
||||
</h3>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="mt-3">
|
||||
<div v-for="i in 3" :key="i" class="d-flex align-items-center gap-3 mb-3 p-2">
|
||||
<SkeletonBlock width="40px" height="40px" border-radius="12px" />
|
||||
<div class="flex-grow-1">
|
||||
<SkeletonText width="60%" height="16px" class="mb-2" />
|
||||
<SkeletonText width="40%" height="12px" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div v-if="!loading && filteredItems.length === 0" class="text-center py-4">
|
||||
<i class="fas fa-inbox fa-2x text-muted opacity-50 mb-2"></i>
|
||||
<p class="text-muted small mb-0">{{ emptyText }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Searchable list items -->
|
||||
<ul v-if="!loading && filteredItems.length > 0" class="activity-list mt-3 mb-5 px-0">
|
||||
<SearchableListItem
|
||||
v-for="(item, index) in filteredItems"
|
||||
:key="index"
|
||||
:title="item.title"
|
||||
:subtitle="formatSubtitle(item)"
|
||||
:right-text="formatTimestamp(item.timestamp || item.rightText)"
|
||||
:search-class="item.searchClass || ''"
|
||||
:icon="item.icon || ''"
|
||||
:icon-width="item.iconWidth || 30"
|
||||
:icon-height="item.iconHeight || 30"
|
||||
@click="$emit('item-click', item, index)"
|
||||
/>
|
||||
</ul>
|
||||
|
||||
<!-- Optional arrow button list -->
|
||||
<slot name="arrow-buttons" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import SearchBar from './SearchBar.vue'
|
||||
import SearchableListItem from './SearchableListItem.vue'
|
||||
import SkeletonText from '../Skeleton/SkeletonText.vue'
|
||||
import SkeletonBlock from '../Skeleton/SkeletonBlock.vue'
|
||||
|
||||
const props = defineProps({
|
||||
title: { type: String, default: '' },
|
||||
viewAllText: { type: String, default: '' },
|
||||
searchable: { type: Boolean, default: false },
|
||||
searchId: { type: String, default: 'searchable-list' },
|
||||
searchPlaceholder: { type: String, default: 'Search' },
|
||||
searchLeftIcon: { type: String, default: '' },
|
||||
searchRightIcon: { type: String, default: '' },
|
||||
emptyText: { type: String, default: 'No recent activity' },
|
||||
/**
|
||||
* Array of { title, subtitle?, rightText?, timestamp?, searchClass?, icon?, iconWidth?, iconHeight? }
|
||||
*/
|
||||
items: { type: Array, required: true },
|
||||
loading: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
defineEmits(['view-all', 'item-click'])
|
||||
|
||||
const searchQuery = ref('')
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
if (!searchQuery.value) return props.items
|
||||
const q = searchQuery.value.toLowerCase()
|
||||
return props.items.filter(item => {
|
||||
const text = `${item.title} ${item.subtitle || ''}`.toLowerCase()
|
||||
return text.includes(q)
|
||||
})
|
||||
})
|
||||
|
||||
const formatTimestamp = (ts) => {
|
||||
if (!ts) return ''
|
||||
try {
|
||||
const date = new Date(ts)
|
||||
const now = new Date()
|
||||
const diffMs = now - date
|
||||
const diffMins = Math.floor(diffMs / 60000)
|
||||
const diffHours = Math.floor(diffMins / 60)
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
|
||||
if (diffMins < 1) return 'Just now'
|
||||
if (diffMins < 60) return `${diffMins}m ago`
|
||||
if (diffHours < 24) return `${diffHours}h ago`
|
||||
if (diffDays < 7) return `${diffDays}d ago`
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const formatSubtitle = (item) => {
|
||||
if (item.subtitle) return item.subtitle
|
||||
if (item.type === 'transaction') return 'System transaction'
|
||||
return ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.activity-list {
|
||||
list-style: none;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
135
resources/js/Components/Core/Search/SearchableListItem.vue
Normal file
135
resources/js/Components/Core/Search/SearchableListItem.vue
Normal file
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<li class="activity-list-item" :class="searchClass" @click="$emit('click', $event)">
|
||||
<div class="activity-icon-box">
|
||||
<img
|
||||
v-if="icon"
|
||||
:src="icon"
|
||||
:style="{ width: normalizeSize(iconWidth), height: normalizeSize(iconHeight) }"
|
||||
class="activity-icon"
|
||||
/>
|
||||
<i v-else class="fas fa-circle activity-icon-default"></i>
|
||||
</div>
|
||||
<div class="activity-content">
|
||||
<div class="activity-header">
|
||||
<h4 class="activity-title">
|
||||
<a href="javascript:void(0);">
|
||||
{{ title }}
|
||||
</a>
|
||||
</h4>
|
||||
<span v-if="rightText" class="activity-right-text">{{ rightText }}</span>
|
||||
</div>
|
||||
<p v-if="subtitle" class="activity-subtitle">{{ subtitle }}</p>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
title: { type: String, required: true },
|
||||
subtitle: { type: String, default: '' },
|
||||
rightText: { type: String, default: '' },
|
||||
searchClass: { type: String, default: '' },
|
||||
icon: { type: String, default: '' },
|
||||
iconWidth: { type: [String, Number], default: 30 },
|
||||
iconHeight: { type: [String, Number], default: 30 },
|
||||
})
|
||||
|
||||
defineEmits(['click'])
|
||||
|
||||
const normalizeSize = (val) => {
|
||||
if (typeof val === 'number') return `${val}px`
|
||||
if (typeof val === 'string' && /^\d+$/.test(val)) return `${val}px`
|
||||
return val
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.activity-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 8px;
|
||||
border-radius: 12px;
|
||||
transition: background-color 0.2s ease;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--bs-border-color-translucent, #ededed);
|
||||
}
|
||||
|
||||
.activity-list-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.activity-list-item:hover {
|
||||
background-color: var(--bs-tertiary-bg, rgba(0, 0, 0, 0.03));
|
||||
}
|
||||
|
||||
.activity-icon-box {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
min-width: 40px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: var(--bs-primary-bg-subtle, rgba(13, 110, 253, 0.08));
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.activity-icon {
|
||||
object-fit: contain;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.activity-icon-default {
|
||||
font-size: 12px;
|
||||
color: var(--bs-secondary-color, #717171);
|
||||
}
|
||||
|
||||
.activity-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.activity-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.activity-title {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
margin: 0;
|
||||
color: var(--bs-emphasis-color, #1e1e1e);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.activity-title a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.activity-right-text {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--bs-primary, #533dea);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.activity-subtitle {
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
color: var(--bs-secondary-color, #717171);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user