Web app: new entities (Image, RenderedAsset, SharedImage, Token, DeviceImageHistory), enums, repositories, controllers, message handlers, migrations, tests, frontend upload/library/sticker UI, Vue components. Firmware: EPD background screen binaries + gen scripts, setup_bg header. Infra: ddev config, test bundle, gitignore coverage dir. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
<template>
|
||||
<div class="approve-card">
|
||||
<img :src="item.thumbnailUrl" :alt="`Photo from ${item.sharedBy}`" class="approve-card__thumb" loading="lazy" />
|
||||
|
||||
<div class="approve-card__body">
|
||||
<p class="approve-card__from">From <strong>{{ item.sharedBy }}</strong></p>
|
||||
<p class="approve-card__date">{{ formattedDate }}</p>
|
||||
|
||||
<div class="approve-card__status" v-if="item.status !== 'pending'">
|
||||
<span :class="['approve-card__badge', `approve-card__badge--${item.status}`]">
|
||||
{{ item.status }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="approve-card__actions">
|
||||
<template v-if="item.status === 'pending' || item.status === 'declined'">
|
||||
<BaseButton variant="primary" size="sm" :disabled="busy" @click="showPicker = true">
|
||||
{{ item.status === 'declined' ? 'Add anyway' : 'Add to frame' }}
|
||||
</BaseButton>
|
||||
</template>
|
||||
<template v-if="item.status === 'pending' || item.status === 'approved'">
|
||||
<BaseButton variant="ghost" size="sm" :disabled="busy" @click="decline">
|
||||
{{ item.status === 'approved' ? 'Remove' : 'Decline' }}
|
||||
</BaseButton>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DevicePicker
|
||||
v-model="showPicker"
|
||||
:devices="devicesStore.devices"
|
||||
:selected="selectedDeviceIds"
|
||||
:uploading="busy"
|
||||
confirm-label="Add to frames"
|
||||
@update:selected="selectedDeviceIds = $event"
|
||||
@confirm="approve"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import type { SharedImage } from '@/types'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import DevicePicker from '@/components/DevicePicker.vue'
|
||||
import { useImagesStore } from '@/stores/images'
|
||||
import { useDevicesStore } from '@/stores/devices'
|
||||
|
||||
const props = defineProps<{ item: SharedImage }>()
|
||||
const emit = defineEmits<{ (e: 'updated', v: SharedImage): void }>()
|
||||
|
||||
const imagesStore = useImagesStore()
|
||||
const devicesStore = useDevicesStore()
|
||||
const showPicker = ref(false)
|
||||
const busy = ref(false)
|
||||
const selectedDeviceIds = ref<number[]>([])
|
||||
|
||||
const formattedDate = computed(() =>
|
||||
new Date(props.item.sharedAt).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
)
|
||||
|
||||
async function approve() {
|
||||
showPicker.value = false
|
||||
busy.value = true
|
||||
try {
|
||||
const updated = await imagesStore.approveShared(props.item.id, selectedDeviceIds.value)
|
||||
emit('updated', updated)
|
||||
} finally {
|
||||
busy.value = false
|
||||
selectedDeviceIds.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function decline() {
|
||||
busy.value = true
|
||||
try {
|
||||
const updated = await imagesStore.declineShared(props.item.id)
|
||||
emit('updated', updated)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.approve-card {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
|
||||
&__thumb {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius-sm);
|
||||
flex-shrink: 0;
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
&__body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
&__from { font-size: var(--text-sm); }
|
||||
&__date { font-size: var(--text-xs); color: var(--color-text-muted); }
|
||||
|
||||
&__badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
|
||||
&--approved { background: #d4edda; color: #1a7f4b; }
|
||||
&--declined { background: #fde8e8; color: #d93025; }
|
||||
}
|
||||
|
||||
&__actions {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
margin-top: auto;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -8,7 +8,12 @@
|
||||
:aria-label="tab.label"
|
||||
:aria-current="isActive(tab.to) ? 'page' : undefined"
|
||||
>
|
||||
<span class="bottom-nav__icon" aria-hidden="true" v-html="tab.icon" />
|
||||
<span class="bottom-nav__icon-wrap" aria-hidden="true">
|
||||
<span class="bottom-nav__icon" v-html="tab.icon" />
|
||||
<span v-if="tab.name === 'shared' && imagesStore.pendingCount > 0" class="bottom-nav__badge">
|
||||
{{ imagesStore.pendingCount > 9 ? '9+' : imagesStore.pendingCount }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="bottom-nav__label">{{ tab.label }}</span>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
@@ -16,8 +21,10 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useImagesStore } from '@/stores/images'
|
||||
|
||||
const route = useRoute()
|
||||
const route = useRoute()
|
||||
const imagesStore = useImagesStore()
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
@@ -35,7 +42,7 @@ const tabs = [
|
||||
{
|
||||
name: 'shared',
|
||||
label: 'Shared',
|
||||
to: '/shared',
|
||||
to: '/library?tab=shared',
|
||||
icon: '<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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>',
|
||||
},
|
||||
{
|
||||
@@ -46,7 +53,8 @@ const tabs = [
|
||||
},
|
||||
]
|
||||
|
||||
function isActive(path: string) {
|
||||
function isActive(to: string) {
|
||||
const path = to.split('?')[0]
|
||||
if (path === '/') return route.path === '/'
|
||||
return route.path.startsWith(path)
|
||||
}
|
||||
@@ -85,6 +93,15 @@ function isActive(path: string) {
|
||||
}
|
||||
}
|
||||
|
||||
&__icon-wrap {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -93,6 +110,24 @@ function isActive(path: string) {
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
&__badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -6px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
background: var(--color-primary);
|
||||
color: var(--color-primary-fg);
|
||||
border-radius: 999px;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
<template>
|
||||
<div class="crop-editor" ref="containerRef">
|
||||
<canvas
|
||||
ref="canvasRef"
|
||||
class="crop-editor__canvas"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="onPointerUp"
|
||||
@pointercancel="onPointerUp"
|
||||
/>
|
||||
<div class="crop-editor__label" v-if="deviceName">{{ deviceName }}</div>
|
||||
<div class="crop-editor__actions">
|
||||
<BaseButton variant="primary" class="crop-editor__use-btn" @click="useCrop">
|
||||
Use this crop
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import type { CropParams } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
src: string
|
||||
orientation: 'landscape' | 'portrait'
|
||||
deviceName?: string
|
||||
initialParams?: CropParams | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'crop', result: { blob: Blob; params: CropParams }): void
|
||||
}>()
|
||||
|
||||
// Dimensions for each orientation
|
||||
const OUTPUT_W = props.orientation === 'landscape' ? 1600 : 960
|
||||
const OUTPUT_H = props.orientation === 'landscape' ? 960 : 1600
|
||||
const ASPECT = OUTPUT_W / OUTPUT_H
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
const canvasRef = ref<HTMLCanvasElement>()
|
||||
let ctx: CanvasRenderingContext2D | null = null
|
||||
let img: HTMLImageElement | null = null
|
||||
let rafId = 0
|
||||
|
||||
// State: pan (canvas px from centered) + zoom multiplier
|
||||
const panX = ref(0)
|
||||
const panY = ref(0)
|
||||
const zoom = ref(1)
|
||||
|
||||
// Crop rect on canvas (set when canvas is sized)
|
||||
let cropRect = { x: 0, y: 0, w: 0, h: 0 }
|
||||
let minScale = 1 // natural px → canvas px at zoom=1 (cover)
|
||||
|
||||
function sizeCanvas() {
|
||||
const canvas = canvasRef.value
|
||||
const container = containerRef.value
|
||||
if (!canvas || !container) return
|
||||
|
||||
const available = container.getBoundingClientRect()
|
||||
// Leave space for bottom button bar
|
||||
const availH = available.height - 80
|
||||
const availW = available.width
|
||||
|
||||
canvas.width = availW
|
||||
canvas.height = availH
|
||||
|
||||
ctx = canvas.getContext('2d')
|
||||
|
||||
// Compute crop rect (inset 24px each side for comfort)
|
||||
const pad = 24
|
||||
const maxW = availW - pad * 2
|
||||
const maxH = availH - pad * 2
|
||||
|
||||
let cropW: number, cropH: number
|
||||
if (maxW / maxH > ASPECT) {
|
||||
cropH = maxH
|
||||
cropW = cropH * ASPECT
|
||||
} else {
|
||||
cropW = maxW
|
||||
cropH = cropW / ASPECT
|
||||
}
|
||||
|
||||
cropRect = {
|
||||
x: (availW - cropW) / 2,
|
||||
y: (availH - cropH) / 2,
|
||||
w: cropW,
|
||||
h: cropH,
|
||||
}
|
||||
|
||||
if (img) {
|
||||
resetView()
|
||||
}
|
||||
}
|
||||
|
||||
function resetView() {
|
||||
if (!img) return
|
||||
minScale = Math.max(cropRect.w / img.naturalWidth, cropRect.h / img.naturalHeight)
|
||||
if (props.initialParams) {
|
||||
restoreView(props.initialParams)
|
||||
} else {
|
||||
zoom.value = 1
|
||||
panX.value = 0
|
||||
panY.value = 0
|
||||
draw()
|
||||
}
|
||||
}
|
||||
|
||||
function restoreView(p: CropParams) {
|
||||
if (!img) return
|
||||
// actualScale such that natW fills the crop frame width
|
||||
const actualScale = cropRect.w / p.natW
|
||||
zoom.value = actualScale / minScale
|
||||
// pan: offset from centered position so crop center = frame center
|
||||
panX.value = actualScale * (img.naturalWidth / 2 - p.natX - p.natW / 2)
|
||||
panY.value = actualScale * (img.naturalHeight / 2 - p.natY - p.natH / 2)
|
||||
const [cx, cy] = clampPan(panX.value, panY.value)
|
||||
panX.value = cx
|
||||
panY.value = cy
|
||||
draw()
|
||||
}
|
||||
|
||||
function clampPan(px: number, py: number): [number, number] {
|
||||
if (!img) return [px, py]
|
||||
const actualScale = minScale * zoom.value
|
||||
const imgW = img.naturalWidth * actualScale
|
||||
const imgH = img.naturalHeight * actualScale
|
||||
const maxPx = (imgW - cropRect.w) / 2
|
||||
const maxPy = (imgH - cropRect.h) / 2
|
||||
return [
|
||||
Math.max(-maxPx, Math.min(maxPx, px)),
|
||||
Math.max(-maxPy, Math.min(maxPy, py)),
|
||||
]
|
||||
}
|
||||
|
||||
function draw() {
|
||||
if (!ctx || !img || !canvasRef.value) return
|
||||
const { width, height } = canvasRef.value
|
||||
const actualScale = minScale * zoom.value
|
||||
const imgW = img.naturalWidth * actualScale
|
||||
const imgH = img.naturalHeight * actualScale
|
||||
const cx = cropRect.x + cropRect.w / 2 + panX.value
|
||||
const cy = cropRect.y + cropRect.h / 2 + panY.value
|
||||
const imgLeft = cx - imgW / 2
|
||||
const imgTop = cy - imgH / 2
|
||||
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
ctx.drawImage(img, imgLeft, imgTop, imgW, imgH)
|
||||
|
||||
// Dark overlay outside crop
|
||||
ctx.save()
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.55)'
|
||||
ctx.fillRect(0, 0, width, height)
|
||||
ctx.globalCompositeOperation = 'destination-out'
|
||||
ctx.fillRect(cropRect.x, cropRect.y, cropRect.w, cropRect.h)
|
||||
ctx.restore()
|
||||
|
||||
// Crop border + corner marks
|
||||
ctx.strokeStyle = '#fff'
|
||||
ctx.lineWidth = 2
|
||||
ctx.strokeRect(cropRect.x, cropRect.y, cropRect.w, cropRect.h)
|
||||
|
||||
const cLen = 20
|
||||
ctx.lineWidth = 3
|
||||
;[
|
||||
[cropRect.x, cropRect.y, cLen, 0, 0, cLen],
|
||||
[cropRect.x + cropRect.w, cropRect.y, -cLen, 0, 0, cLen],
|
||||
[cropRect.x, cropRect.y + cropRect.h, cLen, 0, 0, -cLen],
|
||||
[cropRect.x + cropRect.w, cropRect.y + cropRect.h, -cLen, 0, 0, -cLen],
|
||||
].forEach(([x, y, dx1, dy1, dx2, dy2]) => {
|
||||
ctx!.beginPath()
|
||||
ctx!.moveTo(x + dx1, y + dy1)
|
||||
ctx!.lineTo(x, y)
|
||||
ctx!.lineTo(x + dx2, y + dy2)
|
||||
ctx!.stroke()
|
||||
})
|
||||
}
|
||||
|
||||
// ── Touch / pointer handling ──────────────────────────────────────────────────
|
||||
|
||||
const activePointers = new Map<number, { x: number; y: number }>()
|
||||
let lastPinchDist = 0
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
canvasRef.value?.setPointerCapture(e.pointerId)
|
||||
activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY })
|
||||
if (activePointers.size === 2) {
|
||||
const pts = [...activePointers.values()]
|
||||
lastPinchDist = Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y)
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
if (!activePointers.has(e.pointerId)) return
|
||||
const prev = activePointers.get(e.pointerId)!
|
||||
activePointers.set(e.pointerId, { x: e.clientX, y: e.clientY })
|
||||
|
||||
if (activePointers.size === 1) {
|
||||
const dx = e.clientX - prev.x
|
||||
const dy = e.clientY - prev.y
|
||||
const [cx, cy] = clampPan(panX.value + dx, panY.value + dy)
|
||||
panX.value = cx
|
||||
panY.value = cy
|
||||
scheduleDraw()
|
||||
return
|
||||
}
|
||||
|
||||
if (activePointers.size === 2) {
|
||||
const pts = [...activePointers.values()]
|
||||
const dist = Math.hypot(pts[1].x - pts[0].x, pts[1].y - pts[0].y)
|
||||
if (lastPinchDist > 0) {
|
||||
const ratio = dist / lastPinchDist
|
||||
const newZoom = Math.max(1, zoom.value * ratio)
|
||||
zoom.value = newZoom
|
||||
const [cx, cy] = clampPan(panX.value, panY.value)
|
||||
panX.value = cx
|
||||
panY.value = cy
|
||||
scheduleDraw()
|
||||
}
|
||||
lastPinchDist = dist
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerUp(e: PointerEvent) {
|
||||
activePointers.delete(e.pointerId)
|
||||
lastPinchDist = 0
|
||||
}
|
||||
|
||||
function scheduleDraw() {
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = requestAnimationFrame(draw)
|
||||
}
|
||||
|
||||
// ── Output ────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function useCrop() {
|
||||
if (!img) return
|
||||
const actualScale = minScale * zoom.value
|
||||
const cx = cropRect.x + cropRect.w / 2 + panX.value
|
||||
const cy = cropRect.y + cropRect.h / 2 + panY.value
|
||||
const imgLeft = cx - img.naturalWidth * actualScale / 2
|
||||
const imgTop = cy - img.naturalHeight * actualScale / 2
|
||||
|
||||
const natCropX = (cropRect.x - imgLeft) / actualScale
|
||||
const natCropY = (cropRect.y - imgTop) / actualScale
|
||||
const natCropW = cropRect.w / actualScale
|
||||
const natCropH = cropRect.h / actualScale
|
||||
|
||||
const out = new OffscreenCanvas(OUTPUT_W, OUTPUT_H)
|
||||
const outCtx = out.getContext('2d')!
|
||||
outCtx.drawImage(img, natCropX, natCropY, natCropW, natCropH, 0, 0, OUTPUT_W, OUTPUT_H)
|
||||
const blob = await out.convertToBlob({ type: 'image/jpeg', quality: 0.92 })
|
||||
emit('crop', {
|
||||
blob,
|
||||
params: { natX: natCropX, natY: natCropY, natW: natCropW, natH: natCropH },
|
||||
})
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const ro = new ResizeObserver(sizeCanvas)
|
||||
|
||||
onMounted(() => {
|
||||
if (containerRef.value) ro.observe(containerRef.value)
|
||||
sizeCanvas()
|
||||
|
||||
img = new Image()
|
||||
img.onload = () => {
|
||||
sizeCanvas()
|
||||
resetView()
|
||||
}
|
||||
img.src = props.src
|
||||
})
|
||||
|
||||
watch(() => props.src, src => {
|
||||
if (!img) return
|
||||
img.onload = () => resetView()
|
||||
img.src = src
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
ro.disconnect()
|
||||
cancelAnimationFrame(rafId)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.crop-editor {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #000;
|
||||
touch-action: none;
|
||||
|
||||
&__canvas {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: block;
|
||||
touch-action: none;
|
||||
cursor: grab;
|
||||
&:active { cursor: grabbing; }
|
||||
}
|
||||
|
||||
&__label {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
color: #fff;
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
padding: 4px 12px;
|
||||
border-radius: 999px;
|
||||
letter-spacing: 0.04em;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: var(--space-4);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__use-btn {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,122 @@
|
||||
<template>
|
||||
<BaseBottomSheet :model-value="modelValue" label="Choose frames" @update:model-value="$emit('update:modelValue', $event)">
|
||||
<h2 class="device-picker__title">Add to frames</h2>
|
||||
<p class="device-picker__sub">Choose which frames will show this photo.</p>
|
||||
|
||||
<div class="device-picker__list">
|
||||
<label
|
||||
v-for="device in devices"
|
||||
:key="device.id"
|
||||
class="device-picker__row"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="device-picker__check"
|
||||
:checked="selected.includes(device.id)"
|
||||
@change="toggle(device.id)"
|
||||
/>
|
||||
<span class="device-picker__name">{{ device.name }}</span>
|
||||
<span class="device-picker__orientation">{{ device.orientation }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<BaseButton
|
||||
variant="primary"
|
||||
class="device-picker__confirm"
|
||||
:disabled="selected.length === 0 || uploading"
|
||||
@click="$emit('confirm')"
|
||||
>
|
||||
{{ uploading ? 'Uploading…' : confirmLabel }}
|
||||
</BaseButton>
|
||||
</BaseBottomSheet>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import BaseBottomSheet from '@/components/BaseBottomSheet.vue'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import type { Device } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
devices: Device[]
|
||||
selected: number[]
|
||||
uploading?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: boolean): void
|
||||
(e: 'update:selected', v: number[]): void
|
||||
(e: 'confirm'): void
|
||||
}>()
|
||||
|
||||
function toggle(id: number) {
|
||||
if (props.selected.includes(id)) {
|
||||
emit('update:selected', props.selected.filter(d => d !== id))
|
||||
} else {
|
||||
emit('update:selected', [...props.selected, id])
|
||||
}
|
||||
}
|
||||
|
||||
const confirmLabel = computed(() => {
|
||||
const n = props.selected.length
|
||||
return n === 0 ? 'Add to frame' : `Add to ${n} frame${n > 1 ? 's' : ''}`
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.device-picker {
|
||||
&__title {
|
||||
font-size: var(--text-md);
|
||||
font-weight: 700;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
&__sub {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
&__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
&__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
cursor: pointer;
|
||||
min-height: var(--touch-min);
|
||||
}
|
||||
|
||||
&__check {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
accent-color: var(--color-primary);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__name {
|
||||
flex: 1;
|
||||
font-size: var(--text-base);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__orientation {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-muted);
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
&__confirm {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<BaseBottomSheet :model-value="modelValue" label="Share photo" @update:model-value="$emit('update:modelValue', $event)">
|
||||
<h2 class="share-sheet__title">Share with someone</h2>
|
||||
<p class="share-sheet__sub">They'll get an email and can add it to their frame.</p>
|
||||
|
||||
<div class="share-sheet__field">
|
||||
<input
|
||||
v-model="email"
|
||||
type="email"
|
||||
class="share-sheet__input"
|
||||
placeholder="their@email.com"
|
||||
autocomplete="email"
|
||||
@keydown.enter.prevent="submit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="errorMsg" class="share-sheet__error">{{ errorMsg }}</p>
|
||||
<p v-if="successMsg" class="share-sheet__success">{{ successMsg }}</p>
|
||||
|
||||
<BaseButton
|
||||
variant="primary"
|
||||
class="share-sheet__btn"
|
||||
:disabled="sending || !email.trim()"
|
||||
@click="submit"
|
||||
>
|
||||
{{ sending ? 'Sending…' : 'Send invite' }}
|
||||
</BaseButton>
|
||||
</BaseBottomSheet>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import BaseBottomSheet from '@/components/BaseBottomSheet.vue'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import { useImagesStore } from '@/stores/images'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean
|
||||
imageId: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: boolean): void
|
||||
}>()
|
||||
|
||||
const imagesStore = useImagesStore()
|
||||
const email = ref('')
|
||||
const sending = ref(false)
|
||||
const errorMsg = ref('')
|
||||
const successMsg = ref('')
|
||||
|
||||
async function submit() {
|
||||
errorMsg.value = ''
|
||||
successMsg.value = ''
|
||||
if (!email.value.trim()) return
|
||||
sending.value = true
|
||||
try {
|
||||
await imagesStore.shareImage(props.imageId, email.value.trim())
|
||||
successMsg.value = `Invite sent to ${email.value.trim()}`
|
||||
email.value = ''
|
||||
} catch (e) {
|
||||
errorMsg.value = e instanceof Error ? e.message : 'Failed to send'
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.share-sheet {
|
||||
&__title { font-size: var(--text-md); font-weight: 700; margin-bottom: var(--space-1); }
|
||||
&__sub { font-size: var(--text-sm); color: var(--color-text-muted); margin-bottom: var(--space-4); }
|
||||
&__field { margin-bottom: var(--space-3); }
|
||||
&__input {
|
||||
width: 100%;
|
||||
min-height: var(--touch-min);
|
||||
padding: 0 var(--space-3);
|
||||
border: 1.5px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-sm);
|
||||
font-family: inherit;
|
||||
&:focus { outline: 2px solid var(--color-primary); outline-offset: 2px; }
|
||||
}
|
||||
&__error { font-size: var(--text-sm); color: var(--color-danger, #d93025); margin-bottom: var(--space-3); }
|
||||
&__success { font-size: var(--text-sm); color: var(--color-success, #1a7f4b); margin-bottom: var(--space-3); }
|
||||
&__btn { width: 100%; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,353 @@
|
||||
<template>
|
||||
<div class="sticker-canvas" ref="containerRef">
|
||||
<v-stage
|
||||
ref="stageRef"
|
||||
:config="stageConfig"
|
||||
@click="onStageClick"
|
||||
@tap="onStageClick"
|
||||
>
|
||||
<v-layer>
|
||||
<v-image :config="imageConfig" />
|
||||
</v-layer>
|
||||
<v-layer ref="stickerLayerRef">
|
||||
<v-text
|
||||
v-for="s in stickers"
|
||||
:key="s.id"
|
||||
:config="stickerConfig(s)"
|
||||
@click="selectSticker(s.id, $event)"
|
||||
@tap="selectSticker(s.id, $event)"
|
||||
@dragend="onDragEnd(s.id, $event)"
|
||||
@transformend="onTransformEnd(s.id, $event)"
|
||||
/>
|
||||
<v-transformer ref="transformerRef" :config="transformerConfig" />
|
||||
</v-layer>
|
||||
</v-stage>
|
||||
|
||||
<!-- Delete selected sticker button -->
|
||||
<button
|
||||
v-if="selectedId"
|
||||
class="sticker-canvas__delete"
|
||||
type="button"
|
||||
aria-label="Remove sticker"
|
||||
@click="removeSelected"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Add sticker / Next -->
|
||||
<div class="sticker-canvas__bar">
|
||||
<button class="sticker-canvas__add-btn" type="button" @click="trayOpen = true">
|
||||
<svg width="20" height="20" 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="16"/><line x1="8" y1="12" x2="16" y2="12"/>
|
||||
</svg>
|
||||
Add sticker
|
||||
</button>
|
||||
<BaseButton variant="primary" class="sticker-canvas__next-btn" @click="done">Next</BaseButton>
|
||||
</div>
|
||||
|
||||
<!-- Sticker tray -->
|
||||
<StickerTray v-model="trayOpen" @pick="addStickerFromTray" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'
|
||||
import Konva from 'konva'
|
||||
import BaseButton from '@/components/BaseButton.vue'
|
||||
import StickerTray from '@/components/StickerTray.vue'
|
||||
import type { StickerLayer } from '@/types'
|
||||
|
||||
const props = defineProps<{
|
||||
croppedUrl: string
|
||||
orientation: 'landscape' | 'portrait'
|
||||
stickers: StickerLayer[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'add-sticker', s: StickerLayer): void
|
||||
(e: 'update-sticker', id: string, patch: Partial<StickerLayer>): void
|
||||
(e: 'remove-sticker', id: string): void
|
||||
(e: 'done', blob: Blob): void
|
||||
}>()
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
const stageRef = ref()
|
||||
const transformerRef = ref()
|
||||
const stickerLayerRef = ref()
|
||||
|
||||
const trayOpen = ref(false)
|
||||
const selectedId = ref<string | null>(null)
|
||||
|
||||
// Stage dimensions fitted to container
|
||||
const stageW = ref(375)
|
||||
const stageH = ref(225)
|
||||
|
||||
const ASPECT = props.orientation === 'landscape' ? (1600 / 960) : (960 / 1600)
|
||||
|
||||
function sizeStage() {
|
||||
if (!containerRef.value) return
|
||||
const { width, height } = containerRef.value.getBoundingClientRect()
|
||||
const availH = height - 72 // bottom bar
|
||||
if (width / availH > ASPECT) {
|
||||
stageH.value = availH
|
||||
stageW.value = availH * ASPECT
|
||||
} else {
|
||||
stageW.value = width
|
||||
stageH.value = width / ASPECT
|
||||
}
|
||||
loadImage()
|
||||
}
|
||||
|
||||
const ro = new ResizeObserver(sizeStage)
|
||||
onMounted(() => {
|
||||
if (containerRef.value) ro.observe(containerRef.value)
|
||||
sizeStage()
|
||||
attachPinchListeners()
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
ro.disconnect()
|
||||
detachPinchListeners()
|
||||
})
|
||||
|
||||
// ── Image ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const bgImage = ref<HTMLImageElement | null>(null)
|
||||
function loadImage() {
|
||||
const i = new Image()
|
||||
i.onload = () => { bgImage.value = i }
|
||||
i.src = props.croppedUrl
|
||||
}
|
||||
watch(() => props.croppedUrl, () => loadImage(), { immediate: true })
|
||||
|
||||
const stageConfig = computed(() => ({ width: stageW.value, height: stageH.value }))
|
||||
const imageConfig = computed(() => ({
|
||||
image: bgImage.value,
|
||||
x: 0, y: 0,
|
||||
width: stageW.value,
|
||||
height: stageH.value,
|
||||
}))
|
||||
|
||||
const transformerConfig = {
|
||||
enabledAnchors: ['top-left', 'top-right', 'bottom-left', 'bottom-right'],
|
||||
rotateEnabled: true,
|
||||
borderStroke: 'rgba(255,255,255,0.8)',
|
||||
anchorFill: '#fff',
|
||||
anchorSize: 18, // larger for touch
|
||||
keepRatio: true,
|
||||
boundBoxFunc: (_: any, newBox: any) => newBox,
|
||||
}
|
||||
|
||||
// ── Stickers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const EMOJI_FONT_SIZE = 52
|
||||
|
||||
function stickerConfig(s: StickerLayer) {
|
||||
return {
|
||||
id: s.id,
|
||||
text: stickerEmoji(s.type),
|
||||
fontSize: EMOJI_FONT_SIZE,
|
||||
fontFamily: '"Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif',
|
||||
x: s.x,
|
||||
y: s.y,
|
||||
scaleX: s.scale,
|
||||
scaleY: s.scale,
|
||||
rotation: s.rotation,
|
||||
draggable: true,
|
||||
offsetX: EMOJI_FONT_SIZE / 2,
|
||||
offsetY: EMOJI_FONT_SIZE / 2,
|
||||
}
|
||||
}
|
||||
|
||||
import { STICKERS } from '@/assets/stickers/index'
|
||||
function stickerEmoji(type: string): string {
|
||||
return STICKERS.find(s => s.id === type)?.emoji ?? '⭐'
|
||||
}
|
||||
|
||||
function selectSticker(id: string, e: any) {
|
||||
e.cancelBubble = true
|
||||
selectedId.value = id
|
||||
nextTick(() => {
|
||||
const layer = stickerLayerRef.value?.getNode()
|
||||
const node = layer?.findOne(`#${id}`)
|
||||
const tr = transformerRef.value?.getNode()
|
||||
if (node && tr) tr.nodes([node])
|
||||
})
|
||||
}
|
||||
|
||||
function onStageClick(e: any) {
|
||||
if (e.target === e.target.getStage()) {
|
||||
selectedId.value = null
|
||||
transformerRef.value?.getNode()?.nodes([])
|
||||
}
|
||||
}
|
||||
|
||||
function removeSelected() {
|
||||
if (!selectedId.value) return
|
||||
emit('remove-sticker', selectedId.value)
|
||||
selectedId.value = null
|
||||
transformerRef.value?.getNode()?.nodes([])
|
||||
}
|
||||
|
||||
function onDragEnd(id: string, e: any) {
|
||||
emit('update-sticker', id, { x: e.target.x(), y: e.target.y() })
|
||||
}
|
||||
|
||||
function onTransformEnd(id: string, e: any) {
|
||||
emit('update-sticker', id, {
|
||||
x: e.target.x(),
|
||||
y: e.target.y(),
|
||||
scale: e.target.scaleX(),
|
||||
rotation: e.target.rotation(),
|
||||
})
|
||||
}
|
||||
|
||||
function addStickerFromTray(stickerId: string) {
|
||||
const s: StickerLayer = {
|
||||
id: `${stickerId}-${Date.now()}`,
|
||||
type: stickerId,
|
||||
x: stageW.value / 2,
|
||||
y: stageH.value / 2,
|
||||
scale: 1,
|
||||
rotation: 0,
|
||||
}
|
||||
emit('add-sticker', s)
|
||||
trayOpen.value = false
|
||||
// Auto-select the new sticker
|
||||
nextTick(() => selectSticker(s.id, { cancelBubble: false }))
|
||||
}
|
||||
|
||||
// ── Pinch-to-resize ───────────────────────────────────────────────────────────
|
||||
|
||||
let pinchStartDist = 0
|
||||
let pinchStartScale = 1
|
||||
|
||||
function touchDist(touches: TouchList) {
|
||||
const dx = touches[0].clientX - touches[1].clientX
|
||||
const dy = touches[0].clientY - touches[1].clientY
|
||||
return Math.hypot(dx, dy)
|
||||
}
|
||||
|
||||
function onPinchStart(e: TouchEvent) {
|
||||
if (e.touches.length !== 2 || !selectedId.value) return
|
||||
pinchStartDist = touchDist(e.touches)
|
||||
const s = props.stickers.find(x => x.id === selectedId.value)
|
||||
pinchStartScale = s?.scale ?? 1
|
||||
}
|
||||
|
||||
function onPinchMove(e: TouchEvent) {
|
||||
if (e.touches.length !== 2 || !selectedId.value || pinchStartDist === 0) return
|
||||
e.preventDefault()
|
||||
const newScale = Math.max(0.2, Math.min(6, pinchStartScale * (touchDist(e.touches) / pinchStartDist)))
|
||||
emit('update-sticker', selectedId.value, { scale: newScale })
|
||||
}
|
||||
|
||||
function onPinchEnd() {
|
||||
pinchStartDist = 0
|
||||
pinchStartScale = 1
|
||||
}
|
||||
|
||||
function attachPinchListeners() {
|
||||
const el = containerRef.value
|
||||
if (!el) return
|
||||
el.addEventListener('touchstart', onPinchStart, { passive: true })
|
||||
el.addEventListener('touchmove', onPinchMove, { passive: false })
|
||||
el.addEventListener('touchend', onPinchEnd, { passive: true })
|
||||
}
|
||||
|
||||
function detachPinchListeners() {
|
||||
const el = containerRef.value
|
||||
if (!el) return
|
||||
el.removeEventListener('touchstart', onPinchStart)
|
||||
el.removeEventListener('touchmove', onPinchMove)
|
||||
el.removeEventListener('touchend', onPinchEnd)
|
||||
}
|
||||
|
||||
// ── Output ────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function done() {
|
||||
// Deselect to hide transformer handles before capture
|
||||
selectedId.value = null
|
||||
transformerRef.value?.getNode()?.nodes([])
|
||||
await nextTick()
|
||||
|
||||
const stage: Konva.Stage = stageRef.value?.getNode()
|
||||
if (!stage) return
|
||||
|
||||
const outputW = props.orientation === 'landscape' ? 1600 : 960
|
||||
const pixelRatio = outputW / stageW.value
|
||||
|
||||
// Use explicit mimeType so the blob is a real JPEG, not the default PNG
|
||||
const blob = await stage.toBlob({ pixelRatio, mimeType: 'image/jpeg', quality: 0.92 }) as Blob | null
|
||||
if (blob) emit('done', blob)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.sticker-canvas {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background: #111;
|
||||
overflow: hidden;
|
||||
|
||||
:deep(.konvajs-content) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__delete {
|
||||
position: absolute;
|
||||
top: var(--space-3);
|
||||
right: var(--space-3);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: rgba(200, 30, 30, 0.85);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
&__bar {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 72px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: 0 var(--space-4);
|
||||
background: var(--color-surface);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
&__add-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1.5px solid var(--color-border);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__next-btn {
|
||||
margin-left: auto;
|
||||
min-width: 96px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,115 @@
|
||||
<template>
|
||||
<BaseBottomSheet :model-value="modelValue" label="Add sticker" @update:model-value="$emit('update:modelValue', $event)">
|
||||
<div class="sticker-tray">
|
||||
<div class="sticker-tray__cats" role="tablist">
|
||||
<button
|
||||
v-for="cat in STICKER_CATEGORIES"
|
||||
:key="cat.id"
|
||||
type="button"
|
||||
role="tab"
|
||||
:class="['sticker-tray__cat', { 'sticker-tray__cat--active': activeCategory === cat.id }]"
|
||||
@click="activeCategory = cat.id"
|
||||
>{{ cat.label }}</button>
|
||||
</div>
|
||||
<div class="sticker-tray__grid" role="tabpanel">
|
||||
<button
|
||||
v-for="s in visibleStickers"
|
||||
:key="s.id"
|
||||
type="button"
|
||||
class="sticker-tray__item"
|
||||
:aria-label="s.label"
|
||||
@click="$emit('pick', s.id)"
|
||||
>
|
||||
<span class="sticker-tray__emoji" aria-hidden="true">{{ s.emoji }}</span>
|
||||
<span class="sticker-tray__label">{{ s.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</BaseBottomSheet>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import BaseBottomSheet from '@/components/BaseBottomSheet.vue'
|
||||
import { STICKERS, STICKER_CATEGORIES } from '@/assets/stickers/index'
|
||||
import type { StickerCategory } from '@/assets/stickers/index'
|
||||
|
||||
defineProps<{ modelValue: boolean }>()
|
||||
defineEmits<{
|
||||
(e: 'update:modelValue', v: boolean): void
|
||||
(e: 'pick', stickerId: string): void
|
||||
}>()
|
||||
|
||||
const activeCategory = ref<StickerCategory>('seasonal')
|
||||
|
||||
const visibleStickers = computed(() =>
|
||||
STICKERS.filter(s => s.category === activeCategory.value)
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.sticker-tray {
|
||||
&__cats {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
overflow-x: auto;
|
||||
padding-bottom: var(--space-3);
|
||||
scrollbar-width: none;
|
||||
&::-webkit-scrollbar { display: none; }
|
||||
}
|
||||
|
||||
&__cat {
|
||||
padding: 6px 14px;
|
||||
border-radius: 999px;
|
||||
border: 1.5px solid var(--color-border);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
transition: all var(--duration-fast);
|
||||
|
||||
&--active {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary-fg);
|
||||
}
|
||||
}
|
||||
|
||||
&__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
&__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: var(--space-2) var(--space-1);
|
||||
border-radius: var(--radius-sm);
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: background var(--duration-fast);
|
||||
|
||||
&:active { background: var(--color-surface-2); }
|
||||
}
|
||||
|
||||
&__emoji {
|
||||
font-size: 36px;
|
||||
line-height: 1;
|
||||
font-family: "Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
text-align: center;
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user