Implemented comprehensive UI overhaul with three-layer architecture: Layer 1 - Theme System: - CSS variables for light/dark themes (theme.css) - Theme composable with light/dark/auto mode (useTheme.ts) - Sidebar state management composable (useSidebar.ts) - Refactored main.css to use CSS variables throughout Layer 2 - Core Components: - AppSidebar with collapsible navigation (desktop) and overlay (mobile) - CollapsibleSection reusable component for expandable cards - Restructured App.vue with new sidebar layout - Integrated Lucide icons library (lucide-vue-next) Layer 3 - Views & Components: - Updated all 14 views with CSS variables and responsive design - Replaced inline SVG with Lucide icon components - Added collapsible sections to Dashboard, Admin pages, UserProfile - Updated 3 shared components (BookingForm, SpaceCalendar, AttachmentsList) Features: - Dark/light/auto theme with persistent preference - Collapsible sidebar (icons-only on desktop, overlay on mobile) - Consistent color palette using CSS variables - Full responsive design across all pages - Modern minimalist aesthetic with Indigo accent color Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
241 lines
5.3 KiB
Vue
241 lines
5.3 KiB
Vue
<template>
|
|
<div class="attachments-list">
|
|
<h4 class="attachments-title">Attachments</h4>
|
|
|
|
<div v-if="loading" class="loading">Loading attachments...</div>
|
|
|
|
<div v-else-if="error" class="error">{{ error }}</div>
|
|
|
|
<div v-else-if="attachments.length === 0" class="no-attachments">No attachments</div>
|
|
|
|
<ul v-else class="attachment-items">
|
|
<li v-for="attachment in attachments" :key="attachment.id" class="attachment-item">
|
|
<div class="attachment-info">
|
|
<span class="attachment-icon">📎</span>
|
|
<div class="attachment-details">
|
|
<a
|
|
:href="getDownloadUrl(attachment.id)"
|
|
class="attachment-name"
|
|
target="_blank"
|
|
:download="attachment.filename"
|
|
>
|
|
{{ attachment.filename }}
|
|
</a>
|
|
<span class="attachment-meta">
|
|
{{ formatFileSize(attachment.size) }} · Uploaded by {{ attachment.uploader_name }} ·
|
|
{{ formatDate(attachment.created_at) }}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<button
|
|
v-if="canDelete"
|
|
type="button"
|
|
class="btn-delete"
|
|
:disabled="deleting === attachment.id"
|
|
@click="handleDelete(attachment.id)"
|
|
>
|
|
{{ deleting === attachment.id ? 'Deleting...' : 'Delete' }}
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { attachmentsApi, handleApiError } from '@/services/api'
|
|
import { useAuthStore } from '@/stores/auth'
|
|
import { formatDateTime as formatDateTimeUtil } from '@/utils/datetime'
|
|
import type { Attachment } from '@/types'
|
|
|
|
interface Props {
|
|
bookingId: number
|
|
canDelete?: boolean
|
|
}
|
|
|
|
interface Emits {
|
|
(e: 'deleted'): void
|
|
}
|
|
|
|
const props = defineProps<Props>()
|
|
const emit = defineEmits<Emits>()
|
|
|
|
const authStore = useAuthStore()
|
|
const userTimezone = computed(() => authStore.user?.timezone || 'UTC')
|
|
const attachments = ref<Attachment[]>([])
|
|
const loading = ref(true)
|
|
const error = ref('')
|
|
const deleting = ref<number | null>(null)
|
|
|
|
const loadAttachments = async () => {
|
|
loading.value = true
|
|
error.value = ''
|
|
|
|
try {
|
|
attachments.value = await attachmentsApi.list(props.bookingId)
|
|
} catch (err) {
|
|
error.value = handleApiError(err)
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
const handleDelete = async (attachmentId: number) => {
|
|
if (!confirm('Are you sure you want to delete this attachment?')) {
|
|
return
|
|
}
|
|
|
|
deleting.value = attachmentId
|
|
try {
|
|
await attachmentsApi.delete(attachmentId)
|
|
attachments.value = attachments.value.filter(a => a.id !== attachmentId)
|
|
emit('deleted')
|
|
} catch (err) {
|
|
error.value = handleApiError(err)
|
|
} finally {
|
|
deleting.value = null
|
|
}
|
|
}
|
|
|
|
const getDownloadUrl = (attachmentId: number): string => {
|
|
return attachmentsApi.download(attachmentId)
|
|
}
|
|
|
|
const formatFileSize = (bytes: number): string => {
|
|
if (bytes === 0) return '0 Bytes'
|
|
const k = 1024
|
|
const sizes = ['Bytes', 'KB', 'MB']
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i]
|
|
}
|
|
|
|
const formatDate = (dateString: string): string => {
|
|
return formatDateTimeUtil(dateString, userTimezone.value)
|
|
}
|
|
|
|
onMounted(() => {
|
|
loadAttachments()
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.attachments-list {
|
|
margin-top: 24px;
|
|
}
|
|
|
|
.attachments-title {
|
|
font-size: 16px;
|
|
font-weight: 600;
|
|
color: var(--color-text-primary);
|
|
margin: 0 0 12px 0;
|
|
}
|
|
|
|
.loading,
|
|
.error,
|
|
.no-attachments {
|
|
padding: 12px;
|
|
border-radius: var(--radius-sm);
|
|
font-size: 14px;
|
|
}
|
|
|
|
.loading,
|
|
.no-attachments {
|
|
background: var(--color-bg-tertiary);
|
|
color: var(--color-text-secondary);
|
|
}
|
|
|
|
.error {
|
|
background: color-mix(in srgb, var(--color-danger) 10%, transparent);
|
|
color: var(--color-danger);
|
|
}
|
|
|
|
.attachment-items {
|
|
list-style: none;
|
|
padding: 0;
|
|
margin: 0;
|
|
}
|
|
|
|
.attachment-item {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 12px;
|
|
background: var(--color-bg-secondary);
|
|
border: 1px solid var(--color-border);
|
|
border-radius: var(--radius-sm);
|
|
margin-bottom: 8px;
|
|
gap: 12px;
|
|
}
|
|
|
|
.attachment-info {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 12px;
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.attachment-icon {
|
|
font-size: 20px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.attachment-details {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 4px;
|
|
min-width: 0;
|
|
}
|
|
|
|
.attachment-name {
|
|
font-size: 14px;
|
|
color: var(--color-accent);
|
|
text-decoration: none;
|
|
font-weight: 500;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.attachment-name:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.attachment-meta {
|
|
font-size: 12px;
|
|
color: var(--color-text-secondary);
|
|
}
|
|
|
|
.btn-delete {
|
|
padding: 6px 12px;
|
|
background: var(--color-surface);
|
|
color: var(--color-danger);
|
|
border: 1px solid var(--color-danger);
|
|
border-radius: var(--radius-sm);
|
|
font-size: 13px;
|
|
cursor: pointer;
|
|
transition: all var(--transition-fast);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.btn-delete:hover:not(:disabled) {
|
|
background: color-mix(in srgb, var(--color-danger) 5%, transparent);
|
|
}
|
|
|
|
.btn-delete:disabled {
|
|
opacity: 0.6;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
@media (max-width: 640px) {
|
|
.attachment-item {
|
|
flex-direction: column;
|
|
align-items: stretch;
|
|
}
|
|
|
|
.btn-delete {
|
|
width: 100%;
|
|
}
|
|
}
|
|
</style>
|