chore(fc2a): polish pass — toasts, loading skeletons, focus guards, mobile breakpoints
AppSnackbar mounts once at the app root and exposes a window.__fcToast function the stores call from error paths. This avoids prop-drilling a toast emitter through every component while keeping the snackbar component itself testable in isolation. GalleryGrid renders shimmer-skeleton placeholders on initial load so the first paint isn't empty. Skeleton uses the same auto-fill grid columns as the real items so layout doesn't shift when content arrives. Modal arrow nav now ignores text-entry elements so typing in the tag autocomplete doesn't navigate prev/next. Small-screen breakpoint (<600px) tightens gallery thumbnails to 120px min. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,9 +3,19 @@
|
|||||||
<AppShell>
|
<AppShell>
|
||||||
<RouterView />
|
<RouterView />
|
||||||
</AppShell>
|
</AppShell>
|
||||||
|
<AppSnackbar ref="snackbar" />
|
||||||
</v-app>
|
</v-app>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
import AppShell from './components/AppShell.vue'
|
import AppShell from './components/AppShell.vue'
|
||||||
|
import AppSnackbar from './components/AppSnackbar.vue'
|
||||||
|
|
||||||
|
const snackbar = ref(null)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// Expose snackbar via a simple global so stores can call it without props.
|
||||||
|
window.__fcToast = (opts) => snackbar.value?.open(opts)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<template>
|
||||||
|
<v-snackbar
|
||||||
|
v-model="show" :color="color" location="bottom right" timeout="4000"
|
||||||
|
multi-line elevation="4"
|
||||||
|
>
|
||||||
|
{{ message }}
|
||||||
|
<template #actions>
|
||||||
|
<v-btn variant="text" @click="show = false">Dismiss</v-btn>
|
||||||
|
</template>
|
||||||
|
</v-snackbar>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
const show = ref(false)
|
||||||
|
const message = ref('')
|
||||||
|
const color = ref('error')
|
||||||
|
|
||||||
|
function open({ text, type = 'error' }) {
|
||||||
|
message.value = text
|
||||||
|
color.value = type === 'error' ? 'error' : type === 'success' ? 'success' : 'info'
|
||||||
|
show.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ open })
|
||||||
|
</script>
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="fc-gallery-grid">
|
<div class="fc-gallery-grid">
|
||||||
|
<div v-if="store.loading && store.images.length === 0" class="fc-gallery-grid__skeleton">
|
||||||
|
<div v-for="i in 12" :key="i" class="fc-gallery-grid__skeleton-item" />
|
||||||
|
</div>
|
||||||
<template v-for="group in store.dateGroups" :key="`${group.year}-${group.month}`">
|
<template v-for="group in store.dateGroups" :key="`${group.year}-${group.month}`">
|
||||||
<h2 class="fc-gallery-grid__date-header" :id="dateHeaderId(group)">
|
<h2 class="fc-gallery-grid__date-header" :id="dateHeaderId(group)">
|
||||||
{{ monthLabel(group.year, group.month) }}
|
{{ monthLabel(group.year, group.month) }}
|
||||||
@@ -96,4 +99,33 @@ function imagesForGroup(group) {
|
|||||||
padding: 32px 0;
|
padding: 32px 0;
|
||||||
color: rgb(var(--v-theme-on-surface-variant, var(--v-theme-on-surface)));
|
color: rgb(var(--v-theme-on-surface-variant, var(--v-theme-on-surface)));
|
||||||
}
|
}
|
||||||
|
.fc-gallery-grid__skeleton {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.fc-gallery-grid__skeleton-item {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
rgb(var(--v-theme-surface)) 0%,
|
||||||
|
rgb(var(--v-theme-surface-light)) 50%,
|
||||||
|
rgb(var(--v-theme-surface)) 100%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: fc-shimmer 1.4s infinite;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
@keyframes fc-shimmer {
|
||||||
|
0% { background-position: 200% 0; }
|
||||||
|
100% { background-position: -200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.fc-gallery-grid__items,
|
||||||
|
.fc-gallery-grid__skeleton {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
<div
|
<div
|
||||||
class="fc-viewer" role="dialog" aria-modal="true"
|
class="fc-viewer" role="dialog" aria-modal="true"
|
||||||
@keydown.esc="$emit('close')"
|
@keydown.esc="$emit('close')"
|
||||||
@keydown.left.prevent="modal.goPrev()"
|
@keydown.left="onArrowLeft"
|
||||||
@keydown.right.prevent="modal.goNext()"
|
@keydown.right="onArrowRight"
|
||||||
tabindex="-1" ref="rootEl"
|
tabindex="-1" ref="rootEl"
|
||||||
>
|
>
|
||||||
<button class="fc-viewer__close" @click="$emit('close')" aria-label="Close">
|
<button class="fc-viewer__close" @click="$emit('close')" aria-label="Close">
|
||||||
@@ -83,6 +83,22 @@ watch(() => modal.currentImageId, async () => {
|
|||||||
function nextFrame() {
|
function nextFrame() {
|
||||||
return new Promise(resolve => requestAnimationFrame(resolve))
|
return new Promise(resolve => requestAnimationFrame(resolve))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function onArrowLeft(ev) {
|
||||||
|
if (isTextEntry(ev.target)) return
|
||||||
|
ev.preventDefault()
|
||||||
|
modal.goPrev()
|
||||||
|
}
|
||||||
|
function onArrowRight(ev) {
|
||||||
|
if (isTextEntry(ev.target)) return
|
||||||
|
ev.preventDefault()
|
||||||
|
modal.goNext()
|
||||||
|
}
|
||||||
|
function isTextEntry(el) {
|
||||||
|
if (!el) return false
|
||||||
|
const tag = el.tagName
|
||||||
|
return tag === 'INPUT' || tag === 'TEXTAREA' || el.isContentEditable
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export const useImportStore = defineStore('import', () => {
|
|||||||
settings.value = await api.patch('/api/settings/import', { body: patch })
|
settings.value = await api.patch('/api/settings/import', { body: patch })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
settingsError.value = e.message
|
settingsError.value = e.message
|
||||||
|
window.__fcToast?.({ text: `Settings save failed: ${e.message}`, type: 'error' })
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -58,6 +59,7 @@ export const useImportStore = defineStore('import', () => {
|
|||||||
await refreshStatus()
|
await refreshStatus()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
triggerError.value = e.message
|
triggerError.value = e.message
|
||||||
|
window.__fcToast?.({ text: `Scan failed: ${e.message}`, type: 'error' })
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export const useModalStore = defineStore('modal', () => {
|
|||||||
await api.delete(`/api/images/${currentImageId.value}/tags/${tagId}`)
|
await api.delete(`/api/images/${currentImageId.value}/tags/${tagId}`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
current.value.tags = prev
|
current.value.tags = prev
|
||||||
|
window.__fcToast?.({ text: `Failed to remove tag: ${e.message}`, type: 'error' })
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user