cbb5bb1ff3
CI / test (push) Has been cancelled
Each thumbnail now shows a yellow warning triangle in its action stack when at least one approved device's orientation does not match the photo's crop orientation. Tap opens the edit flow with that device set as the crop context, so the existing in-crop-tool indicator can guide the re-crop. Photos without a stored cropOrientation fall back to inferring it from the saved cropParams aspect, so older uploads aren't left blind. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
631 lines
20 KiB
Vue
631 lines
20 KiB
Vue
<template>
|
|
<main class="library">
|
|
<!-- Tabs -->
|
|
<div class="library__tabs" role="tablist">
|
|
<button
|
|
v-for="tab in TABS"
|
|
:key="tab.id"
|
|
type="button"
|
|
role="tab"
|
|
:aria-selected="activeTab === tab.id"
|
|
:class="['library__tab', { 'library__tab--active': activeTab === tab.id }]"
|
|
@click="activeTab = tab.id"
|
|
>{{ tab.label }}</button>
|
|
</div>
|
|
|
|
<!-- Loading -->
|
|
<div v-if="imagesStore.loading" class="library__loading">Loading…</div>
|
|
|
|
<!-- All / Mine tab -->
|
|
<template v-else-if="activeTab !== 'shared'">
|
|
<div v-if="visibleImages.length === 0" class="library__empty">
|
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
|
|
<rect x="3" y="3" width="18" height="18" rx="2"/>
|
|
<circle cx="8.5" cy="8.5" r="1.5"/>
|
|
<polyline points="21,15 16,10 5,21"/>
|
|
</svg>
|
|
<p class="library__empty-title">No photos yet</p>
|
|
<p class="library__empty-sub">Tap "+ Add Photo" on the home screen to get started.</p>
|
|
</div>
|
|
|
|
<div v-else class="library__grid">
|
|
<div v-for="image in visibleImages" :key="image.id" class="library__item">
|
|
<div class="library__thumb">
|
|
<img
|
|
:src="image.thumbnailUrl"
|
|
:alt="image.originalFilename"
|
|
class="library__img"
|
|
loading="lazy"
|
|
/>
|
|
<div class="library__thumb-actions">
|
|
<button
|
|
v-if="mismatchedDevice(image)"
|
|
class="library__action-btn library__action-btn--warn"
|
|
type="button"
|
|
:aria-label="`Crop orientation does not match ${mismatchedDevice(image)!.name}; tap to re-crop`"
|
|
:title="`Cropped ${photoCropOrientation(image)}, but ${mismatchedDevice(image)!.name} is set to ${mismatchedDevice(image)!.orientation}.`"
|
|
@click="startEdit(image, mismatchedDevice(image)!.id)"
|
|
>
|
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
|
|
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
|
|
<line x1="12" y1="9" x2="12" y2="13"/>
|
|
<line x1="12" y1="17" x2="12.01" y2="17"/>
|
|
</svg>
|
|
</button>
|
|
<button
|
|
class="library__action-btn"
|
|
type="button"
|
|
:aria-label="`Edit ${image.originalFilename}`"
|
|
:disabled="editingId === image.id"
|
|
@click="startEdit(image)"
|
|
>
|
|
<svg v-if="editingId !== image.id" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
|
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
|
</svg>
|
|
<svg v-else width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
|
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
|
|
</svg>
|
|
</button>
|
|
<button
|
|
class="library__action-btn"
|
|
type="button"
|
|
:aria-label="`Share ${image.originalFilename}`"
|
|
@click="openShare(image.id)"
|
|
>
|
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
|
|
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
|
|
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/>
|
|
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
|
|
</svg>
|
|
</button>
|
|
<button
|
|
class="library__action-btn library__action-btn--danger"
|
|
type="button"
|
|
aria-label="Delete photo"
|
|
@click="confirmDelete(image.id)"
|
|
>
|
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
|
|
<polyline points="3 6 5 6 21 6"/>
|
|
<path d="M19 6l-1 14H6L5 6"/>
|
|
<path d="M10 11v6M14 11v6"/>
|
|
<path d="M9 6V4h6v2"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="devicesStore.devices.length > 0" class="library__approvals">
|
|
<button
|
|
v-for="device in devicesStore.devices"
|
|
:key="device.id"
|
|
:class="['library__approval-chip', { 'library__approval-chip--on': image.approvedDeviceIds.includes(device.id) }]"
|
|
type="button"
|
|
:aria-label="`${image.approvedDeviceIds.includes(device.id) ? 'Remove from' : 'Add to'} ${device.name}`"
|
|
@click="toggleApproval(image.id, device.id, !image.approvedDeviceIds.includes(device.id))"
|
|
>
|
|
{{ device.name }}
|
|
</button>
|
|
</div>
|
|
|
|
<div v-if="devicesStore.devices.length > 0" class="library__locks">
|
|
<button
|
|
v-for="device in devicesStore.devices.filter(d => image.approvedDeviceIds.includes(d.id))"
|
|
:key="device.id"
|
|
:class="['library__lock-chip', { 'library__lock-chip--on': device.lockedImageId === image.id }]"
|
|
type="button"
|
|
:aria-label="`${device.lockedImageId === image.id ? 'Unlock from' : 'Lock to'} ${device.name}`"
|
|
@click="toggleLock(image.id, device)"
|
|
>
|
|
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
|
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
|
|
<path v-if="device.lockedImageId === image.id" d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
|
<path v-else d="M7 11V7a5 5 0 0 1 9.9-1"/>
|
|
</svg>
|
|
{{ device.name }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Shared tab -->
|
|
<template v-else>
|
|
<!-- Sub-tabs -->
|
|
<div class="library__subtabs" role="tablist">
|
|
<button
|
|
v-for="st in SHARED_TABS"
|
|
:key="st.id"
|
|
type="button"
|
|
role="tab"
|
|
:aria-selected="sharedTab === st.id"
|
|
:class="['library__subtab', { 'library__subtab--active': sharedTab === st.id }]"
|
|
@click="switchSharedTab(st.id)"
|
|
>{{ st.label }}</button>
|
|
</div>
|
|
|
|
<div v-if="sharedLoading" class="library__loading">Loading…</div>
|
|
|
|
<div v-else-if="sharedItems.length === 0" class="library__shared-empty">
|
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
|
|
<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/>
|
|
<line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/>
|
|
<line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/>
|
|
</svg>
|
|
<p class="library__empty-title">
|
|
{{ sharedTab === 'pending' ? 'No pending photos' : sharedTab === 'approved' ? 'No approved photos' : 'No declined photos' }}
|
|
</p>
|
|
<p class="library__empty-sub">
|
|
{{ sharedTab === 'pending' ? 'Photos shared with you will appear here.' : 'Photos you\'ve added to a frame will appear here.' }}
|
|
</p>
|
|
</div>
|
|
|
|
<div v-else class="library__shared-list">
|
|
<ApproveCard
|
|
v-for="item in sharedItems"
|
|
:key="item.id"
|
|
:item="item"
|
|
@updated="onSharedUpdated"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Pagination -->
|
|
<div v-if="sharedTotalPages > 1" class="library__pagination">
|
|
<button
|
|
class="library__page-btn"
|
|
:disabled="sharedPage <= 1"
|
|
@click="goSharedPage(sharedPage - 1)"
|
|
>← Prev</button>
|
|
<span class="library__page-info">{{ sharedPage }} / {{ sharedTotalPages }}</span>
|
|
<button
|
|
class="library__page-btn"
|
|
:disabled="sharedPage >= sharedTotalPages"
|
|
@click="goSharedPage(sharedPage + 1)"
|
|
>Next →</button>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- Share sheet -->
|
|
<ShareSheet v-if="shareImageId !== null" v-model="shareSheetOpen" :image-id="shareImageId!" />
|
|
|
|
<!-- Confirm delete sheet -->
|
|
<BaseBottomSheet v-model="deleteSheetOpen" label="Delete photo">
|
|
<h2 class="library__sheet-title">Delete this photo?</h2>
|
|
<p class="library__sheet-sub">It will be removed from all frames.</p>
|
|
<div class="library__sheet-actions">
|
|
<BaseButton variant="secondary" @click="deleteSheetOpen = false">Cancel</BaseButton>
|
|
<BaseButton variant="destructive" :disabled="deleting" @click="doDelete">
|
|
{{ deleting ? 'Deleting…' : 'Delete' }}
|
|
</BaseButton>
|
|
</div>
|
|
</BaseBottomSheet>
|
|
</main>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { useImagesStore } from '@/stores/images'
|
|
import { useDevicesStore } from '@/stores/devices'
|
|
import { useUploadStore } from '@/stores/upload'
|
|
import { useToastStore } from '@/stores/toast'
|
|
import BaseBottomSheet from '@/components/BaseBottomSheet.vue'
|
|
import BaseButton from '@/components/BaseButton.vue'
|
|
import ApproveCard from '@/components/ApproveCard.vue'
|
|
import ShareSheet from '@/components/ShareSheet.vue'
|
|
import type { Device, Image, SharedImage } from '@/types'
|
|
|
|
const router = useRouter()
|
|
const imagesStore = useImagesStore()
|
|
const devicesStore = useDevicesStore()
|
|
const uploadStore = useUploadStore()
|
|
const toast = useToastStore()
|
|
const route = useRoute()
|
|
|
|
const TABS = [
|
|
{ id: 'all', label: 'All' },
|
|
{ id: 'mine', label: 'Mine' },
|
|
{ id: 'shared', label: 'Shared' },
|
|
] as const
|
|
type Tab = typeof TABS[number]['id']
|
|
|
|
const activeTab = ref<Tab>((route.query.tab as Tab) ?? 'all')
|
|
|
|
const SHARED_TABS = [
|
|
{ id: 'pending', label: 'Pending' },
|
|
{ id: 'approved', label: 'Approved' },
|
|
{ id: 'declined', label: 'Declined' },
|
|
] as const
|
|
type SharedTab = typeof SHARED_TABS[number]['id']
|
|
|
|
const sharedTab = ref<SharedTab>('pending')
|
|
const sharedItems = ref<SharedImage[]>([])
|
|
const sharedLoading = ref(false)
|
|
const sharedPage = ref(1)
|
|
const sharedTotalPages = ref(1)
|
|
|
|
async function loadShared(tab: SharedTab, page = 1) {
|
|
sharedLoading.value = true
|
|
try {
|
|
const result = await imagesStore.fetchSharedImages(tab, page)
|
|
sharedItems.value = result.items
|
|
sharedPage.value = result.page
|
|
sharedTotalPages.value = result.totalPages
|
|
} finally {
|
|
sharedLoading.value = false
|
|
}
|
|
}
|
|
|
|
function switchSharedTab(tab: SharedTab) {
|
|
sharedTab.value = tab
|
|
loadShared(tab, 1)
|
|
}
|
|
|
|
function goSharedPage(page: number) {
|
|
loadShared(sharedTab.value, page)
|
|
}
|
|
|
|
function onSharedUpdated(updated: SharedImage) {
|
|
const idx = sharedItems.value.findIndex(i => i.id === updated.id)
|
|
if (idx !== -1) sharedItems.value[idx] = updated
|
|
}
|
|
|
|
onMounted(() => {
|
|
imagesStore.fetchImages()
|
|
devicesStore.fetchDevices()
|
|
imagesStore.fetchPendingCount()
|
|
if (activeTab.value === 'shared') loadShared(sharedTab.value)
|
|
})
|
|
|
|
// For now "mine" and "all" show the same list; shared is a placeholder
|
|
const visibleImages = computed(() => imagesStore.images)
|
|
|
|
// ── Share ─────────────────────────────────────────────────────────────────────
|
|
|
|
const shareSheetOpen = ref(false)
|
|
const shareImageId = ref<number | null>(null)
|
|
|
|
function openShare(id: number) {
|
|
shareImageId.value = id
|
|
shareSheetOpen.value = true
|
|
}
|
|
|
|
// ── Edit ──────────────────────────────────────────────────────────────────────
|
|
|
|
const editingId = ref<number | null>(null)
|
|
|
|
async function startEdit(image: Image, deviceId?: number) {
|
|
if (editingId.value) return
|
|
editingId.value = image.id
|
|
try {
|
|
await uploadStore.initEdit(image, deviceId)
|
|
router.push('/upload')
|
|
} catch {
|
|
toast.show('Could not load photo for editing', 'error')
|
|
} finally {
|
|
editingId.value = null
|
|
}
|
|
}
|
|
|
|
// ── Orientation mismatch ──────────────────────────────────────────────────────
|
|
|
|
// Photo's effective crop orientation. Prefers the explicit cropOrientation
|
|
// field; falls back to inferring from cropParams aspect for legacy uploads
|
|
// predating the field. Returns null when neither is available.
|
|
function photoCropOrientation(image: Image): 'landscape' | 'portrait' | null {
|
|
if (image.cropOrientation) return image.cropOrientation
|
|
const p = image.cropParams
|
|
if (!p?.natW || !p?.natH) return null
|
|
return p.natW >= p.natH ? 'landscape' : 'portrait'
|
|
}
|
|
|
|
// First approved device whose orientation doesn't match the photo's crop
|
|
// orientation. Drives the warning triangle and the click target for re-crop.
|
|
function mismatchedDevice(image: Image): Device | null {
|
|
const photoOri = photoCropOrientation(image)
|
|
if (!photoOri) return null
|
|
for (const id of image.approvedDeviceIds) {
|
|
const d = devicesStore.devices.find(dev => dev.id === id)
|
|
if (d && d.orientation !== photoOri) return d
|
|
}
|
|
return null
|
|
}
|
|
|
|
// ── Lock ──────────────────────────────────────────────────────────────────────
|
|
|
|
async function toggleLock(imageId: number, device: Device) {
|
|
try {
|
|
if (device.lockedImageId === imageId) {
|
|
await devicesStore.unlockImage(device.id)
|
|
} else {
|
|
await devicesStore.lockImage(device.id, imageId)
|
|
}
|
|
} catch {
|
|
toast.show('Failed to update lock', 'error')
|
|
}
|
|
}
|
|
|
|
// ── Approval toggles ──────────────────────────────────────────────────────────
|
|
|
|
async function toggleApproval(imageId: number, deviceId: number, approved: boolean) {
|
|
try {
|
|
await imagesStore.setApproval(imageId, deviceId, approved)
|
|
} catch {
|
|
toast.show('Failed to update frame approval', 'error')
|
|
}
|
|
}
|
|
|
|
// ── Delete ────────────────────────────────────────────────────────────────────
|
|
|
|
const deleteSheetOpen = ref(false)
|
|
const deletingId = ref<number | null>(null)
|
|
const deleting = ref(false)
|
|
|
|
function confirmDelete(id: number) {
|
|
deletingId.value = id
|
|
deleteSheetOpen.value = true
|
|
}
|
|
|
|
async function doDelete() {
|
|
if (!deletingId.value) return
|
|
deleting.value = true
|
|
try {
|
|
await imagesStore.deleteImage(deletingId.value)
|
|
deleteSheetOpen.value = false
|
|
toast.show('Photo deleted', 'success')
|
|
} catch {
|
|
toast.show('Delete failed', 'error')
|
|
} finally {
|
|
deleting.value = false
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped lang="scss">
|
|
.library {
|
|
padding-bottom: calc(64px + var(--space-4));
|
|
|
|
&__tabs {
|
|
display: flex;
|
|
border-bottom: 1px solid var(--color-border);
|
|
position: sticky;
|
|
top: 0;
|
|
background: var(--color-bg);
|
|
z-index: 5;
|
|
}
|
|
|
|
&__tab {
|
|
flex: 1;
|
|
padding: var(--space-3) 0;
|
|
font-size: var(--text-sm);
|
|
font-weight: 600;
|
|
color: var(--color-text-muted);
|
|
border: none;
|
|
background: none;
|
|
cursor: pointer;
|
|
border-bottom: 2px solid transparent;
|
|
transition: all var(--duration-fast);
|
|
|
|
&--active {
|
|
color: var(--color-primary);
|
|
border-bottom-color: var(--color-primary);
|
|
}
|
|
}
|
|
|
|
&__loading {
|
|
color: var(--color-text-muted);
|
|
font-size: var(--text-sm);
|
|
padding: var(--space-4);
|
|
text-align: center;
|
|
}
|
|
|
|
&__subtabs {
|
|
display: flex;
|
|
border-bottom: 1px solid var(--color-border);
|
|
background: var(--color-bg);
|
|
}
|
|
|
|
&__subtab {
|
|
flex: 1;
|
|
padding: var(--space-2) 0;
|
|
font-size: var(--text-xs);
|
|
font-weight: 600;
|
|
color: var(--color-text-muted);
|
|
border: none;
|
|
background: none;
|
|
cursor: pointer;
|
|
border-bottom: 2px solid transparent;
|
|
transition: all var(--duration-fast);
|
|
|
|
&--active {
|
|
color: var(--color-primary);
|
|
border-bottom-color: var(--color-primary);
|
|
}
|
|
}
|
|
|
|
&__shared-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-3);
|
|
padding: var(--space-4);
|
|
}
|
|
|
|
&__pagination {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: var(--space-3);
|
|
padding: var(--space-4);
|
|
}
|
|
|
|
&__page-btn {
|
|
padding: var(--space-2) var(--space-3);
|
|
border: 1.5px solid var(--color-border);
|
|
border-radius: var(--radius-md);
|
|
background: none;
|
|
font-size: var(--text-sm);
|
|
font-weight: 600;
|
|
color: var(--color-text);
|
|
cursor: pointer;
|
|
&:disabled { opacity: .4; cursor: default; }
|
|
}
|
|
|
|
&__page-info {
|
|
font-size: var(--text-sm);
|
|
color: var(--color-text-muted);
|
|
}
|
|
|
|
&__empty, &__shared-empty {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: var(--space-3);
|
|
padding: var(--space-6) var(--space-4);
|
|
color: var(--color-text-muted);
|
|
text-align: center;
|
|
}
|
|
|
|
&__empty-title {
|
|
font-size: var(--text-md);
|
|
font-weight: 700;
|
|
color: var(--color-text);
|
|
}
|
|
|
|
&__empty-sub {
|
|
font-size: var(--text-sm);
|
|
max-width: 280px;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
&__grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, 1fr);
|
|
gap: var(--space-3);
|
|
padding: var(--space-4);
|
|
}
|
|
|
|
&__item {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--space-2);
|
|
}
|
|
|
|
&__thumb {
|
|
position: relative;
|
|
aspect-ratio: 4/3;
|
|
border-radius: var(--radius-sm);
|
|
overflow: hidden;
|
|
background: var(--color-surface-2);
|
|
}
|
|
|
|
&__img {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
display: block;
|
|
}
|
|
|
|
&__thumb-actions {
|
|
position: absolute;
|
|
top: var(--space-2);
|
|
right: var(--space-2);
|
|
display: flex;
|
|
gap: 4px;
|
|
}
|
|
|
|
&__action-btn {
|
|
width: 28px;
|
|
height: 28px;
|
|
border-radius: 50%;
|
|
background: rgba(0, 0, 0, 0.55);
|
|
border: none;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
color: #fff;
|
|
transition: background var(--duration-fast);
|
|
flex-shrink: 0;
|
|
|
|
&:disabled { opacity: 0.5; cursor: default; }
|
|
&:hover:not(:disabled) { background: rgba(0, 0, 0, 0.75); }
|
|
&--danger:hover:not(:disabled) { background: rgba(180, 0, 0, 0.8); }
|
|
|
|
&--warn {
|
|
background: var(--color-warning, #f59e0b);
|
|
&:hover:not(:disabled) { background: color-mix(in srgb, var(--color-warning, #f59e0b) 85%, black); }
|
|
}
|
|
}
|
|
|
|
&__approvals {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: var(--space-1);
|
|
}
|
|
|
|
&__approval-chip {
|
|
padding: 3px 10px;
|
|
border-radius: 999px;
|
|
border: 1.5px solid var(--color-border);
|
|
font-size: var(--text-xs, 11px);
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
background: transparent;
|
|
color: var(--color-text-muted);
|
|
transition: all var(--duration-fast);
|
|
|
|
&--on {
|
|
background: var(--color-primary);
|
|
border-color: var(--color-primary);
|
|
color: #fff;
|
|
}
|
|
}
|
|
|
|
&__locks {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: var(--space-1);
|
|
}
|
|
|
|
&__lock-chip {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
padding: 3px 8px;
|
|
border-radius: 999px;
|
|
border: 1.5px dashed var(--color-border);
|
|
font-size: var(--text-xs, 11px);
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
background: transparent;
|
|
color: var(--color-text-muted);
|
|
transition: all var(--duration-fast);
|
|
|
|
&--on {
|
|
background: var(--color-warning, #f59e0b);
|
|
border-color: var(--color-warning, #f59e0b);
|
|
border-style: solid;
|
|
color: #fff;
|
|
}
|
|
}
|
|
|
|
&__sheet-title {
|
|
font-size: var(--text-md);
|
|
font-weight: 700;
|
|
margin-bottom: var(--space-2);
|
|
}
|
|
|
|
&__sheet-sub {
|
|
font-size: var(--text-sm);
|
|
color: var(--color-text-muted);
|
|
margin-bottom: var(--space-5);
|
|
}
|
|
|
|
&__sheet-actions {
|
|
display: flex;
|
|
gap: var(--space-3);
|
|
> * { flex: 1; }
|
|
}
|
|
}
|
|
</style>
|