initial: bootstrap from BukidBountyApp base

This commit is contained in:
Jonathan Sykes
2026-06-06 18:43:00 +08:00
commit eb4a5731fb
5674 changed files with 160857 additions and 0 deletions

View File

@@ -0,0 +1,149 @@
<template>
<button
ref="buttonRef"
:class="['animated-button', btnClass, { 'is-loading': loading, 'is-success': success, 'is-disabled': disabled || loading }]"
:disabled="disabled || loading"
@click="handleClick"
@mousedown="createRipple"
>
<div class="button-content" :class="{ 'opacity-0': loading || success }">
<slot></slot>
</div>
<!-- Loading State -->
<div v-if="loading" class="button-overlay">
<LottiePlayer
path="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/79a4fc375bae.json"
width="40px"
height="40px"
:loop="true"
/>
</div>
<!-- Success State -->
<div v-if="success" class="button-overlay">
<LottiePlayer
path="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/11999b7bb57c.json"
width="30px"
height="30px"
:loop="false"
/>
</div>
<span v-for="ripple in ripples" :key="ripple.id" class="ripple" :style="ripple.style"></span>
</button>
</template>
<script setup>
import { ref, reactive } from 'vue';
import LottiePlayer from './LottiePlayer.vue';
const props = defineProps({
btnClass: { type: String, default: 'btn btn-primary' },
loading: { type: Boolean, default: false },
success: { type: Boolean, default: false },
disabled: { type: Boolean, default: false }
});
const emit = defineEmits(['click']);
const buttonRef = ref(null);
const ripples = reactive([]);
const handleClick = (e) => {
if (props.loading || props.success || props.disabled) return;
emit('click', e);
};
const createRipple = (event) => {
if (props.disabled || props.loading || props.success) return;
const button = buttonRef.value;
if (!button) return;
const rect = button.getBoundingClientRect();
const size = Math.max(rect.width, rect.height);
const x = event.clientX - rect.left - size / 2;
const y = event.clientY - rect.top - size / 2;
const id = Date.now();
ripples.push({
id,
style: {
width: `${size}px`,
height: `${size}px`,
top: `${y}px`,
left: `${x}px`
}
});
setTimeout(() => {
const index = ripples.findIndex(r => r.id === id);
if (index > -1) ripples.splice(index, 1);
}, 600);
};
</script>
<style scoped>
.animated-button {
position: relative;
overflow: hidden;
display: inline-flex;
align-items: center;
justify-content: center;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
border: none;
outline: none;
}
.button-content {
transition: opacity 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.button-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.ripple {
position: absolute;
background: rgba(255, 255, 255, 0.4);
border-radius: 50%;
transform: scale(0);
animation: ripple-animation 0.6s linear;
pointer-events: none;
}
@keyframes ripple-animation {
to {
transform: scale(4);
opacity: 0;
}
}
.is-loading, .is-success {
pointer-events: none;
}
.opacity-0 {
opacity: 0;
}
/* Base button styles if not provided by framework */
.btn {
padding: 0.6rem 1.5rem;
border-radius: 12px;
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,83 @@
<template>
<div ref="container" :style="{ width: props.width, height: props.height }" class="lottie-container"></div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch } from 'vue';
import lottie from 'lottie-web';
const props = defineProps({
path: { type: String, required: true },
loop: { type: Boolean, default: true },
autoplay: { type: Boolean, default: true },
width: { type: String, default: '100%' },
height: { type: String, default: '100%' },
play: { type: Boolean, default: true },
speed: { type: Number, default: 1 }
});
const container = ref(null);
let animation = null;
const initAnimation = () => {
if (animation) {
animation.destroy();
}
if (!container.value) return;
animation = lottie.loadAnimation({
container: container.value,
renderer: 'svg',
loop: props.loop,
autoplay: props.autoplay,
path: props.path
});
if (props.speed !== 1) {
animation.setSpeed(props.speed);
}
};
onMounted(() => {
initAnimation();
});
onUnmounted(() => {
if (animation) {
animation.destroy();
}
});
watch(() => props.path, () => {
initAnimation();
});
watch(() => props.play, (newVal) => {
if (animation) {
if (newVal) {
animation.play();
} else {
animation.stop();
}
}
});
defineExpose({
play: () => animation?.play(),
stop: () => animation?.stop(),
pause: () => animation?.pause(),
setSpeed: (speed) => animation?.setSpeed(speed),
goToAndPlay: (value, isFrame) => animation?.goToAndPlay(value, isFrame)
});
</script>
<style scoped>
.lottie-container {
overflow: hidden;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
}
</style>

View File

@@ -0,0 +1,44 @@
<template>
<transition
:name="transitionName"
mode="out-in"
>
<div :key="routeKey" class="route-wrapper">
<slot></slot>
</div>
</transition>
</template>
<script setup>
import { ref } from 'vue';
const props = defineProps({
routeKey: { type: String, required: true }
});
const transitionName = ref('route-fade');
</script>
<style scoped>
.route-wrapper {
width: 100%;
height: 100%;
}
/* Fast, lightweight fade transition — keeps navigation feeling instant */
.route-fade-enter-active {
transition: opacity 0.12s ease-out;
}
.route-fade-leave-active {
transition: opacity 0.08s ease-in;
}
.route-fade-enter-from {
opacity: 0;
}
.route-fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,124 @@
<script setup>
import { useNavigate } from '../../composables/Core/useNavigate'
const props = defineProps({
to: {
type: [String, Object],
default: null
},
fallback: {
type: String,
default: 'Home'
},
text: {
type: String,
default: 'Back'
},
className: {
type: String,
default: ''
}
})
const { navigate } = useNavigate()
const goBack = () => {
if (props.to) {
if (typeof props.to === 'string') {
navigate({ page: props.to })
} else {
navigate(props.to)
}
} else if (window.history.length > 1) {
window.history.back()
} else {
navigate({ page: props.fallback })
}
}
</script>
<template>
<button @click="goBack" class="back-button-premium" :class="className" type="button">
<div class="icon-container">
<i class="fas fa-chevron-left"></i>
</div>
<span v-if="text" class="back-text">{{ text }}</span>
</button>
</template>
<style scoped>
.back-button-premium {
display: inline-flex;
align-items: center;
gap: 8px;
background: var(--bg-card, rgba(255, 255, 255, 0.8));
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.05));
padding: 6px 14px;
border-radius: 50px;
color: var(--text-primary, #333);
font-weight: 600;
font-size: 14px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
cursor: pointer;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
outline: none;
}
.back-button-premium:hover {
transform: translateX(-3px);
background: var(--bg-primary, #fff);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
border-color: var(--border-color, rgba(0, 0, 0, 0.1));
}
.back-button-premium:active {
transform: translateX(-1px) scale(0.97);
}
.icon-container {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background: var(--bg-tertiary, #f8f9fa);
color: var(--text-primary, #1a1a1a);
border-radius: 50%;
font-size: 10px;
transition: all 0.3s ease;
}
.back-button-premium:hover .icon-container {
background: var(--text-primary, #1a1a1a);
color: var(--bg-primary, #fff);
}
.back-text {
letter-spacing: -0.2px;
}
/* Dark mode support */
:global(.dark-mode) .back-button-premium {
background: rgba(30, 32, 38, 0.8);
border-color: rgba(255, 255, 255, 0.15);
color: #fff;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.4);
}
:global(.dark-mode) .icon-container {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
:global(.dark-mode) .back-button-premium:hover {
background: rgba(55, 60, 75, 1);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.3);
}
:global(.dark-mode) .back-button-premium:hover .icon-container {
background: #fff;
color: #1a1a1a;
}
</style>

View File

@@ -0,0 +1,97 @@
<template>
<Teleport to="body">
<!-- modal -->
<div v-if="modelValue" class="modal fade show d-block" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<!-- HEADER -->
<div v-if="showHeader" class="modal-header">
<h4 class="modal-title">{{ modalTitle }}</h4>
<h4 class="cursor-pointer" @click="$emit('update:modelValue', false)">×</h4>
</div>
<!-- BODY -->
<div class="modal-body">
<slot />
<!-- If string, render as text; if component/VNode, render directly -->
<template v-if="isString">
<div v-html="body"></div>
</template>
<template v-else-if="body">
<component :is="body" />
</template>
</div>
<!-- FOOTER -->
<div class="modal-footer w-100">
<slot name="footer" />
<button v-if="footerClose && !$slots.footer" class="btn btn-primary w-100 py-2 rounded-3 shadow-sm fw-bold" @click="$emit('update:modelValue', false)">
OK
</button>
</div>
</div>
</div>
</div>
<!-- backdrop -->
<div v-if="modelValue" class="modal-backdrop fade show"></div>
</Teleport>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
modelValue: Boolean,
modalTitle: [String, Boolean],
body: [String, Object], // support string or VNode/component
footerClose: { type: Boolean, default: true }
})
const showHeader = computed(() => props.modalTitle !== false)
const isString = computed(() => typeof props.body === 'string')
</script>
<style scoped>
.modal {
z-index: 30001;
}
.modal-content {
max-height: 85vh;
display: flex;
flex-direction: column;
}
.modal-body {
overflow-y: auto;
flex: 1;
}
.modal-backdrop {
z-index: 30000;
}
.modal-dialog {
z-index: 30002;
margin-top: 5vh;
}
.modal-title {
margin: 0;
font-weight: 700;
}
.modal-header h4:last-child {
font-size: 24px;
line-height: 1;
color: #999;
transition: color 0.2s;
}
.modal-header h4:last-child:hover {
color: #333;
}
</style>

View File

@@ -0,0 +1,104 @@
<template>
<div
class="card-custom"
:id="id"
:style="cardStyle"
:class="[
`shadow-${shadow}`,
{ 'is-premium': isPremium, 'no-padding': noPadding }
]"
>
<div v-if="title" class="card-header-custom">
<h4 class="card-title">{{ title }}</h4>
<div v-if="$slots.headerAction" class="header-action">
<slot name="headerAction" />
</div>
</div>
<div class="card-body-custom">
<slot />
</div>
<div v-if="$slots.footer" class="card-footer-custom">
<slot name="footer" />
</div>
</div>
</template>
<script setup>
defineProps({
title: { type: String, default: '' },
id: { type: String, default: '' },
cardStyle: { type: String, default: '' },
isPremium: { type: Boolean, default: true },
noPadding: { type: Boolean, default: false },
shadow: { type: String, default: 'md', validator: (v) => ['none', 'sm', 'md', 'lg'].includes(v) },
})
</script>
<style scoped>
.card-custom {
background: var(--bg-card, #ffffff);
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.08));
border-radius: 16px;
overflow: hidden;
transition: all 0.3s ease;
height: 100%;
display: flex;
flex-direction: column;
}
.card-header-custom {
padding: 1.5rem;
border-bottom: 1px solid var(--border-color, rgba(0, 0, 0, 0.05));
display: flex;
align-items: center;
justify-content: space-between;
}
.card-title {
margin: 0;
font-size: 1.25rem;
font-weight: 700;
color: var(--text-primary, #1e1e1e);
}
.card-body-custom {
padding: 1.5rem;
flex-grow: 1;
}
.no-padding .card-body-custom {
padding: 0;
}
.card-footer-custom {
padding: 1.25rem 1.5rem;
border-top: 1px solid var(--border-color, rgba(0, 0, 0, 0.05));
background: var(--bg-secondary, rgba(0, 0, 0, 0.02));
}
/* Premium / Glassmorphism */
.is-premium {
background: rgba(255, 255, 255, 0.6);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(0, 0, 0, 0.05);
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.07);
}
:global(.dark-mode) .is-premium {
background: rgba(30, 41, 59, 0.7);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3);
}
.is-premium:hover {
border-color: var(--accent-color, rgba(83, 61, 234, 0.3));
transform: translateY(-2px);
}
/* Shadows */
.shadow-none { box-shadow: none; }
.shadow-sm { box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05); }
.shadow-md { box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); }
.shadow-lg { box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); }
</style>

View File

@@ -0,0 +1,115 @@
<template>
<BaseModal
:modelValue="modelValue"
@update:modelValue="$emit('update:modelValue', $event)"
:modalTitle="false"
>
<div class="confirm-modal-body p-4 text-center">
<div v-if="variant === 'danger'" class="icon-circle bg-soft-danger text-danger mb-3 mx-auto">
<i class="fas fa-exclamation-triangle fa-2x"></i>
</div>
<div v-else-if="variant === 'warning'" class="icon-circle bg-soft-warning text-warning mb-3 mx-auto">
<i class="fas fa-exclamation-circle fa-2x"></i>
</div>
<div v-else class="icon-circle bg-soft-primary text-primary mb-3 mx-auto">
<i class="fas fa-info-circle fa-2x"></i>
</div>
<h3 class="fw_8 mb-2">{{ title }}</h3>
<p class="text-muted mb-0">{{ message }}</p>
</div>
<template #footer>
<div class="d-flex w-100 gap-2 mb-2 px-2">
<button type="button" class="btn btn-light flex-fill rounded-pill py-2 fw_6" @click="handleCancel">
{{ cancelText }}
</button>
<button type="button" :class="confirmBtnClass" class="btn flex-fill rounded-pill py-2 fw_6" @click="handleConfirm">
{{ confirmText }}
</button>
</div>
</template>
</BaseModal>
</template>
<script setup>
import { computed } from 'vue';
import BaseModal from './BaseModal.vue';
const props = defineProps({
modelValue: { type: Boolean, required: true },
title: { type: String, default: 'Confirm Action' },
message: { type: String, default: 'Are you sure you want to proceed?' },
confirmText: { type: String, default: 'Confirm' },
cancelText: { type: String, default: 'Cancel' },
variant: { type: String, default: 'info' } // danger, warning, info
});
const emit = defineEmits(['update:modelValue', 'confirm', 'cancel']);
const confirmBtnClass = computed(() => {
if (props.variant === 'danger') return 'btn-danger shadow-danger';
if (props.variant === 'warning') return 'btn-warning shadow-warning';
return 'btn-primary shadow-primary';
});
const handleCancel = () => {
emit('cancel');
emit('update:modelValue', false);
};
const handleConfirm = () => {
emit('confirm');
emit('update:modelValue', false);
};
</script>
<style scoped>
.confirm-modal-body {
min-height: 180px;
display: flex;
flex-direction: column;
justify-content: center;
}
.icon-circle {
width: 70px;
height: 70px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.confirm-modal-body:hover .icon-circle {
transform: scale(1.1);
}
.bg-soft-danger { background-color: rgba(231, 76, 60, 0.1); }
.bg-soft-warning { background-color: rgba(243, 156, 18, 0.1); }
.bg-soft-primary { background-color: rgba(52, 152, 219, 0.1); }
.shadow-danger {
background-color: #e74c3c !important;
border-color: #e74c3c !important;
color: white !important;
box-shadow: 0 4px 14px rgba(231, 76, 60, 0.4);
}
.shadow-warning {
background-color: #f39c12 !important;
border-color: #f39c12 !important;
color: white !important;
box-shadow: 0 4px 14px rgba(243, 156, 18, 0.4);
}
.shadow-primary {
background-color: #3498db !important;
border-color: #3498db !important;
color: white !important;
box-shadow: 0 4px 14px rgba(52, 152, 219, 0.4);
}
:global(.dark-mode) .icon-circle {
filter: brightness(1.2);
}
</style>

View File

@@ -0,0 +1,423 @@
<template>
<div
class="dropzone-container"
:class="{ 'is-dragging': isDragging, 'has-error': !!error }"
@dragenter.prevent="isDragging = true"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="onDrop"
>
<div v-if="files.length === 0" class="dropzone-placeholder" @click="triggerFileInput">
<div class="icon-wrapper">
<img src="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/b1ef4a83a864.svg" class="cloud-icon" style="width: 48px; height: 48px; opacity: 0.5;" />
</div>
<p class="main-text">Click to upload or drag and drop</p>
<p class="sub-text">SVG, PNG, JPG or GIF (max. {{ maxSizeMB }}MB)</p>
</div>
<div v-else class="previews-grid">
<div v-for="(file, index) in files" :key="index" class="preview-card" :class="{ 'is-main': index === 0 }">
<label v-if="index === 0" class="main-photo-badge">Main Photo</label>
<img :src="file.preview" :alt="file.name" class="preview-img" />
<div class="preview-overlay">
<button v-if="index !== 0" @click.stop="setAsMain(index)" class="set-main-btn" title="Set as main photo">
<img src="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/8fad7e49d466.svg" style="width: 18px; height: 18px; filter: brightness(0) invert(1);" />
</button>
<button @click.stop="removeFile(index)" class="remove-btn" title="Remove image">
<img src="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/b6dc254166b4.bin" style="width: 16px; height: 16px; filter: brightness(0) invert(1);" />
</button>
</div>
<div v-if="file.uploading" class="progress-bar">
<div class="progress-fill" :style="{ width: file.progress + '%' }"></div>
</div>
<div v-if="file.error" class="error-badge" :title="file.error">!</div>
</div>
<div class="add-more-card" @click="triggerFileInput" title="Add more images">
<img src="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/8d3954387e14.svg" style="width: 24px; height: 24px; opacity: 0.6;" />
</div>
</div>
<input
ref="fileInput"
type="file"
multiple
class="hidden-input"
:accept="accept"
@change="onFileChange"
/>
<transition name="fade">
<div v-if="error" class="error-message">{{ error }}</div>
</transition>
</div>
</template>
<script setup>
import { ref, onUnmounted, watch } from 'vue';
const props = defineProps({
files: {
type: Array,
default: () => []
},
accept: {
type: String,
default: 'image/*'
},
maxSizeMB: {
type: Number,
default: 10
}
});
const emit = defineEmits(['update:files', 'removed']);
const isDragging = ref(false);
const fileInput = ref(null);
const files = ref([]);
const error = ref(null);
// Initialize internal files with prop on mount
files.value = [...props.files];
// Sync internal files with prop when it explicitly changes reference or length
watch(() => props.files, (newFiles) => {
if (newFiles !== files.value) {
// Preserve internal hashes if they are not in the new prop yet
// This allows the child to keep the 'hashkey' it just received while the parent is still updating
files.value = newFiles.map((newF, index) => {
const existingF = files.value[index];
if (existingF && existingF.file === newF.file && !newF.hashkey && existingF.hashkey) {
return { ...newF, hashkey: existingF.hashkey };
}
return { ...newF };
});
}
}, { deep: false });
const triggerFileInput = () => {
fileInput.value.click();
};
const validateFile = (file) => {
if (file.size > props.maxSizeMB * 1024 * 1024) {
return { valid: false, error: `File ${file.name} is too large. Max ${props.maxSizeMB}MB.` };
}
const acceptedTypes = props.accept.split(',').map(t => t.trim());
const isAccepted = acceptedTypes.some(type => {
if (type.endsWith('/*')) {
return file.type.startsWith(type.replace('*', ''));
}
return file.type === type;
});
if (!isAccepted && props.accept !== '*/*') {
return { valid: false, error: `File ${file.name} has invalid type.` };
}
return { valid: true };
};
const addFiles = (newFiles) => {
error.value = null;
const filteredFiles = Array.from(newFiles).map(file => {
const validation = validateFile(file);
if (!validation.valid) {
error.value = validation.error;
return null;
}
return {
file,
name: file.name,
preview: URL.createObjectURL(file),
uploading: false,
progress: 0,
error: null,
hashkey: null
};
}).filter(f => f !== null);
files.value = [...files.value, ...filteredFiles];
emit('update:files', files.value);
};
const onFileChange = (e) => {
addFiles(e.target.files);
};
const onDrop = (e) => {
isDragging.value = false;
addFiles(e.dataTransfer.files);
};
const removeFile = (index) => {
const removedFile = files.value[index];
if (removedFile.preview) {
URL.revokeObjectURL(removedFile.preview);
}
files.value.splice(index, 1);
emit('update:files', files.value);
if (removedFile.hashkey) {
emit('removed', removedFile.hashkey);
}
};
const setAsMain = (index) => {
if (index === 0) return;
const mainFile = files.value.splice(index, 1)[0];
files.value.unshift(mainFile);
emit('update:files', files.value);
};
onUnmounted(() => {
files.value.forEach(file => {
if (file.preview) URL.revokeObjectURL(file.preview);
});
});
defineExpose({
clear: () => {
files.value.forEach(file => {
if (file.preview) URL.revokeObjectURL(file.preview);
});
files.value = [];
emit('update:files', []);
},
setFileStatus: (index, status) => {
if (files.value[index]) {
files.value[index] = { ...files.value[index], ...status };
emit('update:files', [...files.value]);
}
}
});
</script>
<style scoped>
.dropzone-container {
width: 100%;
min-height: 180px;
border: 2px dashed #e2e8f0;
border-radius: 16px;
background: #f8fafc;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
overflow: hidden;
padding: 20px;
}
.dropzone-container:hover {
border-color: #3b82f6;
background: #eff6ff;
}
.dropzone-container.is-dragging {
border-color: #3b82f6;
background: #dbeafe;
transform: scale(1.01);
}
.dropzone-container.has-error {
border-color: #ef4444;
background: #fef2f2;
}
.dropzone-placeholder {
text-align: center;
color: #64748b;
}
.icon-wrapper {
margin-bottom: 12px;
color: #94a3b8;
}
.cloud-icon {
width: 48px;
height: 48px;
}
.main-text {
font-weight: 600;
color: #1e293b;
margin-bottom: 4px;
}
.sub-text {
font-size: 0.85rem;
color: #94a3b8;
}
.previews-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 12px;
width: 100%;
}
.preview-card {
position: relative;
aspect-ratio: 1;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
background: #fff;
}
.preview-card.is-main {
border: 3px solid #3b82f6;
transform: scale(1.02);
z-index: 2;
}
.main-photo-badge {
position: absolute;
top: 8px;
left: 8px;
background: #2563eb !important;
color: white !important;
padding: 4px 10px;
border-radius: 6px;
font-size: 0.75rem;
font-weight: 800;
text-transform: uppercase;
z-index: 20;
box-shadow: 0 4px 6px rgba(37, 99, 235, 0.4);
letter-spacing: 0.05em;
}
.preview-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.preview-overlay {
position: absolute;
top: 8px;
right: 8px;
opacity: 0;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
align-items: center;
gap: 8px;
z-index: 25;
}
.preview-card:hover .preview-overlay {
opacity: 1;
transform: translateY(2px);
}
.remove-btn, .set-main-btn {
color: white !important;
border: none;
border-radius: 50%;
width: 34px;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.15);
}
.remove-btn {
background: #ef4444 !important;
}
.set-main-btn {
background: #2563eb !important;
}
.remove-btn svg, .set-main-btn svg {
display: block;
pointer-events: none;
}
.remove-btn:hover, .set-main-btn:hover {
transform: scale(1.1);
filter: brightness(1.1);
}
.add-more-card {
aspect-ratio: 1;
border: 2px dashed #cbd5e1;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
color: #94a3b8;
background: #fff;
transition: all 0.2s;
}
.add-more-card:hover {
border-color: #3b82f6;
color: #3b82f6;
background: #f1f5f9;
}
.progress-bar {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 4px;
background: rgba(255, 255, 255, 0.3);
}
.progress-fill {
height: 100%;
background: #3b82f6;
transition: width 0.3s;
}
.error-badge {
position: absolute;
top: 4px;
right: 4px;
background: #ef4444;
color: white;
width: 20px;
height: 20px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 12px;
}
.error-message {
position: absolute;
bottom: 8px;
left: 50%;
transform: translateX(-50%);
background: #ef4444;
color: white;
padding: 4px 12px;
border-radius: 20px;
font-size: 0.8rem;
white-space: nowrap;
}
.hidden-input {
display: none;
}
.fade-enter-active, .fade-leave-active {
transition: opacity 0.3s;
}
.fade-enter-from, .fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,38 @@
<template>
<img :src="processedSrc" :alt="alt" @error="handleError" v-bind="$attrs" />
</template>
<script setup>
import { computed } from "vue";
const props = defineProps({
src: { type: String, default: "" },
fallback: { type: String, default: "https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/1c00e9b46132.bin" },
alt: { type: String, default: "" },
});
const processedSrc = computed(() => {
if (!props.src) return props.fallback;
if (Array.isArray(props.src)) return props.src[0];
if (
props.src.startsWith("http") ||
props.src.startsWith("/") ||
props.src.startsWith("data:")
) {
return props.src;
}
// Assume it's a hash if it's long and doesn't have common URL markers
if (
props.src.length > 20 &&
!props.src.includes(".") &&
!props.src.includes("/")
) {
return `/RequestData/File/${props.src}`;
}
return props.src;
});
const handleError = (e) => {
e.target.src = props.fallback;
};
</script>

View File

@@ -0,0 +1,184 @@
<template>
<div :class="['form-group mb-3', { 'has-error': error }]">
<label v-if="label" :for="id" class="form-label d-flex align-items-center">
{{ label }}
<span v-if="required" class="text-danger ms-1">*</span>
</label>
<div
class="input-group-container"
:class="[
`variant-${variant}`,
{ 'is-premium': isPremium, 'is-invalid': error, 'is-disabled': disabled }
]"
>
<div class="input-wrapper">
<input
:type="type"
:id="id"
:name="id"
:class="['form-control-custom', inputClass]"
:placeholder="placeholder"
:value="modelValue"
:required="required"
:disabled="disabled"
:list="datalistId"
@input="$emit('update:modelValue', $event.target.value)"
/>
<div class="input-addons">
<!-- Icon/span append -->
<IconImage
v-if="icon"
:src="icon"
:width="iconWidth"
:height="iconHeight"
:id="`imgspan${id}`"
/>
<span v-else-if="spanClass" :class="spanClass" :id="`${id}-span`"></span>
</div>
</div>
</div>
<div v-if="hint && !error" class="form-hint mt-1">{{ hint }}</div>
<div v-if="error" class="form-error mt-1">{{ error }}</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import IconImage from '../IconImage.vue'
const props = defineProps({
label: { type: String, default: '' },
type: { type: String, default: 'text' },
id: { type: String, required: true },
placeholder: { type: String, default: '' },
modelValue: { type: [String, Number], default: '' },
required: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
error: { type: String, default: '' },
hint: { type: String, default: '' },
isPremium: { type: Boolean, default: true },
variant: { type: String, default: 'default', validator: (v) => ['default', 'glass', 'soft'].includes(v) },
inputClass: { type: String, default: '' },
spanClass: { type: String, default: '' },
icon: { type: String, default: '' },
iconWidth: { type: [String, Number], default: '20px' },
iconHeight: { type: [String, Number], default: '20px' },
datalistId: { type: String, default: '' },
})
defineEmits(['update:modelValue'])
</script>
<style scoped>
.form-group {
width: 100%;
}
.form-label {
font-size: 0.875rem;
font-weight: 600;
margin-bottom: 0.5rem;
color: var(--text-primary, #1e1e1e);
}
.input-group-container {
position: relative;
transition: all 0.2s ease;
}
.input-wrapper {
display: flex;
align-items: center;
position: relative;
}
.form-control-custom {
width: 100%;
padding: 0.75rem 1rem;
background: var(--bg-card, #ffffff);
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.08));
border-radius: 12px;
color: var(--text-primary, #1e1e1e);
font-size: 1rem;
transition: all 0.2s ease;
}
:global(.dark-mode) .form-control-custom {
background: var(--bg-secondary, #1a1c22);
}
.form-control-custom:focus {
outline: none;
border-color: var(--accent-color, #533dea);
box-shadow: 0 0 0 3px var(--accent-soft, rgba(83, 61, 234, 0.2));
}
/* Glass Variant */
.variant-glass .form-control-custom {
background: rgba(255, 255, 255, 0.4);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
:global(.dark-mode) .variant-glass .form-control-custom {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
}
.variant-glass .form-control-custom:focus {
background: rgba(255, 255, 255, 0.6);
border-color: var(--accent-color, #533dea);
}
:global(.dark-mode) .variant-glass .form-control-custom:focus {
background: rgba(255, 255, 255, 0.1);
}
/* Soft Variant */
.variant-soft .form-control-custom {
background: var(--bg-tertiary, #f0f2f5);
border: none;
}
/* Premium Styles */
.is-premium .form-control-custom {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
}
:global(.dark-mode) .is-premium .form-control-custom {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.2);
}
/* States */
.is-invalid .form-control-custom {
border-color: #ef4444;
}
.is-disabled .form-control-custom {
opacity: 0.6;
cursor: not-allowed;
}
.input-addons {
position: absolute;
right: 1rem;
display: flex;
align-items: center;
pointer-events: none;
}
.form-hint {
font-size: 0.75rem;
color: var(--text-muted, #a0a0a0);
}
.form-error {
font-size: 0.75rem;
color: #ef4444;
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,162 @@
<template>
<button
:class="[
'btn-custom',
`btn-${variant}`,
`btn-${size}`,
{ 'is-loading': loading, 'is-disabled': disabled || loading }
]"
:id="id"
:style="buttonStyle"
:disabled="disabled || loading"
@click="$emit('click', $event)"
>
<div class="btn-content">
<LoadingSpinner v-if="loading" size="sm" color="currentColor" class="me-2" />
<slot>
{{ text }}
</slot>
</div>
</button>
</template>
<script setup>
import LoadingSpinner from '@/Components/LoadingSpinner.vue'
defineProps({
text: { type: String, default: '' },
variant: { type: String, default: 'primary', validator: (v) => ['primary', 'secondary', 'outline', 'danger', 'glass', 'text'].includes(v) },
id: { type: String, default: '' },
buttonStyle: { type: String, default: '' },
loading: { type: Boolean, default: false },
size: { type: String, default: 'md', validator: (v) => ['sm', 'md', 'lg'].includes(v) },
disabled: { type: Boolean, default: false },
})
defineEmits(['click'])
</script>
<style scoped>
.btn-custom {
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 600;
border-radius: 12px;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
border: 1px solid transparent;
cursor: pointer;
position: relative;
overflow: hidden;
gap: 0.5rem;
}
.btn-content {
display: flex;
align-items: center;
justify-content: center;
z-index: 1;
}
/* Sizes */
.btn-sm { padding: 0.5rem 1rem; font-size: 0.875rem; }
.btn-md { padding: 0.75rem 1.5rem; font-size: 1rem; }
.btn-lg { padding: 1rem 2rem; font-size: 1.125rem; }
/* Variants */
.btn-primary {
background: linear-gradient(135deg, var(--accent-color, #4f46e5) 0%, #3730a3 100%);
color: white;
box-shadow: 0 4px 6px -1px var(--accent-soft, rgba(79, 70, 229, 0.4));
}
.btn-primary:hover:not(.is-disabled) {
transform: translateY(-2px);
box-shadow: 0 10px 15px -3px var(--accent-soft, rgba(79, 70, 229, 0.5));
}
.btn-secondary {
background: var(--bg-tertiary, #f0f2f5);
color: var(--text-primary, #1e1e1e);
}
:global(.dark-mode) .btn-secondary {
background: var(--bg-tertiary, #2d3748);
color: white;
}
.btn-secondary:hover:not(.is-disabled) {
background: var(--border-color, #e2e8f0);
}
:global(.dark-mode) .btn-secondary:hover:not(.is-disabled) {
background: #3d4a5d;
}
.btn-outline {
background: transparent;
border-color: var(--border-color, rgba(0, 0, 0, 0.1));
color: var(--text-primary, #1e1e1e);
}
:global(.dark-mode) .btn-outline {
border-color: rgba(255, 255, 255, 0.2);
color: white;
}
.btn-outline:hover:not(.is-disabled) {
background: var(--accent-soft, rgba(0, 0, 0, 0.05));
border-color: var(--accent-color, rgba(0, 0, 0, 0.4));
}
:global(.dark-mode) .btn-outline:hover:not(.is-disabled) {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.4);
}
.btn-danger {
background: #ef4444;
color: white;
}
.btn-danger:hover:not(.is-disabled) {
background: #dc2626;
}
.btn-glass {
background: rgba(255, 255, 255, 0.4);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
color: var(--text-primary, #1e1e1e);
}
:global(.dark-mode) .btn-glass {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.1);
color: white;
}
.btn-glass:hover:not(.is-disabled) {
background: rgba(255, 255, 255, 0.5);
}
:global(.dark-mode) .btn-glass:hover:not(.is-disabled) {
background: rgba(255, 255, 255, 0.15);
}
.btn-text {
background: transparent;
color: var(--text-secondary, #717171);
}
.btn-text:hover:not(.is-disabled) {
color: var(--text-primary, #1e1e1e);
}
:global(.dark-mode) .btn-text:hover:not(.is-disabled) {
color: white;
}
/* States */
.is-disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none !important;
}
.is-loading {
cursor: wait;
}
.btn-custom:active:not(.is-disabled) {
transform: scale(0.98);
}
</style>

View File

@@ -0,0 +1,168 @@
<template>
<div :class="['form-check-container mb-3', { 'has-error': error, 'is-switch': isSwitch }]">
<label :for="id" class="checkbox-wrapper">
<input
type="checkbox"
:id="id"
class="checkbox-input"
:checked="isChecked"
:disabled="disabled"
:value="value"
@change="handleChange"
/>
<div class="checkbox-custom" :class="{ 'is-premium': isPremium }">
<div v-if="isSwitch" class="switch-handle"></div>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" class="check-icon"><path d="M20 6 9 17l-5-5"/></svg>
</div>
<span class="checkbox-label" v-if="label">{{ label }}</span>
</label>
<div v-if="error" class="form-error mt-1">{{ error }}</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
label: { type: String, default: '' },
id: { type: String, required: true },
modelValue: { type: [Boolean, Array], default: false },
value: { type: [String, Number, Boolean], default: null },
disabled: { type: Boolean, default: false },
isSwitch: { type: Boolean, default: false },
isPremium: { type: Boolean, default: true },
error: { type: String, default: '' },
})
const emit = defineEmits(['update:modelValue'])
const isChecked = computed(() => {
if (Array.isArray(props.modelValue)) {
return props.modelValue.includes(props.value)
}
return props.modelValue
})
const handleChange = (event) => {
const checked = event.target.checked
if (Array.isArray(props.modelValue)) {
const newValue = [...props.modelValue]
if (checked) {
newValue.push(props.value)
} else {
const index = newValue.indexOf(props.value)
if (index > -1) newValue.splice(index, 1)
}
emit('update:modelValue', newValue)
} else {
emit('update:modelValue', checked)
}
}
</script>
<style scoped>
.form-check-container {
display: block;
min-height: 1.5rem;
}
.checkbox-wrapper {
display: flex;
align-items: center;
cursor: pointer;
user-select: none;
gap: 0.75rem;
}
.checkbox-input {
position: absolute;
opacity: 0;
cursor: pointer;
height: 0;
width: 0;
}
/* Custom Checkbox Design */
.checkbox-custom {
position: relative;
height: 22px;
width: 22px;
background: var(--bg-secondary, #f8f9fa);
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.1));
border-radius: 6px;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
align-items: center;
justify-content: center;
}
:global(.dark-mode) .checkbox-custom {
background: var(--bg-tertiary, #24272d);
border-color: rgba(255, 255, 255, 0.1);
}
.is-switch .checkbox-custom {
width: 44px;
border-radius: 20px;
}
.checkbox-input:checked ~ .checkbox-custom {
background: var(--accent-color, #533dea);
border-color: var(--accent-color, #533dea);
box-shadow: 0 4px 6px -1px var(--accent-soft, rgba(83, 61, 234, 0.4));
}
.check-icon {
color: white;
opacity: 0;
transform: scale(0.5);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.checkbox-input:checked ~ .checkbox-custom .check-icon {
opacity: 1;
transform: scale(1);
}
/* Switch Handle */
.switch-handle {
position: absolute;
left: 3px;
top: 3px;
height: 14px;
width: 14px;
background: white;
border-radius: 50%;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
}
.checkbox-input:checked ~ .checkbox-custom .switch-handle {
left: 25px;
}
/* Premium Effects */
.is-premium {
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.checkbox-wrapper:hover .checkbox-custom {
border-color: var(--accent-color, rgba(83, 61, 234, 0.3));
}
.checkbox-label {
font-size: 0.875rem;
color: var(--text-primary, #1e1e1e);
font-weight: 500;
}
.form-error {
font-size: 0.75rem;
color: #ef4444;
margin-left: 2rem;
}
.checkbox-input:disabled ~ .checkbox-custom {
opacity: 0.5;
cursor: not-allowed;
}
</style>

View File

@@ -0,0 +1,51 @@
<template>
<div>
<label v-if="label" :for="id">
<span v-html="label"></span>
<IconImage
v-if="!disabled"
src="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/b6dc254166b4.bin"
:id="`ClearUploadButton-${id}`"
class="clear-upload-btn"
@click="$emit('clear')"
/>
</label>
<div class="input-group mb-3">
<form
:id="id"
:action="uploadUrl"
:style="{ width: computedWidth, pointerEvents: disabled ? 'none' : 'auto' }"
class="dropzone"
:class="{ disabled: disabled }"
>
<slot>
<div class="dz-message">Drop files here or click to upload</div>
</slot>
</form>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import IconImage from '../IconImage.vue'
const props = defineProps({
label: { type: String, default: '' },
id: { type: String, required: true },
uploadUrl: { type: String, default: '/File/Upload/Unknown' },
width: { type: String, default: '100%' },
disabled: { type: Boolean, default: false },
})
defineEmits(['files-changed', 'clear'])
const computedWidth = computed(() => props.width || '100%')
</script>
<style scoped>
.clear-upload-btn {
cursor: pointer;
margin-left: 0.5rem;
}
</style>

View File

@@ -0,0 +1,48 @@
<template>
<InputGroup
:label="label"
type="number"
:id="id"
:placeholder="placeholder"
:model-value="modelValue"
:required="required"
:disabled="disabled"
:input-class="inputClass"
:icon="icon"
:icon-width="iconWidth"
:icon-height="iconHeight"
:datalist-id="datalistId"
v-bind="numberAttrs"
@update:model-value="$emit('update:modelValue', $event)"
/>
</template>
<script setup>
import { computed } from 'vue'
import InputGroup from './InputGroup.vue'
const props = defineProps({
label: { type: String, default: '' },
id: { type: String, required: true },
placeholder: { type: String, default: '' },
modelValue: { type: [String, Number], default: 0 },
min: { type: [String, Number], default: '' },
max: { type: [String, Number], default: '' },
required: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
inputClass: { type: String, default: '' },
icon: { type: String, default: '' },
iconWidth: { type: [String, Number], default: '40px' },
iconHeight: { type: [String, Number], default: '40px' },
datalistId: { type: String, default: '' },
})
defineEmits(['update:modelValue'])
const numberAttrs = computed(() => {
const attrs = {}
if (props.min !== '') attrs.min = props.min
if (props.max !== '') attrs.max = props.max
return attrs
})
</script>

View File

@@ -0,0 +1,191 @@
<template>
<div :class="['form-group mb-3', { 'has-error': error }]">
<label v-if="label" :for="id" class="form-label d-flex align-items-center">
{{ label }}
<span v-if="required" class="text-danger ms-1">*</span>
</label>
<div
class="input-group-container"
:class="[
`variant-${variant}`,
{ 'is-premium': isPremium, 'is-invalid': error, 'is-disabled': disabled }
]"
>
<div class="input-wrapper">
<select
:id="id"
:class="['form-control-custom', selectClass]"
:disabled="disabled"
:value="modelValue"
:required="required"
@change="$emit('update:modelValue', $event.target.value)"
>
<option v-if="placeholder" value="" disabled selected>{{ placeholder }}</option>
<option
v-for="(opt, index) in formattedOptions"
:key="index"
:value="opt.value"
>
{{ opt.text }}
</option>
</select>
<div class="select-arrow">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m6 9 6 6 6-6"/></svg>
</div>
<div v-if="icon || spanClass" class="input-addons">
<IconImage v-if="icon" :src="icon" :width="iconWidth" :height="iconHeight" />
<span v-else-if="spanClass" :class="spanClass"></span>
</div>
</div>
</div>
<div v-if="hint && !error" class="form-hint mt-1">{{ hint }}</div>
<div v-if="error" class="form-error mt-1">{{ error }}</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import IconImage from '../IconImage.vue'
const props = defineProps({
label: { type: String, default: '' },
id: { type: String, required: true },
modelValue: { type: [String, Number], default: '' },
options: { type: Array, default: () => [] },
placeholder: { type: String, default: '' },
required: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
error: { type: String, default: '' },
hint: { type: String, default: '' },
isPremium: { type: Boolean, default: true },
variant: { type: String, default: 'default' },
selectClass: { type: String, default: '' },
spanClass: { type: String, default: '' },
icon: { type: String, default: '' },
iconWidth: { type: [String, Number], default: '20px' },
iconHeight: { type: [String, Number], default: '20px' },
})
defineEmits(['update:modelValue'])
const formattedOptions = computed(() => {
return props.options.map(opt => {
if (typeof opt === 'string' || typeof opt === 'number') {
return { value: opt, text: opt }
}
return opt
})
})
</script>
<style scoped>
.form-group {
width: 100%;
}
.form-label {
font-size: 0.875rem;
font-weight: 600;
margin-bottom: 0.5rem;
color: var(--text-primary, #1e1e1e);
}
.input-group-container {
position: relative;
transition: all 0.2s ease;
}
.input-wrapper {
display: flex;
align-items: center;
position: relative;
}
.form-control-custom {
width: 100%;
padding: 0.75rem 1rem;
background: var(--bg-card, #ffffff);
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.08));
border-radius: 12px;
color: var(--text-primary, #1e1e1e);
font-size: 1rem;
appearance: none;
-webkit-appearance: none;
transition: all 0.2s ease;
cursor: pointer;
}
:global(.dark-mode) .form-control-custom {
background: var(--bg-secondary, #1a1c22);
}
.form-control-custom:focus {
outline: none;
border-color: var(--accent-color, #533dea);
box-shadow: 0 0 0 3px var(--accent-soft, rgba(83, 61, 234, 0.2));
}
.select-arrow {
position: absolute;
right: 1rem;
pointer-events: none;
color: var(--text-muted, #a0a0a0);
display: flex;
align-items: center;
}
/* Glass Variant */
.variant-glass .form-control-custom {
background: rgba(255, 255, 255, 0.4);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
:global(.dark-mode) .variant-glass .form-control-custom {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
}
/* Premium Styles */
.is-premium .form-control-custom {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
}
:global(.dark-mode) .is-premium .form-control-custom {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.2);
}
/* States */
.is-invalid .form-control-custom {
border-color: #ef4444;
}
.is-disabled .form-control-custom {
opacity: 0.6;
cursor: not-allowed;
}
.input-addons {
position: absolute;
right: 2.5rem;
display: flex;
align-items: center;
pointer-events: none;
}
.form-hint {
font-size: 0.75rem;
color: var(--text-muted, #a0a0a0);
}
.form-error {
font-size: 0.75rem;
color: #ef4444;
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,133 @@
<template>
<div :class="['form-group mb-3', { 'has-error': error }]">
<label v-if="label" :for="id" class="form-label d-flex align-items-center">
{{ label }}
<span v-if="required" class="text-danger ms-1">*</span>
</label>
<div
class="input-group-container"
:class="[
`variant-${variant}`,
{ 'is-premium': isPremium, 'is-invalid': error, 'is-disabled': disabled }
]"
>
<textarea
:id="id"
:name="id"
:rows="rows"
:class="['form-control-custom', textareaClass]"
:placeholder="placeholder"
:required="required"
:disabled="disabled"
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
></textarea>
</div>
<div v-if="hint && !error" class="form-hint mt-1">{{ hint }}</div>
<div v-if="error" class="form-error mt-1">{{ error }}</div>
</div>
</template>
<script setup>
const props = defineProps({
label: { type: String, default: '' },
id: { type: String, required: true },
modelValue: { type: String, default: '' },
placeholder: { type: String, default: '' },
required: { type: Boolean, default: false },
disabled: { type: Boolean, default: false },
rows: { type: Number, default: 3 },
error: { type: String, default: '' },
hint: { type: String, default: '' },
isPremium: { type: Boolean, default: true },
variant: { type: String, default: 'default' },
textareaClass: { type: String, default: '' },
})
defineEmits(['update:modelValue'])
</script>
<style scoped>
.form-group {
width: 100%;
}
.form-label {
font-size: 0.875rem;
font-weight: 600;
margin-bottom: 0.5rem;
color: var(--text-primary, #1e1e1e);
}
.input-group-container {
position: relative;
transition: all 0.2s ease;
}
.form-control-custom {
width: 100%;
padding: 0.75rem 1rem;
background: var(--bg-card, #ffffff);
border: 1px solid var(--border-color, rgba(0, 0, 0, 0.08));
border-radius: 12px;
color: var(--text-primary, #1e1e1e);
font-size: 1rem;
transition: all 0.2s ease;
resize: vertical;
}
:global(.dark-mode) .form-control-custom {
background: var(--bg-secondary, #1a1c22);
}
.form-control-custom:focus {
outline: none;
border-color: var(--accent-color, #533dea);
box-shadow: 0 0 0 3px var(--accent-soft, rgba(83, 61, 234, 0.2));
}
/* Glass Variant */
.variant-glass .form-control-custom {
background: rgba(255, 255, 255, 0.4);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
:global(.dark-mode) .variant-glass .form-control-custom {
background: rgba(255, 255, 255, 0.05);
border-color: rgba(255, 255, 255, 0.1);
}
/* Premium Styles */
.is-premium .form-control-custom {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.05), 0 2px 4px -1px rgba(0, 0, 0, 0.03);
}
:global(.dark-mode) .is-premium .form-control-custom {
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.2);
}
/* States */
.is-invalid .form-control-custom {
border-color: #ef4444;
}
.is-disabled .form-control-custom {
opacity: 0.6;
cursor: not-allowed;
}
.form-hint {
font-size: 0.75rem;
color: var(--text-muted, #a0a0a0);
}
.form-error {
font-size: 0.75rem;
color: #ef4444;
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,34 @@
<template>
<img
v-if="src"
:src="src"
:style="imgStyle"
:id="id"
class="icon-user"
@click="$emit('click', $event)"
/>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
src: { type: String, required: true },
width: { type: [String, Number], default: 30 },
height: { type: [String, Number], default: 30 },
id: { type: String, default: '' },
})
defineEmits(['click'])
const normalize = (val) => {
if (typeof val === 'number') return `${val}px`
if (typeof val === 'string' && /^\d+$/.test(val)) return `${val}px`
return val
}
const imgStyle = computed(() => ({
width: normalize(props.width),
height: normalize(props.height),
}))
</script>

View File

@@ -0,0 +1,42 @@
<template>
<button
:id="idtext"
:value="value"
:class="computedClass"
v-bind="additionalData"
@click="handleClick"
>
<img v-if="img" :src="img" class="btn-icon" />
<slot>{{ content }}</slot>
</button>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
content: { type: String, default: '' },
value: { type: [String, Number], default: '' },
idtext: { type: String, default: '' },
btnClass: { type: String, default: '' }, // custom classes
block: { type: Boolean, default: false },
img: { type: String, default: '' },
additionalData: { type: Object, default: () => ({}) },
onClick: { type: Function, default: null }
})
const computedClass = computed(() => {
return `${props.btnClass} ${props.block ? 'btn-block' : ''}`.trim()
})
function handleClick(event) {
if (props.onClick) props.onClick(event)
}
</script>
<style scoped>
.btn-icon {
margin-right: 0.5rem;
height: 1em;
}
</style>

View File

@@ -0,0 +1,15 @@
<template>
<BaseButton v-bind="$props" btn-class="btn btn-danger" />
</template>
<script setup>
import BaseButton from './BaseButton.vue'
defineProps({
content: String,
value: String,
idtext: String,
block: Boolean,
img: String,
additionalData: Object,
onClick: Function
})
</script>

View File

@@ -0,0 +1,18 @@
<template>
<BaseButton v-bind="$props" :btn-class="btnClass || 'btn btn-primary'">
<slot />
</BaseButton>
</template>
<script setup>
import BaseButton from './BaseButton.vue'
defineProps({
content: String,
value: String,
idtext: String,
block: Boolean,
img: String,
btnClass: String,
additionalData: Object,
onClick: Function
})
</script>

View File

@@ -0,0 +1,18 @@
<template>
<BaseButton v-bind="$props" :btn-class="btnClass || 'btn btn-warning'">
<slot />
</BaseButton>
</template>
<script setup>
import BaseButton from './BaseButton.vue'
defineProps({
content: String,
value: String,
idtext: String,
block: Boolean,
img: String,
btnClass: String,
additionalData: Object,
onClick: Function
})
</script>

View File

@@ -0,0 +1,11 @@
<template>
<div class="col" :class="colClass">
<slot />
</div>
</template>
<script setup>
defineProps({
colClass: { type: String, default: '' }
})
</script>

View File

@@ -0,0 +1,21 @@
<template>
<Row :row-class="rowClass" :hidden="hidden" :style="style">
<Col>
<slot name="left" />
</Col>
<Col>
<slot name="right" />
</Col>
</Row>
</template>
<script setup>
import Row from './Row.vue'
import Col from './Col.vue'
defineProps({
rowClass: { type: String, default: '' },
hidden: { type: Boolean, default: false },
style: { type: String, default: '' }
})
</script>

View File

@@ -0,0 +1,22 @@
<template>
<div
class="row"
:class="rowClass"
:style="computedStyle"
v-show="!hidden"
>
<slot />
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
rowClass: { type: String, default: '' },
hidden: { type: Boolean, default: false },
style: { type: String, default: '' }
})
const computedStyle = computed(() => props.style)
</script>

View 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>

View 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>

View 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>

View File

@@ -0,0 +1,116 @@
<template>
<div id="tableContainer">
<table :id="tableId" class="display">
<thead>
<tr>
<th v-for="(header, i) in computedHeaders" :key="i">{{ header }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, rowIdx) in filteredData"
:key="rowIdx"
@click="$emit('row-click', row, rowIdx)"
class="searchable-row"
>
<td v-for="(header, colIdx) in computedHeaders" :key="colIdx">
{{ isObjectData ? (row[header] ?? '') : (row[colIdx] ?? '') }}
</td>
</tr>
</tbody>
</table>
<!-- Simple pagination -->
<div v-if="totalPages > 1" class="table-pagination mt-3 d-flex justify-content-between align-items-center">
<button class="btn btn-sm btn-default" :disabled="currentPage <= 1" @click="currentPage--">
Previous
</button>
<span>Page {{ currentPage }} of {{ totalPages }}</span>
<button class="btn btn-sm btn-default" :disabled="currentPage >= totalPages" @click="currentPage++">
Next
</button>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const props = defineProps({
/**
* Array of objects or array of arrays
*/
data: { type: Array, required: true },
headers: { type: Array, default: () => [] },
tableId: { type: String, default: 'dynamicTable' },
pageLength: { type: Number, default: 10 },
defaultSort: { type: Object, default: () => ({ column: 0, direction: 'asc' }) },
defaultSearch: { type: String, default: '' },
})
defineEmits(['row-click'])
const currentPage = ref(1)
const searchTerm = ref(props.defaultSearch)
const isObjectData = computed(() =>
props.data.length > 0 &&
props.data[0] !== null &&
typeof props.data[0] === 'object' &&
!Array.isArray(props.data[0])
)
const computedHeaders = computed(() => {
if (props.headers.length > 0) return props.headers
if (isObjectData.value) return Object.keys(props.data[0])
return []
})
const sortedData = computed(() => {
const dataCopy = [...props.data]
const { column, direction } = props.defaultSort
dataCopy.sort((a, b) => {
const valA = isObjectData.value ? a[computedHeaders.value[column]] : a[column]
const valB = isObjectData.value ? b[computedHeaders.value[column]] : b[column]
if (valA < valB) return direction === 'asc' ? -1 : 1
if (valA > valB) return direction === 'asc' ? 1 : -1
return 0
})
return dataCopy
})
const filteredData = computed(() => {
let result = sortedData.value
if (searchTerm.value) {
const q = searchTerm.value.toLowerCase()
result = result.filter(row => {
const text = isObjectData.value
? Object.values(row).join(' ')
: row.join(' ')
return text.toLowerCase().includes(q)
})
}
// Pagination
const start = (currentPage.value - 1) * props.pageLength
return result.slice(start, start + props.pageLength)
})
const totalPages = computed(() => {
const total = sortedData.value.length
return Math.max(1, Math.ceil(total / props.pageLength))
})
</script>
<style scoped>
.searchable-row {
cursor: pointer;
}
.searchable-row:hover {
background-color: rgba(0, 0, 0, 0.05);
}
</style>

View File

@@ -0,0 +1,206 @@
<template>
<div class="searchable-table-wrapper">
<!-- Toolbar: Search + Density on same line -->
<div class="toolbar-row d-flex align-items-center gap-3 mb-4">
<div class="search-container flex-grow-1">
<SearchBar
v-model="searchModel"
:placeholder="searchPlaceholder"
/>
</div>
<div class="density-container flex-shrink-0">
<TableDensityToggle v-model="densityModel" />
</div>
</div>
<!-- Loading State -->
<div v-if="loading" class="mt-2">
<SkeletonTable :rows="skeletonRows" :columns="skeletonColumns" />
</div>
<!-- Error State -->
<div v-else-if="error" class="alert alert-danger">{{ error }}</div>
<!-- Empty State -->
<div v-else-if="empty" class="text-center py-5 no-results">
<slot name="empty-state">
<i :class="[emptyIcon, 'fa-4x text-muted opacity-25 mb-3']"></i>
<h5>{{ emptyTitle }}</h5>
<p class="text-muted">{{ emptyMessage }}</p>
</slot>
</div>
<!-- Table Content -->
<div v-else class="card border-0 shadow-sm rounded-20" :data-table-density="densityModel">
<div class="table-responsive">
<table class="table table-hover align-middle density-table mb-0" :class="tableDensityClass">
<slot name="table"></slot>
</table>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import SearchBar from './Search/SearchBar.vue'
import TableDensityToggle from './TableDensityToggle.vue'
import SkeletonTable from './Skeleton/SkeletonTable.vue'
const props = defineProps({
// Search
searchValue: { type: String, default: '' },
searchPlaceholder: { type: String, default: 'Search...' },
// Density
densityValue: { type: String, default: 'comfortable' },
// States
loading: { type: Boolean, default: false },
error: { type: String, default: null },
empty: { type: Boolean, default: false },
// Empty state customization
emptyIcon: { type: String, default: 'fas fa-inbox' },
emptyTitle: { type: String, default: 'No results found' },
emptyMessage: { type: String, default: 'Try adjusting your search criteria' },
// Skeleton config
skeletonRows: { type: Number, default: 8 },
skeletonColumns: { type: Number, default: 6 },
})
const emit = defineEmits(['update:searchValue', 'update:densityValue'])
const searchModel = computed({
get: () => props.searchValue,
set: (val) => emit('update:searchValue', val)
})
const densityModel = computed({
get: () => props.densityValue,
set: (val) => emit('update:densityValue', val)
})
const tableDensityClass = computed(() => {
return {
'density-comfortable': props.densityValue === 'comfortable',
'density-compact': props.densityValue === 'compact',
'density-ultra': props.densityValue === 'ultra-compact'
}
})
</script>
<style scoped>
.searchable-table-wrapper {
width: 100%;
}
.toolbar-row {
flex-wrap: nowrap;
}
.search-container {
min-width: 0;
flex: 1;
max-width: 50%;
}
.density-container {
flex: 1;
max-width: 50%;
}
.no-results {
background: var(--bg-card);
border-radius: 20px;
border: 2px dashed var(--border-color);
}
/* Table Density Styles */
.density-table :deep(thead th) {
transition: padding 0.2s ease;
}
.density-table :deep(tbody td) {
transition: padding 0.2s ease;
}
/* Comfortable - Default */
.density-comfortable :deep(thead th) {
padding: 1rem;
}
.density-comfortable :deep(tbody td) {
padding: 1rem;
}
/* Compact */
.density-compact :deep(thead th) {
padding: 0.625rem 0.75rem;
}
.density-compact :deep(tbody td) {
padding: 0.5rem 0.75rem;
}
.density-compact :deep(.store-thumb),
.density-compact :deep(.product-thumb) {
width: 36px !important;
height: 36px !important;
}
/* Ultra Compact */
.density-ultra :deep(thead th) {
padding: 0.375rem 0.5rem;
font-size: 0.7rem;
}
.density-ultra :deep(tbody td) {
padding: 0.25rem 0.5rem;
font-size: 0.8rem;
}
.density-ultra :deep(.store-thumb),
.density-ultra :deep(.product-thumb) {
width: 28px !important;
height: 28px !important;
}
.density-ultra :deep(.btn-icon) {
width: 26px !important;
height: 26px !important;
font-size: 0.7rem;
}
.density-ultra :deep(.small) {
font-size: 0.75rem;
}
.density-ultra :deep(.smallest) {
font-size: 0.65rem;
}
/* Responsive */
@media (max-width: 768px) {
.toolbar-row {
flex-wrap: wrap;
}
.search-container {
max-width: 100%;
width: 100%;
margin-bottom: 0.5rem;
}
.density-container {
width: 100%;
}
}
/* Dark Mode */
:global(.dark-mode) .no-results {
background: var(--bg-card) !important;
border-color: var(--border-color) !important;
}
</style>

View File

@@ -0,0 +1,31 @@
<template>
<div class="list-bill-view mb-4" @click="$emit('click', $event)">
<IconImage
v-if="icon"
:src="icon"
:width="iconWidth"
:height="iconHeight"
/>
<div class="content">
<h4>
<a href="javascript:void(0);" class="fw_6">{{ title }}</a>
</h4>
<p>{{ subtitle }}</p>
</div>
<i class="fas fa-chevron-right"></i>
</div>
</template>
<script setup>
import IconImage from '../IconImage.vue'
defineProps({
title: { type: String, required: true },
subtitle: { type: String, default: '' },
icon: { type: String, default: '' },
iconWidth: { type: [String, Number], default: 30 },
iconHeight: { type: [String, Number], default: 30 },
})
defineEmits(['click'])
</script>

View File

@@ -0,0 +1,27 @@
<template>
<div>
<ListArrowButton
v-for="(item, index) in items"
:key="index"
:title="item.title"
:subtitle="item.subtitle || ''"
:icon="item.icon || ''"
:icon-width="item.iconWidth || 30"
:icon-height="item.iconHeight || 30"
@click="$emit('item-click', item, index)"
/>
</div>
</template>
<script setup>
import ListArrowButton from './ListArrowButton.vue'
defineProps({
/**
* Array of { title, subtitle?, icon?, iconWidth?, iconHeight? }
*/
items: { type: Array, required: true },
})
defineEmits(['item-click'])
</script>

View File

@@ -0,0 +1,20 @@
<template>
<li>
<a href="javascript:void(0);" @click="$emit('click', $event)">
<div :class="['icon-box', bgColor8 ? 'bg_color_8' : '']" style="background: transparent !important; border: none !important;">
<img :src="icon" />
</div>
<span style="color: var(--text-primary);">{{ title }}</span>
</a>
</li>
</template>
<script setup>
defineProps({
icon: { type: String, required: true },
title: { type: String, default: '' },
bgColor8: { type: Boolean, default: false },
})
defineEmits(['click'])
</script>

View File

@@ -0,0 +1,48 @@
<template>
<div class="mt-5">
<div class="tf-container">
<div class="tf-title d-flex justify-content-between">
<h3 class="fw_6">{{ title }}</h3>
<a
v-if="viewAllText"
href="javascript:void(0);"
class="primary_color fw_6"
@click="$emit('view-all')"
>
{{ viewAllText }}
</a>
</div>
<ul class="box-service mt-3">
<ServiceButton
v-for="(item, index) in items"
:key="index"
:icon="item.icon"
:title="item.title"
:bg-color8="item.bgColor8 || false"
@click="$emit('item-click', item, index)"
@mouseenter="prefetchPage(item.pagename)"
@touchstart.passive="prefetchPage(item.pagename)"
/>
</ul>
</div>
</div>
</template>
<script setup>
import ServiceButton from './ServiceButton.vue'
defineProps({
title: { type: String, default: '' },
viewAllText: { type: String, default: '' },
/**
* Array of { icon, title, bgColor8? }
*/
items: { type: Array, required: true },
})
defineEmits(['view-all', 'item-click'])
const prefetchPage = (pagename) => {
if (pagename && window.$prefetchPage) window.$prefetchPage(pagename);
};
</script>

View File

@@ -0,0 +1,60 @@
<template>
<li @click="$emit('click', $event)" class="side-text-button">
<div class="icon-wrapper-seamless">
<IconImage
v-if="icon"
:src="icon"
:width="iconWidth"
:height="iconHeight"
class="seamless-icon"
/>
</div>
<div class="text-label" style="color: var(--text-primary);">
{{ text }}
</div>
</li>
</template>
<script setup>
import IconImage from '../IconImage.vue'
defineProps({
text: { type: String, required: true },
icon: { type: String, default: '' },
iconWidth: { type: [String, Number], default: 30 },
iconHeight: { type: [String, Number], default: 30 },
})
defineEmits(['click'])
</script>
<style scoped>
.side-text-button {
background: transparent !important;
border: none !important;
display: flex;
align-items: center;
gap: 12px;
padding: 10px 16px;
transition: all 0.2s ease;
cursor: pointer;
}
.side-text-button:hover {
background: var(--bg-tertiary) !important;
}
.icon-wrapper-seamless {
display: flex;
align-items: center;
justify-content: center;
}
.seamless-icon {
filter: brightness(1.1) contrast(1.1);
}
.text-label {
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,32 @@
<template>
<ul class="mt-3 box-outstanding-service">
<SideTextButton
v-for="(item, index) in items"
:key="index"
:text="item.text"
:icon="item.icon || ''"
:icon-width="item.iconWidth || 30"
:icon-height="item.iconHeight || 30"
@click="$emit('item-click', item, index)"
@mouseenter="prefetchPage(item.pagename)"
@touchstart.passive="prefetchPage(item.pagename)"
/>
</ul>
</template>
<script setup>
import SideTextButton from './SideTextButton.vue'
defineProps({
/**
* Array of { text, icon?, iconWidth?, iconHeight? }
*/
items: { type: Array, required: true },
})
defineEmits(['item-click'])
const prefetchPage = (pagename) => {
if (pagename && window.$prefetchPage) window.$prefetchPage(pagename);
};
</script>

View File

@@ -0,0 +1,66 @@
<template>
<div class="home-skeleton pb-5">
<!-- BalanceBox Skeleton -->
<div class="tf-container">
<SkeletonStats />
</div>
<!-- Grid Services Skeleton -->
<div class="mt-4 px-2">
<div class="tf-container">
<div class="row g-3 justify-content-center">
<div v-for="i in 12" :key="i" class="col-3 text-center mb-4">
<SkeletonBlock width="52px" height="52px" borderRadius="14px" margin="0 auto 10px" />
<SkeletonBlock width="40px" height="10px" margin="0 auto" borderRadius="2px" />
</div>
</div>
</div>
</div>
<!-- Announcements Skeleton -->
<div class="mt-4">
<div class="tf-container">
<SkeletonBlock width="100%" height="110px" borderRadius="20px" />
</div>
</div>
<!-- Management Tools Skeleton -->
<div class="tf-container mt-5">
<div class="d-flex justify-content-between align-items-center mb-3 px-1">
<SkeletonBlock width="160px" height="22px" borderRadius="4px" />
</div>
<div v-for="i in 5" :key="i" class="d-flex align-items-center mb-3 p-3 glass-card rounded-xl border border-light-5">
<SkeletonBlock width="44px" height="44px" borderRadius="12px" margin="0 16px 0 0" />
<div class="flex-grow-1">
<SkeletonBlock width="60%" height="16px" borderRadius="4px" />
</div>
<SkeletonBlock width="24px" height="24px" borderRadius="6px" />
</div>
</div>
</div>
</template>
<script setup>
import SkeletonBlock from './SkeletonBlock.vue';
import SkeletonStats from './SkeletonStats.vue';
</script>
<style scoped>
.glass-card {
background: white;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
}
.rounded-xl {
border-radius: 20px;
}
.border-light-5 {
border-color: rgba(0, 0, 0, 0.03) !important;
}
:global(.dark-mode) .glass-card {
background: #1f2228 !important;
}
:global(.dark-mode) .border-light-5 {
border-color: rgba(255, 255, 255, 0.05) !important;
}
</style>

View File

@@ -0,0 +1,43 @@
<template>
<div class="product-list-skeleton px-2 pt-3">
<div class="row g-3 justify-content-center">
<div v-for="i in parseInt(count)" :key="i" class="col-6 col-sm-4 col-md-3">
<div class="glass-card rounded-xl overflow-hidden mb-3 border border-light-5 text-start">
<SkeletonBlock width="100%" height="140px" borderRadius="0" :noShimmer="false" />
<div class="p-3">
<SkeletonBlock width="85%" height="14px" margin="0 0 8px 0" borderRadius="3px" />
<SkeletonBlock width="60%" height="10px" margin="0 0 16px 0" borderRadius="2px" />
<div class="d-flex justify-content-between align-items-center">
<SkeletonBlock width="50px" height="12px" borderRadius="3px" />
<SkeletonBlock width="36px" height="36px" borderRadius="12px" />
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import SkeletonBlock from './SkeletonBlock.vue';
defineProps({
count: { type: [Number, String], default: 8 }
})
</script>
<style scoped>
.glass-card {
background: white;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
}
.rounded-xl {
border-radius: 20px;
}
.border-light-5 {
border-color: rgba(0, 0, 0, 0.03) !important;
}
:global(.dark-mode) .glass-card {
background: #1f2228 !important;
}
</style>

View File

@@ -0,0 +1,17 @@
<template>
<SkeletonBlock
:width="size"
:height="size"
borderRadius="50%"
:margin="margin"
/>
</template>
<script setup>
import SkeletonBlock from './SkeletonBlock.vue';
defineProps({
size: { type: String, default: '48px' },
margin: { type: String, default: '0' }
})
</script>

View File

@@ -0,0 +1,68 @@
<template>
<div
class="skeleton-block"
:style="{
width: width,
height: height,
borderRadius: borderRadius,
margin: margin
}"
:class="{ 'shimmer': !noShimmer }"
></div>
</template>
<script setup>
defineProps({
width: { type: String, default: '100%' },
height: { type: String, default: '1rem' },
borderRadius: { type: String, default: '4px' },
margin: { type: String, default: '0' },
noShimmer: { type: Boolean, default: false }
})
</script>
<style scoped>
.skeleton-block {
background: #f0f2f5;
position: relative;
overflow: hidden;
display: block;
}
.shimmer::after {
content: "";
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
transform: translateX(-100%);
background-image: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0,
rgba(255, 255, 255, 0.2) 20%,
rgba(255, 255, 255, 0.5) 60%,
rgba(255, 255, 255, 0)
);
animation: shimmer 2s infinite;
}
:global(.dark-mode) .skeleton-block {
background: #2d3138 !important;
}
:global(.dark-mode) .shimmer::after {
background-image: linear-gradient(
90deg,
rgba(255, 255, 255, 0) 0,
rgba(255, 255, 255, 0.05) 20%,
rgba(255, 255, 255, 0.1) 60%,
rgba(255, 255, 255, 0)
);
}
@keyframes shimmer {
100% {
transform: translateX(100%);
}
}
</style>

View File

@@ -0,0 +1,38 @@
<template>
<div class="skeleton-card glass-card p-4 rounded-xl mb-4 text-start">
<div class="d-flex align-items-center mb-3">
<SkeletonAvatar size="56px" margin="0 16px 0 0" />
<div class="flex-grow-1">
<SkeletonBlock width="140px" height="20px" margin="0 0 8px 0" borderRadius="6px" />
<SkeletonBlock width="80px" height="14px" borderRadius="4px" />
</div>
</div>
<div class="divider border-bottom border-light opacity-10 mb-3"></div>
<SkeletonBlock width="100%" height="12px" margin="0 0 8px 0" borderRadius="4px" />
<SkeletonBlock width="100%" height="12px" margin="0 0 8px 0" borderRadius="4px" />
<SkeletonBlock width="60%" height="12px" borderRadius="4px" />
<div class="d-flex justify-content-between align-items-center mt-4 pt-2">
<SkeletonBlock width="100px" height="10px" borderRadius="4px" />
<SkeletonBlock width="90px" height="36px" borderRadius="18px" />
</div>
</div>
</template>
<script setup>
import SkeletonBlock from './SkeletonBlock.vue';
import SkeletonAvatar from './SkeletonAvatar.vue';
</script>
<style scoped>
.rounded-xl {
border-radius: 24px;
}
.glass-card {
background: white;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05);
}
:global(.dark-mode) .glass-card {
background: #1f2228 !important;
}
</style>

View File

@@ -0,0 +1,44 @@
<template>
<div class="skeleton-stats glass-card p-4 rounded-xl mb-4 border text-center">
<div class="row align-items-center mb-3">
<div class="col-6 border-right">
<SkeletonBlock width="60px" height="10px" margin="0 auto 8px" borderRadius="3px" />
<SkeletonBlock width="100px" height="28px" margin="0 auto" borderRadius="6px" />
</div>
<div class="col-6">
<SkeletonBlock width="60px" height="10px" margin="0 auto 8px" borderRadius="3px" />
<SkeletonBlock width="130px" height="28px" margin="0 auto" borderRadius="6px" />
</div>
</div>
<div class="d-flex justify-content-center gap-4 mt-4 pt-3 border-top-dashed">
<SkeletonBlock v-for="i in 4" :key="i" width="44px" height="44px" borderRadius="12px" />
</div>
</div>
</template>
<script setup>
import SkeletonBlock from './SkeletonBlock.vue';
</script>
<style scoped>
.skeleton-stats {
background: white;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
}
.rounded-xl {
border-radius: 24px;
}
.border-right {
border-right: 1px solid #e9ecef;
}
.border-top-dashed {
border-top: 1px dashed #e9ecef;
}
:global(.dark-mode) .skeleton-stats {
background: #1f2228 !important;
}
:global(.dark-mode) .border-right,
:global(.dark-mode) .border-top-dashed {
border-color: rgba(255, 255, 255, 0.05) !important;
}
</style>

View File

@@ -0,0 +1,35 @@
<template>
<div class="skeleton-table rounded-3 overflow-hidden border text-start bg-white">
<div class="table-header bg-light p-3 border-bottom d-flex align-items-center">
<SkeletonBlock v-for="n in parseInt(columns)" :key="n" :width="colWidth" height="16px" margin="0 15px 0 0" borderRadius="4px" />
</div>
<div v-for="row in parseInt(rows)" :key="row" class="table-row p-3 border-bottom d-flex align-items-center">
<SkeletonBlock v-for="n in parseInt(columns)" :key="n" :width="colWidth" height="12px" margin="0 15px 0 0" borderRadius="4px" />
</div>
</div>
</template>
<script setup>
import SkeletonBlock from './SkeletonBlock.vue';
defineProps({
rows: { type: [Number, String], default: 6 },
columns: { type: [Number, String], default: 4 },
colWidth: { type: String, default: '18%' }
})
</script>
<style scoped>
.skeleton-table {
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
}
.table-row:last-child {
border-bottom: none !important;
}
:global(.dark-mode) .skeleton-table {
background: #1f2228 !important;
}
:global(.dark-mode) .table-header {
background: #2d3138 !important;
}
</style>

View File

@@ -0,0 +1,28 @@
<template>
<div class="skeleton-text-container">
<SkeletonBlock
v-for="line in parseInt(lines)"
:key="line"
:width="getWidth(line)"
height="14px"
margin="0 0 8px 0"
borderRadius="4px"
/>
</div>
</template>
<script setup>
import SkeletonBlock from './SkeletonBlock.vue';
const props = defineProps({
lines: { type: [Number, String], default: 1 },
lastWidth: { type: String, default: '70%' }
})
const getWidth = (line) => {
if (parseInt(props.lines) > 1 && line === parseInt(props.lines)) {
return props.lastWidth;
}
return '100%';
}
</script>

View File

@@ -0,0 +1,15 @@
<template>
<div class="store-list-skeleton tf-container pt-3">
<div v-for="i in parseInt(count)" :key="i">
<SkeletonCard />
</div>
</div>
</template>
<script setup>
import SkeletonCard from './SkeletonCard.vue';
defineProps({
count: { type: [Number, String], default: 4 }
})
</script>

View File

@@ -0,0 +1,39 @@
<template>
<div class="transaction-list-skeleton tf-container pt-3">
<div v-for="i in parseInt(count)" :key="i" class="d-flex align-items-center mb-3 p-3 glass-card rounded-xl border border-light-5 text-start">
<SkeletonBlock width="44px" height="44px" borderRadius="12px" margin="0 16px 0 0" />
<div class="flex-grow-1">
<SkeletonBlock width="160px" height="16px" margin="0 0 6px 0" borderRadius="4px" />
<SkeletonBlock width="100px" height="12px" borderRadius="3px" />
</div>
<div class="text-end">
<SkeletonBlock width="80px" height="18px" margin="0 0 4px auto" borderRadius="4px" />
<SkeletonBlock width="50px" height="10px" margin="0 0 0 auto" borderRadius="2px" />
</div>
</div>
</div>
</template>
<script setup>
import SkeletonBlock from './SkeletonBlock.vue';
defineProps({
count: { type: [Number, String], default: 10 }
})
</script>
<style scoped>
.glass-card {
background: white;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
}
.rounded-xl {
border-radius: 20px;
}
.border-light-5 {
border-color: rgba(0, 0, 0, 0.03) !important;
}
:global(.dark-mode) .glass-card {
background: #1f2228 !important;
}
</style>

View File

@@ -0,0 +1,37 @@
<template>
<div class="tf-container">
<div class="tf-balance-box" :style="boxStyle">
<!-- Balance stats -->
<div id="balance_wrapper">
<div class="balance">
<StatsDetailsRow :stats="stats" />
</div>
</div>
<!-- Footer action buttons -->
<WalletFooter
:items="footerItems"
@item-click="(item, idx) => $emit('footer-click', item, idx)"
/>
</div>
</div>
</template>
<script setup>
import StatsDetailsRow from './StatsDetailsRow.vue'
import WalletFooter from './WalletFooter.vue'
defineProps({
/**
* Stats: Array of { title, number, unit, align?, numberId? }
*/
stats: { type: Array, required: true },
/**
* Footer items: Array of { title, icon?, subtitles? }
*/
footerItems: { type: Array, required: true },
boxStyle: { type: String, default: 'border: solid 2px var(--border-color);' },
})
defineEmits(['footer-click'])
</script>

View File

@@ -0,0 +1,19 @@
<template>
<div class="col br-right">
<div :class="`inner-${align} ${align === 'left' ? 'ps-3' : 'pe-3'}`">
<p>{{ title }}</p>
<h3 :id="numberId">{{ number }}</h3>
<span>{{ unit }}</span>
</div>
</div>
</template>
<script setup>
defineProps({
title: { type: String, default: '' },
number: { type: [Number, String], default: 0 },
unit: { type: String, default: '' },
align: { type: String, default: 'left', validator: v => ['left', 'right'].includes(v) },
numberId: { type: String, default: '' },
})
</script>

View File

@@ -0,0 +1,24 @@
<template>
<div class="row">
<CardStatsDetails
v-for="(stat, index) in stats"
:key="index"
:title="stat.title"
:number="stat.number"
:unit="stat.unit"
:align="stat.align || 'left'"
:number-id="stat.numberId || ''"
/>
</div>
</template>
<script setup>
import CardStatsDetails from './CardStatsDetails.vue'
defineProps({
/**
* Array of stat objects: { title, number, unit, align?, numberId? }
*/
stats: { type: Array, required: true },
})
</script>

View File

@@ -0,0 +1,29 @@
<template>
<div class="wallet-footer">
<ul class="d-flex justify-content-between align-items-center">
<WalletFooterItem
v-for="(item, index) in items"
:key="index"
:title="item.title"
:icon="item.icon || ''"
:icon-width="item.iconWidth || 30"
:icon-height="item.iconHeight || 30"
:subtitles="item.subtitles || []"
@click="$emit('item-click', item, index)"
/>
</ul>
</div>
</template>
<script setup>
import WalletFooterItem from './WalletFooterItem.vue'
defineProps({
/**
* Array of item objects: { title, icon?, iconWidth?, iconHeight?, subtitles? }
*/
items: { type: Array, required: true },
})
defineEmits(['item-click'])
</script>

View File

@@ -0,0 +1,35 @@
<template>
<li class="wallet-card-item px-3">
<a
class="fw_6 text-center"
href="javascript:void(0);"
@click="$emit('click', $event)"
>
<ul>
<li class="path1">{{ subtitles[0] || '' }}</li>
<li class="path2">{{ subtitles[1] || '' }}</li>
<li class="path3">{{ subtitles[2] || '' }}</li>
<li class="path4">{{ subtitles[3] || '' }}</li>
</ul>
<IconImage v-if="icon" :src="icon" :width="iconWidth" :height="iconHeight" />
{{ title }}
</a>
</li>
</template>
<script setup>
import IconImage from '../IconImage.vue'
defineProps({
title: { type: String, default: '' },
icon: { type: String, default: '' },
iconWidth: { type: [String, Number], default: 30 },
iconHeight: { type: [String, Number], default: 30 },
/**
* Array of up to 4 subtitle strings
*/
subtitles: { type: Array, default: () => [] },
})
defineEmits(['click'])
</script>

View File

@@ -0,0 +1,151 @@
<template>
<Teleport to="body">
<div v-if="modelValue" class="modal d-block" tabindex="-1" style="z-index:1055;padding-top:72px;padding-bottom:80px">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content" style="background:var(--bg-card);color:var(--text-primary)">
<div class="modal-header border-0 pb-0">
<div class="d-flex align-items-center gap-2 w-100 me-2">
<i class="fas fa-images" style="color:var(--accent-color)"></i>
<input v-model="query" type="text" class="form-control rounded-pill form-control-sm"
placeholder="Search photos…" style="background:var(--bg-primary);color:var(--text-primary);border-color:rgba(128,128,128,.3)" />
</div>
<button type="button" class="btn-close" @click="$emit('update:modelValue', false)" />
</div>
<div class="modal-body pt-2">
<div v-if="error" class="alert alert-danger alert-dismissible py-2 mb-2">
{{ error }} <button type="button" class="btn-close" @click="error=null"/>
</div>
<!-- skeleton -->
<div v-if="loading && !photos.length" class="row g-2">
<div v-for="n in 9" :key="n" class="col-4">
<div class="ratio ratio-4x3 rounded" style="background:rgba(128,128,128,.15);animation:pulse 1.5s infinite" />
</div>
</div>
<!-- grid -->
<div v-else class="row g-2">
<div v-for="photo in photos" :key="photo.id" class="col-4" style="cursor:pointer" @click="selectPhoto(photo)">
<div class="ratio ratio-4x3 position-relative rounded overflow-hidden">
<img :src="photo.thumb" class="w-100 h-100 object-fit-cover" :alt="photo.title" loading="lazy" />
<div v-if="selecting===photo.id" class="position-absolute top-0 start-0 w-100 h-100 d-flex align-items-center justify-content-center" style="background:rgba(0,0,0,.45)">
<div class="spinner-border text-light spinner-border-sm" />
</div>
</div>
<small class="text-muted d-block text-truncate mt-1 px-1" style="font-size:.7rem">{{ photo.title }}</small>
</div>
</div>
<!-- empty states -->
<div v-if="!loading && !photos.length && !error && !query.trim()" class="text-center text-muted py-4">
<i class="fas fa-search d-block mb-2" style="font-size:1.5rem;opacity:.5"></i>
Type a product name above to search photos.
</div>
<div v-else-if="!loading && !photos.length && !error && query.trim()" class="text-center text-muted py-4">
<i class="fas fa-image d-block mb-2" style="font-size:1.5rem;opacity:.5"></i>
No photos found for {{ query.trim() }}.
</div>
<!-- sentinel for infinite scroll -->
<div ref="sentinel" class="py-2 text-center">
<div v-if="loadingMore" class="spinner-border spinner-border-sm text-muted" />
</div>
</div>
</div>
</div>
<div class="modal-backdrop show" style="z-index:-1" @click="$emit('update:modelValue', false)" />
</div>
</Teleport>
</template>
<script setup>
import { ref, watch, nextTick } from 'vue'
import axios from 'axios'
const props = defineProps({ modelValue: Boolean, productName: { type: String, default: '' } })
const emit = defineEmits(['update:modelValue', 'photo-selected'])
const photos = ref([])
const query = ref('')
const page = ref(1)
const hasMore = ref(false)
const loading = ref(false)
const loadingMore = ref(false)
const selecting = ref(null)
const error = ref(null)
const sentinel = ref(null)
let observer = null
let debounceTimer = null
watch(() => props.modelValue, async (val) => {
if (val) { query.value = props.productName; await resetAndSearch() }
else { photos.value = []; page.value = 1; observer?.disconnect() }
})
watch(query, () => {
clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => resetAndSearch(), 400)
})
async function resetAndSearch() {
observer?.disconnect()
photos.value = []; page.value = 1; error.value = null
// Don't fire a request with an empty query — the API rejects it (422).
if (!query.value.trim()) { loading.value = false; return }
loading.value = true
await fetchPage(1)
loading.value = false
await nextTick()
setupObserver()
}
async function fetchPage(p) {
try {
const res = await axios.get('/api/products/photo-search', { params: { q: query.value.trim(), page: p } })
if (res.data.success) {
photos.value.push(...res.data.photos)
hasMore.value = res.data.has_more
page.value = p
} else {
error.value = res.data.error || 'Could not load photos. Try again.'
}
} catch (e) {
error.value = e?.response?.data?.error || 'Could not load photos. Try again.'
}
}
async function loadMore() {
if (!hasMore.value || loadingMore.value) return
loadingMore.value = true
await fetchPage(page.value + 1)
loadingMore.value = false
await nextTick()
setupObserver()
}
function setupObserver() {
observer?.disconnect()
if (!sentinel.value) return
observer = new IntersectionObserver(([e]) => { if (e.isIntersecting) loadMore() }, { threshold: 0.1 })
observer.observe(sentinel.value)
}
async function selectPhoto(photo) {
if (selecting.value) return
selecting.value = photo.id
error.value = null
try {
const res = await axios.post('/api/products/photo-download', { src: photo.src })
if (res.data.success) {
emit('photo-selected', { hashkey: res.data.hashkey, url: res.data.url })
emit('update:modelValue', false)
} else {
error.value = 'Failed to save photo. Try another.'
}
} catch {
error.value = 'Failed to save photo. Try another.'
} finally {
selecting.value = null
}
}
</script>
<style scoped>
@keyframes pulse { 0%,100%{opacity:.6} 50%{opacity:.3} }
</style>

View File

@@ -0,0 +1,80 @@
<template>
<div class="dropdown table-density-toggle" ref="dropdownRef">
<button
class="btn btn-theme border dropdown-toggle rounded-12 px-3 shadow-sm d-flex align-items-center"
type="button"
@click="toggleDropdown"
@blur="closeDropdown"
style="padding-top: 12px; padding-bottom: 12px;"
>
<i class="fas fa-expand-alt me-2 opacity-50" style="font-size: 0.9rem;"></i>
<span class="fw_6" style="font-size: 0.95rem;">{{ currentLabel }}</span>
</button>
<ul class="dropdown-menu dropdown-menu-end shadow border-0 rounded-12 mt-2 py-2" :class="{ show: isOpen }">
<li v-for="option in options" :key="option.value">
<a class="dropdown-item py-2 px-3 d-flex align-items-center"
:class="{ active: modelValue === option.value }"
href="javascript:void(0);"
@mousedown.prevent="selectOption(option.value)">
<div class="icon-box me-3 rounded-circle d-flex align-items-center justify-content-center" :class="modelValue === option.value ? 'bg-primary text-white' : 'bg-light text-muted'">
<i :class="[option.icon, 'smallest']"></i>
</div>
<div class="flex-grow-1">
<div class="small fw_7 d-block">{{ option.label }}</div>
<div class="smallest text-muted d-block opacity-75">{{ option.description }}</div>
</div>
</a>
</li>
</ul>
</div>
</template>
<script setup>
import { computed, ref } from 'vue';
const props = defineProps({
modelValue: { type: String, default: 'comfortable' }
});
const emit = defineEmits(['update:modelValue']);
const isOpen = ref(false);
const dropdownRef = ref(null);
const options = [
{ value: 'comfortable', label: 'Comfortable', description: 'Standard row spacing', icon: 'fas fa-grip-lines' },
{ value: 'compact', label: 'Compact', description: 'Reduced row padding', icon: 'fas fa-grip-lines-vertical' },
{ value: 'ultra-compact', label: 'Ultra', description: 'Maximum density', icon: 'fas fa-list' }
];
const currentLabel = computed(() => {
return options.find(o => o.value === props.modelValue)?.label || 'Comfortable';
});
const toggleDropdown = () => {
isOpen.value = !isOpen.value;
};
const closeDropdown = () => {
// Small delay to allow click events to fire first
setTimeout(() => {
isOpen.value = false;
}, 150);
};
const selectOption = (value) => {
emit('update:modelValue', value);
isOpen.value = false;
};
</script>
<style scoped>
.rounded-12 { border-radius: 12px; }
.dropdown-item { transition: all 0.2s; cursor: pointer; }
.dropdown-item.active { background-color: rgba(66, 185, 131, 0.1) !important; color: #42b983 !important; }
:global(.dark-mode) .dropdown-item.active { background-color: rgba(66, 185, 131, 0.2) !important; }
.smallest { font-size: 0.75rem; }
.icon-box { width: 28px; height: 28px; flex-shrink: 0; }
.btn-theme { background: var(--bg-card); color: var(--text-primary); border-color: var(--border-color) !important; }
.dropdown-menu { background: var(--bg-card); min-width: 200px; }
</style>

View File

@@ -0,0 +1,196 @@
<script setup>
import { onMounted } from 'vue';
import { useAnnouncements } from '../composables/useAnnouncements.js';
const { announcements, loading, fetchLatest } = useAnnouncements();
onMounted(() => {
fetchLatest();
});
const getIcon = (type) => {
switch (type) {
case 'success': return 'check-circle';
case 'warning': return 'exclamation-triangle';
case 'danger': return 'exclamation-circle';
default: return 'info-circle';
}
};
const getBannerClass = (type) => {
return `announcement-banner banner-${type}`;
};
</script>
<template>
<div v-if="announcements.length > 0" class="announcements-container">
<div v-for="item in announcements" :key="item.id" :class="getBannerClass(item.type)">
<div class="banner-content">
<div class="banner-header">
<div class="banner-icon">
<i :class="'fas fa-' + getIcon(item.type)"></i>
</div>
<h5 class="banner-title">{{ item.title }}</h5>
</div>
<div v-if="item.photo" class="banner-photo">
<img :src="'/RequestData/File/' + item.photo" alt="Announcement photo" />
</div>
<div class="banner-body">
<p class="banner-description">{{ item.content }}</p>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.announcements-container {
padding: 10px 15px;
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
}
.announcement-banner {
border-radius: 16px;
padding: 16px;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
display: flex;
align-items: flex-start;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
position: relative;
border-left: 6px solid transparent;
background-size: 200% 200%;
animation: gradientShift 5s ease infinite;
}
@keyframes gradientShift {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
.announcement-banner:hover {
transform: translateY(-4px) scale(1.01);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
.banner-content {
display: flex;
flex-direction: column;
gap: 12px;
width: 100%;
z-index: 1;
}
.banner-header {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
}
.banner-icon {
font-size: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
}
.banner-photo {
width: 100%;
max-height: 400px;
border-radius: 12px;
overflow: hidden;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
background: rgba(0, 0, 0, 0.02);
margin: 4px 0;
}
.banner-photo img {
max-width: 100%;
width: 100%;
height: auto;
object-fit: contain;
border-radius: inherit;
}
.banner-body {
width: 100%;
padding: 0 4px;
}
.banner-title {
margin: 0;
font-weight: 800;
font-size: 1.25rem;
letter-spacing: -0.025em;
line-height: 1.2;
}
.banner-description {
margin: 0;
font-size: 0.95rem;
line-height: 1.5;
font-weight: 500;
}
/* Premium Color Themes */
.banner-info {
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
color: #0369a1;
border-left-color: #0ea5e9;
}
.banner-info .banner-icon { color: #0ea5e9; }
.banner-success {
background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%);
color: #15803d;
border-left-color: #22c55e;
}
.banner-success .banner-icon { color: #22c55e; }
.banner-warning {
background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%);
color: #a16207;
border-left-color: #f59e0b;
}
.banner-warning .banner-icon { color: #f59e0b; }
.banner-danger {
background: linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%);
color: #b91c1c;
border-left-color: #ef4444;
}
.banner-danger .banner-icon { color: #ef4444; }
/* Dark Mode adaptation support */
:global(.dark-mode) .announcement-banner {
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.4);
}
:global(.dark-mode) .banner-info {
background: linear-gradient(135deg, #0c4a6e 0%, #075985 100%);
color: #e0f2fe;
}
:global(.dark-mode) .banner-success {
background: linear-gradient(135deg, #064e3b 0%, #065f46 100%);
color: #dcfce7;
}
:global(.dark-mode) .banner-warning {
background: linear-gradient(135deg, #713f12 0%, #78350f 100%);
color: #fef3c7;
}
:global(.dark-mode) .banner-danger {
background: linear-gradient(135deg, #7f1d1d 0%, #991b1b 100%);
color: #fee2e2;
}
</style>

View File

@@ -0,0 +1,43 @@
<template>
<div class="loading-overlay" v-if="show">
<div class="spinner"></div>
</div>
</template>
<script>
export default {
name: "LoadingSpinner",
props: {
show: { type: Boolean, default: false }
}
};
</script>
<style scoped>
.loading-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.2);
display: flex;
justify-content: center;
align-items: center;
z-index: 99999;
}
.spinner {
border: 6px solid #f3f3f3;
border-top: 6px solid #007bff;
border-radius: 50%;
width: 50px;
height: 50px;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>

View File

@@ -0,0 +1,259 @@
<script setup>
import { ref, computed } from 'vue';
const props = defineProps({
session: {
type: Object,
required: true
}
});
const isExpanded = ref(false);
const toggleExpand = () => {
isExpanded.value = !isExpanded.value;
};
const transactions = computed(() => {
return props.session.transactions || [];
});
const formattedDate = computed(() => {
if (!props.session.created_at) return 'N/A';
const date = new Date(props.session.created_at);
return date.toLocaleString('en-PH', {
month: 'short',
day: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: true
}).replace(',', ' •');
});
const statusClass = computed(() => {
switch (props.session.status) {
case 'completed': return 'badge-soft-success';
case 'active': return 'badge-soft-primary';
case 'voided': return 'badge-soft-danger';
default: return 'badge-soft-secondary';
}
});
const paymentIcon = computed(() => {
switch (props.session.payment_method?.toLowerCase()) {
case 'cash': return 'fas fa-money-bill-wave';
case 'credit': return 'fas fa-credit-card';
case 'online': return 'fas fa-mobile-alt';
default: return 'fas fa-receipt';
}
});
const formatCurrency = (amount) => {
return new Intl.NumberFormat('en-PH', {
style: 'currency',
currency: 'PHP'
}).format(amount);
};
const getProductInfo = (transaction) => {
return {
name: transaction.product?.name || 'Unknown Product',
quantity: transaction.quantity || 0,
unitPrice: transaction.price_at_sale || 0,
totalPrice: transaction.total_price || 0
};
};
</script>
<template>
<div class="card mb-3 border-0 shadow-sm rounded-4 overflow-hidden pos-history-card">
<div class="card-body p-3">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<span :class="['badge rounded-pill px-3 py-2 text-uppercase fw-bold', statusClass]">
{{ session.status }}
</span>
<h6 class="mb-0 mt-2 text-primary fw-bold">
{{ session.customer_name || 'Walk-in Customer' }}
</h6>
<small class="text-muted d-block mt-1">
<i class="far fa-clock me-1"></i> {{ formattedDate }}
</small>
</div>
<div class="text-end">
<h5 class="mb-0 fw-black text-dark">
{{ formatCurrency(session.total_amount) }}
</h5>
<small class="text-muted">
{{ session.items_count }} {{ session.items_count === 1 ? 'item' : 'items' }}
</small>
</div>
</div>
<div class="d-flex align-items-center justify-content-between mt-3 pt-3 border-top border-light">
<div class="d-flex align-items-center">
<div class="payment-icon-wrapper bg-light rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 32px; height: 32px;">
<i :class="[paymentIcon, 'text-muted sm']"></i>
</div>
<span class="text-muted small text-capitalize">{{ session.payment_method || 'N/A' }}</span>
</div>
<div v-if="session.hashkey" class="text-muted small">
<span class="badge bg-light text-muted fw-normal rounded-pill">#{{ session.hashkey.substring(0, 8) }}</span>
</div>
</div>
<!-- Expandable Items Section -->
<div v-if="isExpanded && transactions.length > 0" class="items-section mt-3 pt-3 border-top border-light">
<div class="items-header d-flex align-items-center mb-2">
<i class="fas fa-box-open text-muted me-2"></i>
<span class="text-muted small fw-bold">Transaction Items</span>
</div>
<div class="items-list">
<div v-for="item in transactions" :key="item.id" class="item-row d-flex align-items-center justify-content-between py-2 border-bottom border-light">
<div class="item-info flex-grow-1">
<div class="item-name text-dark fw-semibold small">
{{ getProductInfo(item).name }}
</div>
<div class="item-qty text-muted small">
{{ getProductInfo(item).quantity }} × {{ formatCurrency(getProductInfo(item).unitPrice) }}
</div>
</div>
<div class="item-total text-end">
<span class="fw-bold text-dark small">
{{ formatCurrency(getProductInfo(item).totalPrice) }}
</span>
</div>
</div>
</div>
</div>
<!-- Footer with Toggle Button -->
<div
v-if="transactions.length > 0"
class="card-footer-toggle d-flex align-items-center justify-content-center mt-3 pt-3 border-top border-light cursor-pointer"
@click="toggleExpand"
>
<span class="text-muted small fw-bold me-2">
{{ isExpanded ? 'Hide Items' : 'View Items' }}
</span>
<i :class="['fas text-muted small', isExpanded ? 'fa-chevron-up' : 'fa-chevron-down']"></i>
</div>
</div>
</div>
</template>
<style scoped>
.pos-history-card {
transition: transform 0.2s, box-shadow 0.2s;
background: var(--bg-card);
}
.pos-history-card:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(0,0,0,0.05) !important;
}
:global(.dark-mode) .pos-history-card {
background: rgba(var(--bg-card-rgb), 0.7);
backdrop-filter: blur(15px);
}
.badge-soft-success {
background-color: rgba(40, 167, 69, 0.1);
color: #28a745;
}
.badge-soft-primary {
background-color: rgba(0, 123, 255, 0.1);
color: #007bff;
}
.badge-soft-danger {
background-color: rgba(220, 53, 69, 0.1);
color: #dc3545;
}
.badge-soft-secondary {
background-color: rgba(108, 117, 125, 0.1);
color: #6c757d;
}
:global(.dark-mode) .badge-soft-success { background-color: rgba(40, 167, 69, 0.2); }
:global(.dark-mode) .badge-soft-primary { background-color: rgba(0, 123, 255, 0.2); }
:global(.dark-mode) .badge-soft-danger { background-color: rgba(220, 53, 69, 0.2); }
:global(.dark-mode) .border-light { border-color: rgba(255,255,255,0.05) !important; }
/* Font Awesome standard sizes for visual hierarchy as per dictionary */
.sm { font-size: 0.875rem; }
/* Items section styling */
.items-section {
animation: slideDown 0.3s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
max-height: 0;
}
to {
opacity: 1;
max-height: 500px;
}
}
.item-row:last-child {
border-bottom: none !important;
}
.item-row {
transition: background-color 0.2s;
}
.item-row:hover {
background-color: rgba(0, 0, 0, 0.02);
}
:global(.dark-mode) .item-row:hover {
background-color: rgba(255, 255, 255, 0.02);
}
:global(.dark-mode) .item-name,
:global(.dark-mode) .item-total span {
color: var(--text-primary) !important;
}
/* Toggle button styling */
.card-footer-toggle {
transition: background-color 0.2s;
}
.cursor-pointer {
cursor: pointer;
}
.card-footer-toggle:hover {
background-color: rgba(0, 0, 0, 0.02);
}
:global(.dark-mode) .card-footer-toggle:hover {
background-color: rgba(255, 255, 255, 0.02);
}
/* Mobile responsiveness */
@media (max-width: 576px) {
.item-name {
font-size: 0.75rem;
}
.item-qty {
font-size: 0.7rem;
}
.item-total span {
font-size: 0.75rem;
}
}
</style>

View File

@@ -0,0 +1,93 @@
<script setup>
import { onMounted, ref } from 'vue';
import { usePosStore } from '../../stores/pos';
import PosHistoryCard from './PosHistoryCard.vue';
const props = defineProps({
storeHash: {
type: String,
required: true
}
});
const posStore = usePosStore();
const isLoadingMore = ref(false);
onMounted(async () => {
// Only fetch if empty or if needed (could always fetch for simplicity on mount)
await posStore.fetchPosSessions(props.storeHash, 1);
});
const loadMore = async () => {
if (posStore.posSessions.length < posStore.posSessionsCount && !posStore.loading) {
isLoadingMore.value = true;
await posStore.fetchPosSessions(props.storeHash, posStore.posSessionsPage + 1);
isLoadingMore.value = false;
}
};
</script>
<template>
<div class="pos-history-list position-relative mt-2">
<div v-if="posStore.loading && posStore.posSessions.length === 0" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-2 text-muted small">Loading POS history...</p>
</div>
<div v-else-if="posStore.posSessions.length === 0" class="text-center py-5 bg-light rounded-4 border border-dashed">
<div class="empty-state-icon mb-3 opacity-2">
<i class="fad fa-receipt fa-4x text-muted"></i>
</div>
<p class="text-muted small">No POS history found for this store.</p>
</div>
<div v-else>
<div class="history-items">
<div v-for="session in posStore.posSessions" :key="session.hashkey">
<PosHistoryCard :session="session" />
</div>
</div>
<div v-if="posStore.posSessions.length < posStore.posSessionsCount" class="text-center mt-4">
<button
@click="loadMore"
class="btn btn-outline-primary btn-sm rounded-pill px-4 py-2 d-inline-flex align-items-center fw-bold"
:disabled="isLoadingMore || posStore.loading"
>
<span v-if="isLoadingMore || posStore.loading" class="spinner-border spinner-border-sm me-2" role="status"></span>
<i v-else class="fas fa-plus me-2"></i>
{{ isLoadingMore || posStore.loading ? 'LOADING...' : 'LOAD MORE' }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
.empty-state-icon {
transition: opacity 0.3s ease;
}
.opacity-2 {
opacity: 0.2;
}
.pos-history-list {
min-height: 100px;
}
.bg-light {
background-color: var(--bg-secondary) !important;
}
.border-dashed {
border-style: dashed !important;
border-width: 2px !important;
}
:global(.dark-mode) .bg-light {
background-color: rgba(0,0,0,0.2) !important;
}
</style>

View File

@@ -0,0 +1,108 @@
<template>
<div class="pos-today-stats mb-4">
<div class="d-flex align-items-center justify-content-between mb-3 mt-1">
<div class="d-flex align-items-center">
<div class="icon-avatar me-3 shadow-sm">
<i class="fas fa-chart-line text-primary"></i>
</div>
<div>
<h5 class="fw_7 mb-0">Today's Performance</h5>
<span class="text-muted small">Daily sales summary</span>
</div>
</div>
<div v-if="!loading" class="date-badge px-3 py-1 text-primary small fw_6 rounded-pill border">
Today
</div>
</div>
<div v-if="loading" class="text-center py-4 glass-card p-3 p-md-4 rounded-xl">
<div class="spinner-border text-primary spinner-border-sm" role="status">
<span class="sr-only">Loading...</span>
</div>
<p class="small text-muted mt-2 mb-0">Fetching stats...</p>
</div>
<div v-else class="glass-card p-3 p-md-4 rounded-xl">
<div class="row text-center mt-2">
<div class="col-6 border-right">
<p class="small text-muted mb-1 text-uppercase ls_1">Transactions</p>
<h3 class="mb-0 fw_7">{{ todayStats.count || 0 }}</h3>
</div>
<div class="col-6">
<p class="small text-muted mb-1 text-uppercase ls_1">Total Sales</p>
<h3 class="mb-0 fw_7">₱{{ formatAmount(todayStats.total) }}</h3>
</div>
</div>
<div v-if="!loading && todayStats.store_name" class="mt-3 text-center border-top-dashed pt-3">
<p class="small text-muted italic mb-0">
Terminal: <span class="fw_6 text-primary">{{ todayStats.store_name }}</span>
</p>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { usePosStore } from '../../stores/pos'
const props = defineProps({
loading: { type: Boolean, default: false }
})
const posStore = usePosStore()
const todayStats = computed(() => posStore.todayStats)
const formatAmount = (val) => {
if (!val) return '0.00'
return parseFloat(val).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
</script>
<style scoped>
.pos-today-stats {
transition: all 0.3s ease;
}
.ls_1 {
letter-spacing: 1px;
}
.border-right {
border-right: 1px solid var(--border-color);
}
.border-top-dashed {
border-top: 1px dashed var(--border-color);
}
.icon-avatar {
width: 44px;
height: 44px;
background: white;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.1rem;
}
.date-badge {
background: rgba(var(--primary-rgb), 0.05) !important;
}
.rounded-xl {
border-radius: 20px !important;
}
:global(.dark-mode) .icon-avatar {
background: #2d3138;
}
:global(.dark-mode) .icon-avatar i {
color: #10b981 !important;
}
:global(.dark-mode) .date-badge {
background: rgba(16, 185, 129, 0.1) !important;
color: #10b981 !important;
}
:global(.dark-mode) .text-primary {
color: #10b981 !important;
}
</style>

View File

@@ -0,0 +1,138 @@
<template>
<div class="product-card" @click="$emit('click')">
<div class="product-image-wrapper">
<FileImage :src="image" :alt="name" class="product-image" fallback="https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/146710fe9ece.bin" />
<div v-if="price" class="product-price-badge">
{{ price }}
</div>
</div>
<div class="product-info">
<h5 class="product-name">{{ name }}</h5>
<p v-if="unit" class="product-unit">per {{ unit }}</p>
<p v-if="description" class="product-description text-truncate-2">
{{ description }}
</p>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import FileImage from '../Core/FileImage.vue'
const props = defineProps({
name: { type: String, required: true },
price: { type: [String, Number], default: '' },
unit: { type: String, default: '' },
description: { type: String, default: '' },
image: { type: String, default: '' }
})
defineEmits(['click'])
</script>
<style scoped>
.product-card {
background: #ffffff;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
transition: transform 0.2s, box-shadow 0.2s;
cursor: pointer;
height: 100%;
display: flex;
flex-direction: column;
}
.product-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
}
.product-image-wrapper {
position: relative;
width: 100%;
padding-top: 100%;
/* 1:1 Aspect Ratio */
background: #f8f9fa;
}
.product-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.product-price-badge {
position: absolute;
bottom: 0;
right: 0;
background: rgba(66, 185, 131, 0.9);
color: white;
padding: 6px 12px;
border-top-left-radius: 12px;
font-weight: 700;
backdrop-filter: blur(4px);
font-size: 0.9rem;
}
.product-info {
padding: 12px;
flex-grow: 1;
display: flex;
flex-direction: column;
}
.product-name {
font-size: 1rem;
font-weight: 600;
margin-bottom: 4px;
color: #2c3e50;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.product-unit {
font-size: 0.75rem;
color: #7f8c8d;
margin-bottom: 8px;
font-style: italic;
}
.product-description {
font-size: 0.85rem;
color: #636e72;
margin-bottom: 0;
line-height: 1.4;
}
.text-truncate-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Dark mode support (if applicable) */
:global(.dark-mode) .product-card {
background: #24272c;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
}
:global(.dark-mode) .product-name {
color: #e0e0e0;
}
:global(.dark-mode) .product-description {
color: #b0b0b0;
}
:global(.dark-mode) .product-image-wrapper {
background: #1a1c20;
}
</style>

View File

@@ -0,0 +1,128 @@
<template>
<div class="store-card" @click="$emit('click')">
<div class="store-image-wrapper">
<img :src="resolvedImage" :alt="name" class="store-image" @error="handleImageError" />
<div v-if="category" class="store-category-badge">
{{ category }}
</div>
</div>
<div class="store-info">
<h5 class="store-name">{{ name }}</h5>
<p v-if="subcategory" class="store-subcategory">{{ subcategory }}</p>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
name: { type: String, required: true },
category: { type: String, default: '' },
subcategory: { type: String, default: '' },
image: { type: String, default: '' }
})
defineEmits(['click'])
const resolvedImage = computed(() => {
if (!props.image) return 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/85605eacd4c8.bin';
// Return blob URLs directly
if (props.image.startsWith('blob:')) {
return props.image;
}
// Check for http, https, or data URIs
if (props.image.startsWith('http') || props.image.startsWith('/') || props.image.startsWith('data:')) {
return props.image;
}
// If it's a hash (long string without slashes), resolve it
return `/RequestData/File/${props.image}`;
});
const handleImageError = (event) => {
event.target.src = 'https://cdn.jsdelivr.net/gh/telemagnadon/obj-vault-3a@v2026.05.14-vendor-2/a/85605eacd4c8.bin';
};
</script>
<style scoped>
.store-card {
background: #ffffff;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05);
transition: transform 0.2s, box-shadow 0.2s;
cursor: pointer;
height: 100%;
display: flex;
flex-direction: column;
}
.store-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.1);
}
.store-image-wrapper {
position: relative;
width: 100%;
padding-top: 60%;
/* 16:9 approx */
background: #f8f9fa;
}
.store-image {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
object-fit: cover;
}
.store-category-badge {
position: absolute;
top: 10px;
right: 10px;
background: rgba(0, 123, 255, 0.85);
color: white;
padding: 4px 10px;
border-radius: 20px;
font-weight: 600;
backdrop-filter: blur(4px);
font-size: 0.75rem;
}
.store-info {
padding: 12px;
flex-grow: 1;
}
.store-name {
font-size: 1rem;
font-weight: 600;
margin-bottom: 2px;
color: #2c3e50;
display: -webkit-box;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.store-subcategory {
font-size: 0.8rem;
color: #7f8c8d;
margin-bottom: 0;
}
:global(.dark-mode) .store-card {
background: #24272c;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
}
:global(.dark-mode) .store-name {
color: #e0e0e0;
}
:global(.dark-mode) .store-image-wrapper {
background: #1a1c20;
}
</style>

View File

@@ -0,0 +1,378 @@
<script setup>
import { ref, onMounted, watch } from 'vue';
import axios from 'axios';
import LoadingSpinner from '../LoadingSpinner.vue';
import Dropzone from '../Core/Dropzone.vue';
import { useFileUpload } from '../../composables/useFileUpload.js';
const props = defineProps({
productHash: { type: String, required: true },
storeHash: { type: String, default: null },
onSaved: { type: Function, default: null },
onClose: { type: Function, default: null }
});
const { uploadFile, removeHash, setInitialHashes, isUploading: isFileUploading } = useFileUpload({
category: 'ProductMarket',
maxSizeMB: 10
});
// Form state
const productName = ref('');
const productDescription = ref('');
const productCategory = ref('');
const productSubcategory = ref('');
const productPrice = ref(0);
const productUnitName = ref('');
const productAvailable = ref(0);
const productBarcode = ref('');
// Data lists
const categoryList = ref([]);
const subcategoryList = ref([]);
// Loading state
const isLoading = ref(false);
const isSaving = ref(false);
const showSuccessState = ref(false);
const successMessage = ref('');
const error = ref(null);
// Dropzone handling
const dropzoneRef = ref(null);
const dropzoneFiles = ref([]);
onMounted(async () => {
await loadCategories();
await loadProductData();
});
const loadCategories = async () => {
try {
const response = await axios.post('/Products/New/Category/Datalist', {});
const data = response.data.categories || response.data;
if (data && Array.isArray(data)) {
categoryList.value = data.map(item => ({
value: typeof item === 'string' ? item : item[0],
label: typeof item === 'string' ? item : (item[1] || item[0])
}));
}
} catch (err) {
console.error('Error loading categories:', err);
}
};
const loadSubcategories = async () => {
if (!productCategory.value) {
subcategoryList.value = [];
return;
}
try {
const response = await axios.post('/Products/New/SubCategory/Datalist', {
category: productCategory.value
});
const data = response.data.subcategories || response.data;
if (data && Array.isArray(data)) {
subcategoryList.value = data.map(item => ({
value: typeof item === 'string' ? item : item[0],
label: typeof item === 'string' ? item : (item[1] || item[0])
}));
}
} catch (err) {
console.error('Error loading subcategories:', err);
}
};
const loadProductData = async () => {
try {
isLoading.value = true;
const response = await axios.post('/View/Product/Details/data', {
target: props.productHash,
data: {
product_id: props.productHash,
store_hash: props.storeHash
}
});
if (response.data && response.data.success && response.data.data) {
const product = response.data.data;
productName.value = product.name || '';
productDescription.value = props.storeHash ? (product.store_description || product.description) : (product.description || '');
productCategory.value = product.category || '';
productSubcategory.value = product.subcategory || '';
productPrice.value = props.storeHash ? (product.store_price || product.price) : (product.price || 0);
productUnitName.value = product.unitname || '';
productBarcode.value = product.barcode || '';
productAvailable.value = product.available || 0;
if (productCategory.value) {
await loadSubcategories();
productSubcategory.value = product.subcategory || '';
}
if (product.photourlDropzone && Array.isArray(product.photourlDropzone)) {
dropzoneFiles.value = product.photourlDropzone.map(f => ({
file: { name: f.name || 'Image' },
hashkey: f.hashkey,
progress: 100,
uploading: false,
preview: f.url
}));
setInitialHashes(product.photourlDropzone.map(f => f.hashkey));
}
}
} catch (err) {
console.error('Error loading product data:', err);
error.value = 'Failed to load product data';
} finally {
isLoading.value = false;
}
};
watch(() => dropzoneFiles.value, async (newFiles) => {
const filesToUpload = newFiles.filter(f => !f.uploading && !f.hashkey && !f.error);
for (const fileObj of filesToUpload) {
const index = newFiles.indexOf(fileObj);
if (index === -1) continue;
dropzoneRef.value.setFileStatus(index, { uploading: true, progress: 30 });
const result = await uploadFile(fileObj.file);
if (result && result.hashkey) {
dropzoneRef.value.setFileStatus(index, {
uploading: false,
progress: 100,
hashkey: result.hashkey
});
} else {
dropzoneRef.value.setFileStatus(index, {
uploading: false,
progress: 0,
error: 'Upload failed'
});
}
}
}, { deep: true });
const handlePhotoRemoved = (hashkey) => {
removeHash(hashkey);
};
const handleCategoryChange = () => {
loadSubcategories();
};
const handleSubmit = async () => {
if (!props.storeHash && (!productName.value || !productCategory.value)) {
error.value = 'Name and Category are required';
return;
}
try {
isSaving.value = true;
error.value = null;
const response = await axios.post('/Products/Admin/Edit/', {
target: props.productHash,
data: {
store_hash: props.storeHash
},
EditProductName: productName.value,
EditProductDescription: productDescription.value,
EditProductCategory: productCategory.value,
EditProductSubCategory: productSubcategory.value,
EditProductPrice: parseFloat(productPrice.value),
EditProductUnitName: productUnitName.value,
EditProductAvailable: parseInt(productAvailable.value),
EditProductBarcode: productBarcode.value,
status: true,
photourl: dropzoneFiles.value
.filter(f => f.hashkey)
.map(f => f.hashkey)
});
if (response.data && response.data.success) {
showSuccessState.value = true;
successMessage.value = 'Product updated successfully!';
setTimeout(() => {
if (props.onSaved) props.onSaved();
}, 1500);
} else {
error.value = response.data?.message || 'Failed to update product';
}
} catch (err) {
error.value = err.response?.data?.message || 'Failed to update product';
} finally {
isSaving.value = false;
}
};
</script>
<template>
<div class="update-product-modal p-1">
<div v-if="isLoading" class="text-center py-5">
<LoadingSpinner />
<p class="mt-2 text-muted">Loading product details...</p>
</div>
<template v-else>
<div v-if="successMessage" class="alert alert-success rounded-xl mb-3 animate-fade-in">
<i class="fas fa-check-circle me-2"></i> {{ successMessage }}
</div>
<div v-if="error" class="alert alert-danger rounded-xl mb-3 animate-shake">
<i class="fas fa-exclamation-triangle me-2"></i> {{ error }}
</div>
<div class="form-scroll-area custom-scrollbar pe-2" style="max-height: 70vh; overflow-y: auto;">
<div class="row g-3">
<div v-if="!storeHash" class="col-12">
<div class="form-group mb-3">
<label class="form-label fw_7 small text-muted text-uppercase">Product Name</label>
<input v-model="productName" type="text" class="form-control rounded-pill border-0 shadow-sm px-4" placeholder="Enter product name">
</div>
</div>
<div class="col-12">
<div class="form-group mb-3">
<label class="form-label fw_7 small text-muted text-uppercase">Description</label>
<textarea v-model="productDescription" class="form-control rounded-2xl border-0 shadow-sm px-4 py-3" rows="3" placeholder="Enter description"></textarea>
</div>
</div>
<div v-if="!storeHash" class="col-md-6">
<div class="form-group mb-3">
<label class="form-label fw_7 small text-muted text-uppercase">Category</label>
<select v-model="productCategory" @change="handleCategoryChange" class="form-select rounded-pill border-0 shadow-sm px-4">
<option value="" disabled>Select Category</option>
<option v-for="cat in categoryList" :key="cat.value" :value="cat.value">{{ cat.label }}</option>
</select>
</div>
</div>
<div v-if="!storeHash" class="col-md-6">
<div class="form-group mb-3">
<label class="form-label fw_7 small text-muted text-uppercase">Subcategory</label>
<select v-model="productSubcategory" :disabled="!subcategoryList.length" class="form-select rounded-pill border-0 shadow-sm px-4">
<option value="" disabled>Select Subcategory</option>
<option v-for="sub in subcategoryList" :key="sub.value" :value="sub.value">{{ sub.label }}</option>
</select>
</div>
</div>
<div class="col-md-6">
<div class="form-group mb-3">
<label class="form-label fw_7 small text-muted text-uppercase">{{ storeHash ? 'Store Price' : 'Global Price' }} (PHP)</label>
<input v-model="productPrice" type="number" step="0.01" class="form-control rounded-pill border-0 shadow-sm px-4">
</div>
</div>
<div v-if="!storeHash" class="col-md-6">
<div class="form-group mb-3">
<label class="form-label fw_7 small text-muted text-uppercase">Unit</label>
<input v-model="productUnitName" type="text" class="form-control rounded-pill border-0 shadow-sm px-4" placeholder="e.g. 25kg">
</div>
</div>
<div class="col-md-6">
<div class="form-group mb-3">
<label class="form-label fw_7 small text-muted text-uppercase">Available Stock</label>
<input v-model="productAvailable" type="number" class="form-control rounded-pill border-0 shadow-sm px-4">
</div>
</div>
<div v-if="!storeHash" class="col-md-6">
<div class="form-group mb-3">
<label class="form-label fw_7 small text-muted text-uppercase">Barcode</label>
<input v-model="productBarcode" type="text" class="form-control rounded-pill border-0 shadow-sm px-4" maxlength="12">
</div>
</div>
<div v-if="!storeHash" class="col-12 mt-2">
<label class="form-label fw_7 small text-muted text-uppercase mb-2">Product Photos</label>
<Dropzone ref="dropzoneRef" v-model:files="dropzoneFiles" @removed="handlePhotoRemoved" />
</div>
</div>
</div>
<div class="modal-footer-actions d-flex gap-3 mt-4 pt-3 border-top">
<button type="button" class="btn btn-light rounded-pill px-4 flex-fill fw_6" @click="onClose" :disabled="isSaving">Cancel</button>
<AnimatedButton
type="button"
btnClass="btn btn-primary rounded-pill px-5 flex-fill fw_6 shadow-sm"
@click="handleSubmit"
:loading="isSaving"
:success="showSuccessState"
:disabled="isFileUploading"
>
Update Product
</AnimatedButton>
</div>
</template>
</div>
</template>
<style scoped>
.form-label {
margin-bottom: 0.4rem;
letter-spacing: 0.05em;
}
.form-control, .form-select {
background: #f8f9fa;
transition: all 0.2s ease;
}
.form-control:focus, .form-select:focus {
background: #fff;
box-shadow: 0 4px 12px rgba(0,0,0,0.05) !important;
}
.rounded-2xl { border-radius: 1.2rem; }
.rounded-xl { border-radius: 1rem; }
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #ddd;
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #ccc;
}
.animate-fade-in {
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-5px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes shake {
10%, 90% { transform: translate3d(-1px, 0, 0); }
20%, 80% { transform: translate3d(2px, 0, 0); }
30%, 50%, 70% { transform: translate3d(-4px, 0, 0); }
40%, 60% { transform: translate3d(4px, 0, 0); }
}
.animate-shake {
animation: shake 0.5s cubic-bezier(.36,.07,.19,.97) both;
}
:global(.dark-mode) .form-control,
:global(.dark-mode) .form-select {
background: #24272c;
color: #eee;
}
:global(.dark-mode) .form-control:focus,
:global(.dark-mode) .form-select:focus {
background: #2c3036;
}
</style>

View File

@@ -0,0 +1,150 @@
<script setup>
import { ref, onMounted } from 'vue';
import axios from 'axios';
const message = ref(null);
const visible = ref(true);
const fetchMessage = async () => {
try {
const response = await axios.get('/api/public/global-message');
if (response.data.success && response.data.data) {
message.value = response.data.data;
}
} catch (error) {
console.error('Error fetching global message:', error);
}
};
const dismiss = () => {
visible.value = false;
// Optional: Store in localStorage to keep it dismissed for the session
sessionStorage.setItem('dismissed_global_message', JSON.stringify(message.value));
};
onMounted(() => {
const dismissed = sessionStorage.getItem('dismissed_global_message');
fetchMessage().then(() => {
if (dismissed && message.value && JSON.stringify(message.value) === dismissed) {
visible.value = false;
}
});
// Refresh every 30 seconds
setInterval(fetchMessage, 30000);
});
const getIcon = (type) => {
switch (type) {
case 'success': return 'check-circle';
case 'warning': return 'exclamation-triangle';
case 'danger': return 'exclamation-circle';
default: return 'info-circle';
}
};
</script>
<template>
<transition name="fade">
<div v-if="message && visible" :class="['system-broadcast', `broadcast-${message.type || 'info'}`]">
<div class="broadcast-container">
<div class="broadcast-content">
<i :class="['fas', `fa-${getIcon(message.type)}`, 'broadcast-icon']"></i>
<span class="broadcast-text">{{ message.text }}</span>
</div>
<button class="broadcast-close" @click="dismiss">
<i class="fas fa-times"></i>
</button>
</div>
</div>
</transition>
</template>
<style scoped>
.system-broadcast {
width: 100%;
padding: 12px 16px;
position: relative;
z-index: 10001;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
animation: slideDown 0.5s ease-out;
}
@keyframes slideDown {
from { transform: translateY(-100%); }
to { transform: translateY(0); }
}
.broadcast-container {
max-width: 800px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 15px;
}
.broadcast-content {
display: flex;
align-items: center;
gap: 12px;
}
.broadcast-icon {
font-size: 1.2rem;
}
.broadcast-text {
font-weight: 600;
font-size: 0.95rem;
line-height: 1.4;
}
.broadcast-close {
background: transparent;
border: none;
color: inherit;
font-size: 1.1rem;
cursor: pointer;
opacity: 0.7;
transition: opacity 0.2s;
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
}
.broadcast-close:hover {
opacity: 1;
}
/* Types */
.broadcast-info {
background: linear-gradient(90deg, #0284c7, #0ea5e9);
color: white;
}
.broadcast-success {
background: linear-gradient(90deg, #059669, #10b981);
color: white;
}
.broadcast-warning {
background: linear-gradient(90deg, #d97706, #f59e0b);
color: white;
}
.broadcast-danger {
background: linear-gradient(90deg, #dc2626, #ef4444);
color: white;
}
.fade-enter-active, .fade-leave-active {
transition: opacity 0.5s, transform 0.5s;
}
.fade-enter-from, .fade-leave-to {
opacity: 0;
transform: translateY(-20px);
}
</style>

View File

@@ -0,0 +1,261 @@
<template>
<div class="api-tokens-panel">
<div class="d-flex align-items-center justify-content-between mb-3">
<div>
<h4 class="fw-black mb-1">API Tokens</h4>
<p class="text-muted small mb-0">
Issue bearer tokens for batch endpoints and external integrations. Scope each token to specific abilities and IPs.
</p>
</div>
<button class="btn btn-primary" @click="openCreate">
<i class="fas fa-key me-1"></i> New Token
</button>
</div>
<div v-if="freshToken" class="alert alert-warning d-flex align-items-start gap-3">
<i class="fas fa-exclamation-triangle mt-1"></i>
<div class="flex-fill">
<div class="fw-bold mb-1">Copy this token now it will not be shown again.</div>
<div class="d-flex align-items-center gap-2">
<code class="flex-fill px-2 py-1 bg-light rounded text-break">{{ freshToken }}</code>
<button class="btn btn-sm btn-outline-secondary" @click="copyFresh">
<i class="fas" :class="copied ? 'fa-check' : 'fa-copy'"></i>
</button>
</div>
</div>
<button class="btn-close" @click="freshToken = null" aria-label="Dismiss"></button>
</div>
<div class="table-responsive">
<table class="table table-hover align-middle mb-0">
<thead>
<tr>
<th>Name</th>
<th>Abilities</th>
<th>IPs</th>
<th>Expires</th>
<th>Last Used</th>
<th>Status</th>
<th class="text-end">Actions</th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td colspan="7" class="text-center text-muted py-4">Loading...</td>
</tr>
<tr v-else-if="!tokens.length">
<td colspan="7" class="text-center text-muted py-4">No tokens yet.</td>
</tr>
<tr v-for="t in tokens" :key="t.id">
<td>
<div class="fw-bold">{{ t.name }}</div>
<div class="text-muted small">{{ t.description }}</div>
</td>
<td>
<span v-for="a in t.abilities" :key="a" class="badge bg-light text-dark border me-1">{{ a }}</span>
</td>
<td>
<div v-if="!t.allowed_ips?.length" class="text-muted small">any</div>
<div v-else>
<code v-for="ip in t.allowed_ips" :key="ip" class="d-block small">{{ ip }}</code>
</div>
</td>
<td>{{ fmt(t.expires_at) || '—' }}</td>
<td>
<div>{{ fmt(t.last_used_at) || 'never' }}</div>
<div class="text-muted small">{{ t.last_used_ip || '' }}</div>
</td>
<td>
<span v-if="t.revoked_at" class="badge bg-danger">revoked</span>
<span v-else-if="!t.is_active" class="badge bg-secondary">expired</span>
<span v-else class="badge bg-success">active</span>
</td>
<td class="text-end">
<button v-if="!t.revoked_at" class="btn btn-sm btn-outline-warning me-1" @click="revoke(t)">Revoke</button>
<button class="btn btn-sm btn-outline-danger" @click="destroy(t)">Delete</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Create Modal -->
<div v-if="showCreate" class="modal-backdrop-custom" @click.self="showCreate = false">
<div class="modal-panel">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0 fw-bold">Create API Token</h5>
<button class="btn-close" @click="showCreate = false"></button>
</div>
<div class="mb-2">
<label class="form-label small fw-bold">Name</label>
<input v-model="form.name" type="text" class="form-control" placeholder="e.g. cooperative-import-prod" />
</div>
<div class="mb-2">
<label class="form-label small fw-bold">Description</label>
<input v-model="form.description" type="text" class="form-control" placeholder="What is this token for?" />
</div>
<div class="mb-2">
<label class="form-label small fw-bold">Expires At (optional)</label>
<input v-model="form.expires_at" type="datetime-local" class="form-control" />
</div>
<div class="mb-2">
<label class="form-label small fw-bold">Allowed IPs (one per line, supports CIDR)</label>
<textarea v-model="form.allowed_ips_text" rows="3" class="form-control font-monospace small"
placeholder="203.0.113.5&#10;10.0.0.0/8"></textarea>
</div>
<div class="mb-2">
<label class="form-label small fw-bold d-flex justify-content-between">
<span>Abilities</span>
<span class="text-muted">{{ form.abilities.length }} selected</span>
</label>
<div class="border rounded p-2" style="max-height: 320px; overflow-y: auto;">
<div v-for="(items, group) in catalog" :key="group" class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-1">
<strong class="small">{{ group }}</strong>
<button type="button" class="btn btn-sm btn-link p-0" @click="toggleGroup(items)">toggle all</button>
</div>
<label v-for="item in items" :key="item.key" class="form-check d-inline-flex me-2 small">
<input type="checkbox" class="form-check-input me-1" :value="item.key" v-model="form.abilities" />
{{ item.label }}
</label>
</div>
</div>
</div>
<div class="d-flex justify-content-between align-items-center mt-3">
<label class="form-check small text-danger">
<input type="checkbox" class="form-check-input me-1" v-model="form.wildcard" />
Grant wildcard (<code>*</code>) full access
</label>
<div>
<button class="btn btn-light me-2" @click="showCreate = false">Cancel</button>
<button class="btn btn-primary" :disabled="creating || !canSubmit" @click="submit">
<span v-if="creating"><i class="fas fa-spinner fa-spin me-1"></i> Creating...</span>
<span v-else>Create Token</span>
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, computed, onMounted } from 'vue';
import axios from 'axios';
const tokens = ref([]);
const catalog = ref({});
const loading = ref(true);
const showCreate = ref(false);
const creating = ref(false);
const freshToken = ref(null);
const copied = ref(false);
const form = reactive({
name: '',
description: '',
expires_at: '',
allowed_ips_text: '',
abilities: [],
wildcard: false,
});
const canSubmit = computed(() => form.name.trim().length > 0 && (form.wildcard || form.abilities.length > 0));
const fmt = (v) => v ? new Date(v).toLocaleString() : '';
async function load() {
loading.value = true;
try {
const [list, cat] = await Promise.all([
axios.get('/admin/ultimate/api-tokens'),
axios.get('/admin/ultimate/api-tokens/catalog'),
]);
tokens.value = list.data.data || [];
catalog.value = cat.data.data.abilities || {};
} finally {
loading.value = false;
}
}
function openCreate() {
Object.assign(form, {
name: '', description: '', expires_at: '', allowed_ips_text: '',
abilities: [], wildcard: false,
});
showCreate.value = true;
}
function toggleGroup(items) {
const keys = items.map(i => i.key);
const allSelected = keys.every(k => form.abilities.includes(k));
if (allSelected) {
form.abilities = form.abilities.filter(a => !keys.includes(a));
} else {
form.abilities = Array.from(new Set([...form.abilities, ...keys]));
}
}
async function submit() {
creating.value = true;
try {
const abilities = form.wildcard ? ['*'] : form.abilities;
const allowed_ips = form.allowed_ips_text.split('\n').map(s => s.trim()).filter(Boolean);
const payload = {
name: form.name.trim(),
description: form.description.trim() || null,
abilities,
allowed_ips: allowed_ips.length ? allowed_ips : null,
expires_at: form.expires_at || null,
};
const { data } = await axios.post('/admin/ultimate/api-tokens', payload);
freshToken.value = data.data.plain_text_token;
showCreate.value = false;
await load();
} catch (e) {
alert(e.response?.data?.message || 'Failed to create token');
} finally {
creating.value = false;
}
}
async function revoke(t) {
if (!confirm(`Revoke token "${t.name}"? It will stop working immediately.`)) return;
await axios.post(`/admin/ultimate/api-tokens/${t.id}/revoke`);
await load();
}
async function destroy(t) {
if (!confirm(`Permanently delete token "${t.name}"? This removes audit history.`)) return;
await axios.delete(`/admin/ultimate/api-tokens/${t.id}`);
await load();
}
async function copyFresh() {
if (!freshToken.value) return;
await navigator.clipboard.writeText(freshToken.value);
copied.value = true;
setTimeout(() => (copied.value = false), 1500);
}
onMounted(load);
</script>
<style scoped>
.modal-backdrop-custom {
position: fixed; inset: 0; background: rgba(0,0,0,0.5);
display: flex; align-items: center; justify-content: center;
z-index: 1050;
}
.modal-panel {
background: #fff; border-radius: 12px; padding: 20px;
width: min(720px, 95vw); max-height: 92vh; overflow-y: auto;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
</style>

View File

@@ -0,0 +1,194 @@
<template>
<BaseModal
:modelValue="show"
@update:modelValue="$emit('close')"
modalTitle="Ultimate Query Console"
>
<div class="query-lab-body">
<div class="mb-4">
<label class="form-label fw-bold text-muted small text-uppercase mb-2 d-flex justify-content-between align-items-center">
SQL Query
<span v-if="loading" class="spinner-border spinner-border-sm text-primary" role="status"></span>
</label>
<div class="query-input-container">
<textarea
v-model="query"
class="form-control font-monospace rounded-4 p-3 border-2 focus-ring shadow-sm"
rows="6"
placeholder="SELECT * FROM users WHERE active = 1..."
spellcheck="false"
></textarea>
<div class="d-flex justify-content-end mt-3">
<button
@click="execute"
:disabled="loading || !query"
class="btn btn-primary rounded-pill px-4 py-2 d-flex align-items-center gap-2 shadow-sm"
>
<i class="fas fa-play"></i>
Run Query
</button>
</div>
</div>
</div>
<!-- Results Section -->
<div v-if="results || affectedRows > 0" class="results-container animate-fade-in">
<h6 class="fw-bold mb-3 d-flex align-items-center gap-2">
<i class="fas fa-database text-success"></i>
Query Results
<span class="badge bg-secondary rounded-pill small ms-auto" v-if="results">
{{ results.length }} rows found
</span>
<span class="badge bg-info rounded-pill small ms-auto" v-else-if="affectedRows > 0">
{{ affectedRows }} rows affected
</span>
</h6>
<div class="table-responsive rounded-4 shadow-sm bg-white border overflow-auto" style="max-height: 400px;">
<table v-if="results && results.length > 0" class="table table-hover table-sm mb-0">
<thead class="bg-light sticky-top">
<tr>
<th v-for="key in Object.keys(results[0])" :key="key" class="text-uppercase small fw-bold px-3 py-2 border-bottom">
{{ key }}
</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, i) in results" :key="i">
<td v-for="(val, key) in row" :key="key" class="px-3 py-2 border-bottom font-monospace small">
{{ val }}
</td>
</tr>
</tbody>
</table>
<div v-else-if="results && results.length === 0" class="p-5 text-center text-muted">
<i class="fas fa-empty-set fa-3x mb-3 opacity-25"></i>
<p>No results found for this query.</p>
</div>
<div v-else-if="affectedRows > 0" class="p-4 text-center text-success">
<i class="fas fa-check-circle fa-2x mb-2"></i>
<p class="mb-0">Query executed successfully. {{ affectedRows }} rows affected.</p>
</div>
</div>
</div>
<!-- Error Alert -->
<div v-if="error" class="alert alert-danger rounded-4 border-0 shadow-sm d-flex align-items-start gap-3 mt-3 animate-shake">
<i class="fas fa-exclamation-triangle mt-1"></i>
<div>
<div class="fw-bold">Query Error</div>
<div class="small opacity-75">{{ error }}</div>
</div>
</div>
</div>
<template #footer>
<div class="d-flex w-100 gap-2">
<button type="button" class="btn btn-light rounded-pill px-4 flex-fill fw-bold" @click="clear">Clear Results</button>
<button type="button" class="btn btn-outline-secondary rounded-pill px-4 flex-fill fw-bold" @click="$emit('close')">Close</button>
</div>
</template>
</BaseModal>
</template>
<script setup>
import { ref } from 'vue';
import { useUltimate } from '@/composables/useUltimate';
import BaseModal from '@/Components/Core/BaseModal.vue';
const props = defineProps({
show: Boolean,
});
const emit = defineEmits(['close']);
const { runQuery, loading } = useUltimate();
const query = ref('');
const results = ref(null);
const affectedRows = ref(0);
const error = ref(null);
const execute = async () => {
error.value = null;
results.value = null;
affectedRows.value = 0;
try {
const response = await runQuery(query.value);
if (response.success) {
results.value = response.data || null;
affectedRows.value = response.affected || 0;
} else {
error.value = response.message;
}
} catch (err) {
error.value = err.response?.data?.message || 'Failed to execute query';
}
};
const clear = () => {
query.value = '';
results.value = null;
affectedRows.value = 0;
error.value = null;
};
</script>
<style scoped>
.focus-ring:focus {
box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.15);
}
.query-lab-body {
padding: 1rem;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #e2e8f0;
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: #cbd5e1;
}
.animate-fade-in {
animation: fadeIn 0.4s ease-out;
}
.animate-shake {
animation: shake 0.5s cubic-bezier(.36,.07,.19,.97) both;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes shake {
10%, 90% { transform: translate3d(-1px, 0, 0); }
20%, 80% { transform: translate3d(2px, 0, 0); }
30%, 50%, 70% { transform: translate3d(-4px, 0, 0); }
40%, 60% { transform: translate3d(4px, 0, 0); }
}
:global(.dark-mode) .table-responsive {
background-color: #1e293b;
border-color: #334155;
}
:global(.dark-mode) .table {
color: #f8fafc;
}
:global(.dark-mode) .table thead.bg-light {
background-color: #0f172a !important;
}
</style>