Compare commits
2 Commits
00121aaec9
...
bdb717de2e
| Author | SHA1 | Date | |
|---|---|---|---|
| bdb717de2e | |||
| 5a0db3cd60 |
@@ -0,0 +1,15 @@
|
|||||||
|
import santaHatUrl from './santa-hat.svg?url'
|
||||||
|
|
||||||
|
export interface CustomStickerAsset {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CUSTOM_STICKER_ASSETS: CustomStickerAsset[] = [
|
||||||
|
{ id: 'santa-hat', label: 'Santa hat', url: santaHatUrl },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function customAssetUrl(id: string): string | undefined {
|
||||||
|
return CUSTOM_STICKER_ASSETS.find(a => a.id === id)?.url
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<path d="M 8 52 L 56 52 Q 50 30 42 12 Q 32 22 24 32 Q 14 42 8 52 Z" fill="#cf2030"/>
|
||||||
|
<path d="M 24 32 Q 14 42 8 52 Q 18 50 28 44 Q 26 38 24 32 Z" fill="#a8141d" opacity="0.55"/>
|
||||||
|
<rect x="6" y="48" width="52" height="10" rx="5" fill="#ffffff"/>
|
||||||
|
<ellipse cx="14" cy="53" rx="2.2" ry="1.6" fill="#ececec"/>
|
||||||
|
<ellipse cx="28" cy="53" rx="2.2" ry="1.6" fill="#ececec"/>
|
||||||
|
<ellipse cx="42" cy="53" rx="2.2" ry="1.6" fill="#ececec"/>
|
||||||
|
<ellipse cx="52" cy="53" rx="2" ry="1.4" fill="#ececec"/>
|
||||||
|
<circle cx="42" cy="12" r="7.5" fill="#ffffff"/>
|
||||||
|
<circle cx="40" cy="10" r="2" fill="#f4f4f4" opacity="0.6"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 678 B |
@@ -216,13 +216,18 @@ function draw() {
|
|||||||
ctx.clearRect(0, 0, width, height)
|
ctx.clearRect(0, 0, width, height)
|
||||||
ctx.drawImage(img, imgLeft, imgTop, imgW, imgH)
|
ctx.drawImage(img, imgLeft, imgTop, imgW, imgH)
|
||||||
|
|
||||||
// Dark overlay outside crop
|
// Dim *outside* the crop with 4 strips. The previous destination-out
|
||||||
ctx.save()
|
// trick using a semi-transparent fill made the crop area partially
|
||||||
ctx.fillStyle = 'rgba(0,0,0,0.55)'
|
// transparent (revealing the black container bg) instead of leaving
|
||||||
ctx.fillRect(0, 0, width, height)
|
// it untouched, so the inside ended up *darker* than the outside —
|
||||||
ctx.globalCompositeOperation = 'destination-out'
|
// the inverse of what a crop overlay is meant to do.
|
||||||
ctx.fillRect(cropRect.x, cropRect.y, cropRect.w, cropRect.h)
|
const cx2 = cropRect.x + cropRect.w
|
||||||
ctx.restore()
|
const cy2 = cropRect.y + cropRect.h
|
||||||
|
ctx.fillStyle = 'rgba(0,0,0,0.6)'
|
||||||
|
ctx.fillRect(0, 0, width, cropRect.y) // top
|
||||||
|
ctx.fillRect(0, cy2, width, height - cy2) // bottom
|
||||||
|
ctx.fillRect(0, cropRect.y, cropRect.x, cropRect.h) // left
|
||||||
|
ctx.fillRect(cx2, cropRect.y, width - cx2, cropRect.h) // right
|
||||||
|
|
||||||
// Crop border + corner marks
|
// Crop border + corner marks
|
||||||
ctx.strokeStyle = '#fff'
|
ctx.strokeStyle = '#fff'
|
||||||
|
|||||||
@@ -10,29 +10,46 @@
|
|||||||
<v-image :config="imageConfig" />
|
<v-image :config="imageConfig" />
|
||||||
</v-layer>
|
</v-layer>
|
||||||
<v-layer ref="stickerLayerRef">
|
<v-layer ref="stickerLayerRef">
|
||||||
<v-text
|
<template v-for="s in stickers" :key="s.id">
|
||||||
v-for="s in stickers"
|
<v-image
|
||||||
:key="s.id"
|
v-if="s.imageAsset"
|
||||||
:config="stickerConfig(s)"
|
:config="imageStickerConfig(s)"
|
||||||
@click="selectSticker(s.id, $event)"
|
@click="selectSticker(s.id, $event)"
|
||||||
@tap="selectSticker(s.id, $event)"
|
@tap="selectSticker(s.id, $event)"
|
||||||
@dragend="onDragEnd(s.id, $event)"
|
@dragend="onDragEnd(s.id, $event)"
|
||||||
@transformend="onTransformEnd(s.id, $event)"
|
@transformend="onTransformEnd(s.id, $event)"
|
||||||
/>
|
/>
|
||||||
|
<v-text
|
||||||
|
v-else
|
||||||
|
:config="emojiStickerConfig(s)"
|
||||||
|
@click="selectSticker(s.id, $event)"
|
||||||
|
@tap="selectSticker(s.id, $event)"
|
||||||
|
@dragend="onDragEnd(s.id, $event)"
|
||||||
|
@transformend="onTransformEnd(s.id, $event)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
<v-transformer ref="transformerRef" :config="transformerConfig" />
|
<v-transformer ref="transformerRef" :config="transformerConfig" />
|
||||||
</v-layer>
|
</v-layer>
|
||||||
</v-stage>
|
</v-stage>
|
||||||
|
|
||||||
<!-- Delete selected sticker button -->
|
<!-- Floating delete handle — follows the selected sticker so the user
|
||||||
|
doesn't have to hunt for an off-canvas X. Trash icon (not X) reads
|
||||||
|
as "remove" not "close." -->
|
||||||
<button
|
<button
|
||||||
v-if="selectedId"
|
v-if="handlePos"
|
||||||
class="sticker-canvas__delete"
|
class="sticker-canvas__delete-handle"
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Remove sticker"
|
aria-label="Remove sticker"
|
||||||
@click="removeSelected"
|
:style="{ left: handlePos.x + 'px', top: handlePos.y + 'px' }"
|
||||||
|
@pointerdown.stop
|
||||||
|
@click.stop="removeSelected"
|
||||||
>
|
>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
<polyline points="3 6 5 6 21 6"/>
|
||||||
|
<path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
|
||||||
|
<path d="M10 11v6"/>
|
||||||
|
<path d="M14 11v6"/>
|
||||||
|
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -109,8 +126,18 @@ onMounted(() => {
|
|||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
ro.disconnect()
|
ro.disconnect()
|
||||||
detachPinchListeners()
|
detachPinchListeners()
|
||||||
|
detachHandleListeners?.()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Pinch-resize and the parent's stickers prop both bypass Konva's
|
||||||
|
// drag/transform events, so refresh the handle when the props change.
|
||||||
|
watch(() => props.stickers, () => {
|
||||||
|
if (!selectedId.value) return
|
||||||
|
const layer = stickerLayerRef.value?.getNode()
|
||||||
|
const node = layer?.findOne(`#${selectedId.value}`)
|
||||||
|
if (node) refreshHandle(node)
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
// ── Image ─────────────────────────────────────────────────────────────────────
|
// ── Image ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const bgImage = ref<HTMLImageElement | null>(null)
|
const bgImage = ref<HTMLImageElement | null>(null)
|
||||||
@@ -121,6 +148,21 @@ function loadImage() {
|
|||||||
}
|
}
|
||||||
watch(() => props.croppedUrl, () => loadImage(), { immediate: true })
|
watch(() => props.croppedUrl, () => loadImage(), { immediate: true })
|
||||||
|
|
||||||
|
// ── Custom-sprite asset preloader ─────────────────────────────────────────────
|
||||||
|
// Image-based stickers need an HTMLImageElement loaded before Konva can
|
||||||
|
// paint them; preload all known custom assets up front so the first
|
||||||
|
// pick renders without a flicker.
|
||||||
|
const imageAssetCache = ref<Record<string, HTMLImageElement>>({})
|
||||||
|
onMounted(() => {
|
||||||
|
for (const a of CUSTOM_STICKER_ASSETS) {
|
||||||
|
const i = new Image()
|
||||||
|
i.onload = () => {
|
||||||
|
imageAssetCache.value = { ...imageAssetCache.value, [a.id]: i }
|
||||||
|
}
|
||||||
|
i.src = a.url
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const stageConfig = computed(() => ({ width: stageW.value, height: stageH.value }))
|
const stageConfig = computed(() => ({ width: stageW.value, height: stageH.value }))
|
||||||
const imageConfig = computed(() => ({
|
const imageConfig = computed(() => ({
|
||||||
image: bgImage.value,
|
image: bgImage.value,
|
||||||
@@ -142,11 +184,12 @@ const transformerConfig = {
|
|||||||
// ── Stickers ──────────────────────────────────────────────────────────────────
|
// ── Stickers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const EMOJI_FONT_SIZE = 52
|
const EMOJI_FONT_SIZE = 52
|
||||||
|
const IMAGE_STICKER_BASE_SIZE = 96
|
||||||
|
|
||||||
function stickerConfig(s: StickerLayer) {
|
function emojiStickerConfig(s: StickerLayer) {
|
||||||
return {
|
return {
|
||||||
id: s.id,
|
id: s.id,
|
||||||
text: stickerEmoji(s.type),
|
text: emojiFor(s),
|
||||||
fontSize: EMOJI_FONT_SIZE,
|
fontSize: EMOJI_FONT_SIZE,
|
||||||
fontFamily: '"Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif',
|
fontFamily: '"Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif',
|
||||||
x: s.x,
|
x: s.x,
|
||||||
@@ -160,9 +203,63 @@ function stickerConfig(s: StickerLayer) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function imageStickerConfig(s: StickerLayer) {
|
||||||
|
return {
|
||||||
|
id: s.id,
|
||||||
|
image: s.imageAsset ? imageAssetCache.value[s.imageAsset] : null,
|
||||||
|
x: s.x,
|
||||||
|
y: s.y,
|
||||||
|
width: IMAGE_STICKER_BASE_SIZE,
|
||||||
|
height: IMAGE_STICKER_BASE_SIZE,
|
||||||
|
scaleX: s.scale,
|
||||||
|
scaleY: s.scale,
|
||||||
|
rotation: s.rotation,
|
||||||
|
draggable: true,
|
||||||
|
offsetX: IMAGE_STICKER_BASE_SIZE / 2,
|
||||||
|
offsetY: IMAGE_STICKER_BASE_SIZE / 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
import { STICKERS } from '@/assets/stickers/index'
|
import { STICKERS } from '@/assets/stickers/index'
|
||||||
function stickerEmoji(type: string): string {
|
import { CUSTOM_STICKER_ASSETS } from '@/assets/stickers/customAssets'
|
||||||
return STICKERS.find(s => s.id === type)?.emoji ?? '⭐'
|
|
||||||
|
function emojiFor(s: StickerLayer): string {
|
||||||
|
// New stickers carry the glyph directly; legacy stickers fall back to
|
||||||
|
// the curated table by id.
|
||||||
|
if (s.emoji) return s.emoji
|
||||||
|
return STICKERS.find(x => x.id === s.type)?.emoji ?? '⭐'
|
||||||
|
}
|
||||||
|
|
||||||
|
// Screen-space position of the delete handle. Tracks the selected
|
||||||
|
// sticker's transformed bbox so the handle stays glued to its top-right
|
||||||
|
// corner through drag/resize/rotate.
|
||||||
|
const handlePos = ref<{ x: number; y: number } | null>(null)
|
||||||
|
let detachHandleListeners: (() => void) | null = null
|
||||||
|
|
||||||
|
function refreshHandle(node: any) {
|
||||||
|
const box = node.getClientRect()
|
||||||
|
// Sit just outside the top-right of the sticker; nudge inward a touch
|
||||||
|
// so it never disappears beyond the stage edge for a corner sticker.
|
||||||
|
const margin = 4
|
||||||
|
const half = 14
|
||||||
|
let x = box.x + box.width + margin - half
|
||||||
|
let y = box.y - margin - half
|
||||||
|
// Clamp inside stage so the button is always tappable.
|
||||||
|
x = Math.max(half, Math.min(stageW.value - half, x))
|
||||||
|
y = Math.max(half, Math.min(stageH.value - half, y))
|
||||||
|
handlePos.value = { x, y }
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachHandleTracking(id: string) {
|
||||||
|
detachHandleListeners?.()
|
||||||
|
detachHandleListeners = null
|
||||||
|
const layer = stickerLayerRef.value?.getNode()
|
||||||
|
const node = layer?.findOne(`#${id}`)
|
||||||
|
if (!node) { handlePos.value = null; return }
|
||||||
|
refreshHandle(node)
|
||||||
|
const update = () => refreshHandle(node)
|
||||||
|
node.on('dragmove.handle transform.handle transformend.handle dragend.handle', update)
|
||||||
|
detachHandleListeners = () => node.off('.handle')
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectSticker(id: string, e: any) {
|
function selectSticker(id: string, e: any) {
|
||||||
@@ -173,6 +270,7 @@ function selectSticker(id: string, e: any) {
|
|||||||
const node = layer?.findOne(`#${id}`)
|
const node = layer?.findOne(`#${id}`)
|
||||||
const tr = transformerRef.value?.getNode()
|
const tr = transformerRef.value?.getNode()
|
||||||
if (node && tr) tr.nodes([node])
|
if (node && tr) tr.nodes([node])
|
||||||
|
attachHandleTracking(id)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,6 +278,9 @@ function onStageClick(e: any) {
|
|||||||
if (e.target === e.target.getStage()) {
|
if (e.target === e.target.getStage()) {
|
||||||
selectedId.value = null
|
selectedId.value = null
|
||||||
transformerRef.value?.getNode()?.nodes([])
|
transformerRef.value?.getNode()?.nodes([])
|
||||||
|
detachHandleListeners?.()
|
||||||
|
detachHandleListeners = null
|
||||||
|
handlePos.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,6 +289,9 @@ function removeSelected() {
|
|||||||
emit('remove-sticker', selectedId.value)
|
emit('remove-sticker', selectedId.value)
|
||||||
selectedId.value = null
|
selectedId.value = null
|
||||||
transformerRef.value?.getNode()?.nodes([])
|
transformerRef.value?.getNode()?.nodes([])
|
||||||
|
detachHandleListeners?.()
|
||||||
|
detachHandleListeners = null
|
||||||
|
handlePos.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDragEnd(id: string, e: any) {
|
function onDragEnd(id: string, e: any) {
|
||||||
@@ -203,14 +307,19 @@ function onTransformEnd(id: string, e: any) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function addStickerFromTray(stickerId: string) {
|
function addStickerFromTray(payload: { emoji?: string; imageAsset?: string }) {
|
||||||
|
const seed = payload.imageAsset ?? payload.emoji ?? 'sticker'
|
||||||
const s: StickerLayer = {
|
const s: StickerLayer = {
|
||||||
id: `${stickerId}-${Date.now()}`,
|
id: `${seed}-${Date.now()}`,
|
||||||
type: stickerId,
|
// `type` doubles as a coarse kind label so legacy serialization
|
||||||
x: stageW.value / 2,
|
// keeps a non-empty value (the field is required server-side).
|
||||||
y: stageH.value / 2,
|
type: payload.imageAsset ? 'image' : 'emoji',
|
||||||
scale: 1,
|
emoji: payload.emoji,
|
||||||
rotation: 0,
|
imageAsset: payload.imageAsset,
|
||||||
|
x: stageW.value / 2,
|
||||||
|
y: stageH.value / 2,
|
||||||
|
scale: 1,
|
||||||
|
rotation: 0,
|
||||||
}
|
}
|
||||||
emit('add-sticker', s)
|
emit('add-sticker', s)
|
||||||
trayOpen.value = false
|
trayOpen.value = false
|
||||||
@@ -299,21 +408,24 @@ async function done() {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__delete {
|
&__delete-handle {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: var(--space-3);
|
width: 28px;
|
||||||
right: var(--space-3);
|
height: 28px;
|
||||||
width: 36px;
|
margin: 0;
|
||||||
height: 36px;
|
padding: 0;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: rgba(200, 30, 30, 0.85);
|
background: rgba(200, 30, 30, 0.95);
|
||||||
border: none;
|
border: 2px solid #fff;
|
||||||
|
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.4);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
touch-action: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__bar {
|
&__bar {
|
||||||
|
|||||||
@@ -1,115 +1,252 @@
|
|||||||
<template>
|
<template>
|
||||||
<BaseBottomSheet :model-value="modelValue" label="Add sticker" @update:model-value="$emit('update:modelValue', $event)">
|
<BaseBottomSheet :model-value="modelValue" label="Add sticker" @update:model-value="$emit('update:modelValue', $event)">
|
||||||
<div class="sticker-tray">
|
<div class="sticker-tray">
|
||||||
<div class="sticker-tray__cats" role="tablist">
|
<!-- Recently used row — surfaces the user's last picks first so
|
||||||
<button
|
common stickers (a heart, a snowflake) are one tap away. -->
|
||||||
v-for="cat in STICKER_CATEGORIES"
|
<section v-if="recents.length" class="sticker-tray__section">
|
||||||
:key="cat.id"
|
<h3 class="sticker-tray__heading">Recent</h3>
|
||||||
type="button"
|
<div class="sticker-tray__row">
|
||||||
role="tab"
|
<button
|
||||||
:class="['sticker-tray__cat', { 'sticker-tray__cat--active': activeCategory === cat.id }]"
|
v-for="r in recents"
|
||||||
@click="activeCategory = cat.id"
|
:key="r.key"
|
||||||
>{{ cat.label }}</button>
|
type="button"
|
||||||
</div>
|
class="sticker-tray__chip"
|
||||||
<div class="sticker-tray__grid" role="tabpanel">
|
:aria-label="r.label"
|
||||||
<button
|
@click="pickRecent(r)"
|
||||||
v-for="s in visibleStickers"
|
>
|
||||||
:key="s.id"
|
<span v-if="r.kind === 'emoji'" class="sticker-tray__emoji" aria-hidden="true">{{ r.emoji }}</span>
|
||||||
type="button"
|
<img v-else :src="r.url" alt="" class="sticker-tray__img" />
|
||||||
class="sticker-tray__item"
|
</button>
|
||||||
:aria-label="s.label"
|
</div>
|
||||||
@click="$emit('pick', s.id)"
|
</section>
|
||||||
>
|
|
||||||
<span class="sticker-tray__emoji" aria-hidden="true">{{ s.emoji }}</span>
|
<!-- Custom (non-emoji) sprites the user asked for. Renders alongside
|
||||||
<span class="sticker-tray__label">{{ s.label }}</span>
|
recents so they're easy to grab without navigating away. -->
|
||||||
</button>
|
<section class="sticker-tray__section">
|
||||||
</div>
|
<h3 class="sticker-tray__heading">Stickers</h3>
|
||||||
|
<div class="sticker-tray__row">
|
||||||
|
<button
|
||||||
|
v-for="a in CUSTOM_STICKER_ASSETS"
|
||||||
|
:key="a.id"
|
||||||
|
type="button"
|
||||||
|
class="sticker-tray__chip"
|
||||||
|
:aria-label="a.label"
|
||||||
|
@click="pickCustom(a)"
|
||||||
|
>
|
||||||
|
<img :src="a.url" :alt="a.label" class="sticker-tray__img" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Emoji input — focusing the field opens the OS emoji keyboard
|
||||||
|
on iOS/Android. Desktop users can paste or type any emoji.
|
||||||
|
We capture the *last* grapheme so users can pick several in a
|
||||||
|
row without the field accumulating. -->
|
||||||
|
<section class="sticker-tray__section">
|
||||||
|
<h3 class="sticker-tray__heading">Any emoji</h3>
|
||||||
|
<p class="sticker-tray__hint">Tap the box, then pick from your keyboard's emoji button.</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="sticker-tray__emoji-input"
|
||||||
|
inputmode="text"
|
||||||
|
autocomplete="off"
|
||||||
|
autocorrect="off"
|
||||||
|
autocapitalize="off"
|
||||||
|
spellcheck="false"
|
||||||
|
placeholder="😀 🎉 🐶 …"
|
||||||
|
aria-label="Emoji input — use your keyboard's emoji button"
|
||||||
|
@input="onEmojiInput"
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</BaseBottomSheet>
|
</BaseBottomSheet>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import BaseBottomSheet from '@/components/BaseBottomSheet.vue'
|
import BaseBottomSheet from '@/components/BaseBottomSheet.vue'
|
||||||
import { STICKERS, STICKER_CATEGORIES } from '@/assets/stickers/index'
|
import { CUSTOM_STICKER_ASSETS, type CustomStickerAsset } from '@/assets/stickers/customAssets'
|
||||||
import type { StickerCategory } from '@/assets/stickers/index'
|
|
||||||
|
|
||||||
defineProps<{ modelValue: boolean }>()
|
defineProps<{ modelValue: boolean }>()
|
||||||
defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'update:modelValue', v: boolean): void
|
(e: 'update:modelValue', v: boolean): void
|
||||||
(e: 'pick', stickerId: string): void
|
(e: 'pick', payload: { emoji?: string; imageAsset?: string }): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const activeCategory = ref<StickerCategory>('seasonal')
|
interface RecentItem {
|
||||||
|
key: string
|
||||||
|
kind: 'emoji' | 'image'
|
||||||
|
label: string
|
||||||
|
emoji?: string
|
||||||
|
imageAsset?: string
|
||||||
|
url?: string
|
||||||
|
}
|
||||||
|
|
||||||
const visibleStickers = computed(() =>
|
const RECENTS_KEY = 'pf.stickerTray.recents'
|
||||||
STICKERS.filter(s => s.category === activeCategory.value)
|
const MAX_RECENTS = 12
|
||||||
)
|
|
||||||
|
const recents = ref<RecentItem[]>([])
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
recents.value = loadRecents()
|
||||||
|
})
|
||||||
|
|
||||||
|
function loadRecents(): RecentItem[] {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(RECENTS_KEY)
|
||||||
|
if (!raw) return []
|
||||||
|
const parsed = JSON.parse(raw) as RecentItem[]
|
||||||
|
// Re-hydrate image URLs from the asset registry — assets may move
|
||||||
|
// across builds, but the id stays stable.
|
||||||
|
return parsed.map(r => {
|
||||||
|
if (r.kind === 'image' && r.imageAsset) {
|
||||||
|
const a = CUSTOM_STICKER_ASSETS.find(x => x.id === r.imageAsset)
|
||||||
|
return { ...r, url: a?.url, label: a?.label ?? r.label }
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}).filter(r => r.kind === 'emoji' ? !!r.emoji : !!r.url)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function persistRecents() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(RECENTS_KEY, JSON.stringify(recents.value))
|
||||||
|
} catch { /* quota / private mode — non-fatal */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberRecent(item: RecentItem) {
|
||||||
|
recents.value = [item, ...recents.value.filter(r => r.key !== item.key)].slice(0, MAX_RECENTS)
|
||||||
|
persistRecents()
|
||||||
|
}
|
||||||
|
|
||||||
|
function firstGrapheme(input: string): string | null {
|
||||||
|
if (!input) return null
|
||||||
|
// Intl.Segmenter understands ZWJ-joined emoji ("👨👩👧" is one grapheme,
|
||||||
|
// not five) so multi-codepoint emoji come through whole.
|
||||||
|
if (typeof Intl !== 'undefined' && (Intl as any).Segmenter) {
|
||||||
|
const seg = new (Intl as any).Segmenter(undefined, { granularity: 'grapheme' })
|
||||||
|
const list = [...seg.segment(input)] as Array<{ segment: string }>
|
||||||
|
return list[list.length - 1]?.segment ?? null
|
||||||
|
}
|
||||||
|
// Fallback for older browsers without Intl.Segmenter.
|
||||||
|
return [...input].pop() ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEmojiInput(e: Event) {
|
||||||
|
const el = e.target as HTMLInputElement
|
||||||
|
const grapheme = firstGrapheme(el.value)
|
||||||
|
el.value = ''
|
||||||
|
if (!grapheme) return
|
||||||
|
const item: RecentItem = {
|
||||||
|
key: `emoji:${grapheme}`,
|
||||||
|
kind: 'emoji',
|
||||||
|
label: grapheme,
|
||||||
|
emoji: grapheme,
|
||||||
|
}
|
||||||
|
rememberRecent(item)
|
||||||
|
emit('pick', { emoji: grapheme })
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickCustom(a: CustomStickerAsset) {
|
||||||
|
rememberRecent({
|
||||||
|
key: `image:${a.id}`,
|
||||||
|
kind: 'image',
|
||||||
|
label: a.label,
|
||||||
|
imageAsset: a.id,
|
||||||
|
url: a.url,
|
||||||
|
})
|
||||||
|
emit('pick', { imageAsset: a.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickRecent(r: RecentItem) {
|
||||||
|
rememberRecent(r)
|
||||||
|
if (r.kind === 'emoji' && r.emoji) {
|
||||||
|
emit('pick', { emoji: r.emoji })
|
||||||
|
} else if (r.kind === 'image' && r.imageAsset) {
|
||||||
|
emit('pick', { imageAsset: r.imageAsset })
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.sticker-tray {
|
.sticker-tray {
|
||||||
&__cats {
|
&__section {
|
||||||
display: flex;
|
margin-bottom: var(--space-4);
|
||||||
gap: var(--space-2);
|
&:last-child { margin-bottom: 0; }
|
||||||
overflow-x: auto;
|
|
||||||
padding-bottom: var(--space-3);
|
|
||||||
scrollbar-width: none;
|
|
||||||
&::-webkit-scrollbar { display: none; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&__cat {
|
&__heading {
|
||||||
padding: 6px 14px;
|
font-size: var(--text-xs);
|
||||||
border-radius: 999px;
|
font-weight: 700;
|
||||||
border: 1.5px solid var(--color-border);
|
text-transform: uppercase;
|
||||||
font-size: var(--text-sm);
|
letter-spacing: 0.06em;
|
||||||
font-weight: 600;
|
|
||||||
white-space: nowrap;
|
|
||||||
cursor: pointer;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
transition: all var(--duration-fast);
|
margin: 0 0 var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
&--active {
|
&__hint {
|
||||||
background: var(--color-primary);
|
font-size: var(--text-xs);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
margin: 0 0 var(--space-2);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__chip {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1.5px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--color-surface);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--duration-fast), border-color var(--duration-fast);
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: var(--color-surface-2);
|
||||||
border-color: 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 {
|
&__emoji {
|
||||||
font-size: 36px;
|
font-size: 32px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
font-family: "Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif;
|
font-family: "Apple Color Emoji","Segoe UI Emoji","Noto Color Emoji",sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__label {
|
&__img {
|
||||||
font-size: 10px;
|
width: 40px;
|
||||||
font-weight: 600;
|
height: 40px;
|
||||||
color: var(--color-text-muted);
|
object-fit: contain;
|
||||||
text-align: center;
|
pointer-events: none;
|
||||||
line-height: 1.2;
|
}
|
||||||
|
|
||||||
|
&__emoji-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);
|
||||||
|
// 16px+ avoids iOS Safari's auto-zoom-on-focus.
|
||||||
|
font-size: 18px;
|
||||||
|
font-family: inherit;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
import { mount } from '@vue/test-utils'
|
import { mount, flushPromises } from '@vue/test-utils'
|
||||||
import StickerTray from '@/components/StickerTray.vue'
|
import StickerTray from '@/components/StickerTray.vue'
|
||||||
import { STICKERS, STICKER_CATEGORIES } from '@/assets/stickers/index'
|
import { CUSTOM_STICKER_ASSETS } from '@/assets/stickers/customAssets'
|
||||||
|
|
||||||
// Stub the bottom sheet so the modal contents are always rendered inline
|
|
||||||
vi.mock('@/components/BaseBottomSheet.vue', () => ({
|
vi.mock('@/components/BaseBottomSheet.vue', () => ({
|
||||||
default: {
|
default: {
|
||||||
name: 'BaseBottomSheet',
|
name: 'BaseBottomSheet',
|
||||||
@@ -13,36 +12,63 @@ vi.mock('@/components/BaseBottomSheet.vue', () => ({
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
describe('StickerTray', () => {
|
beforeEach(() => {
|
||||||
it('renders one tab per sticker category', () => {
|
localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('StickerTray (emoji-keyboard picker)', () => {
|
||||||
|
it('renders the custom-sticker row', async () => {
|
||||||
const wrapper = mount(StickerTray, { props: { modelValue: true } })
|
const wrapper = mount(StickerTray, { props: { modelValue: true } })
|
||||||
expect(wrapper.findAll('.sticker-tray__cat')).toHaveLength(STICKER_CATEGORIES.length)
|
await flushPromises()
|
||||||
|
const chips = wrapper.findAll('.sticker-tray__chip')
|
||||||
|
expect(chips.length).toBeGreaterThanOrEqual(CUSTOM_STICKER_ASSETS.length)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('starts on the seasonal category', () => {
|
it('emits pick with imageAsset when a custom sticker is tapped', async () => {
|
||||||
const wrapper = mount(StickerTray, { props: { modelValue: true } })
|
const wrapper = mount(StickerTray, { props: { modelValue: true } })
|
||||||
const seasonal = wrapper.findAll('.sticker-tray__cat').find(b => b.text() === 'Seasonal')
|
await flushPromises()
|
||||||
expect(seasonal?.classes()).toContain('sticker-tray__cat--active')
|
const santa = wrapper.findAll('.sticker-tray__chip').find(c => c.attributes('aria-label') === 'Santa hat')!
|
||||||
const seasonalCount = STICKERS.filter(s => s.category === 'seasonal').length
|
expect(santa).toBeTruthy()
|
||||||
expect(wrapper.findAll('.sticker-tray__item')).toHaveLength(seasonalCount)
|
await santa.trigger('click')
|
||||||
})
|
|
||||||
|
|
||||||
it('switches the visible grid when a different category tab is clicked', async () => {
|
|
||||||
const wrapper = mount(StickerTray, { props: { modelValue: true } })
|
|
||||||
const fun = wrapper.findAll('.sticker-tray__cat').find(b => b.text() === 'Fun')!
|
|
||||||
await fun.trigger('click')
|
|
||||||
const funCount = STICKERS.filter(s => s.category === 'fun').length
|
|
||||||
expect(wrapper.findAll('.sticker-tray__item')).toHaveLength(funCount)
|
|
||||||
expect(fun.classes()).toContain('sticker-tray__cat--active')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('emits "pick" with the sticker id when an item is clicked', async () => {
|
|
||||||
const wrapper = mount(StickerTray, { props: { modelValue: true } })
|
|
||||||
const firstItem = wrapper.find('.sticker-tray__item')
|
|
||||||
await firstItem.trigger('click')
|
|
||||||
const events = wrapper.emitted('pick')
|
const events = wrapper.emitted('pick')
|
||||||
expect(events).toBeTruthy()
|
expect(events).toBeTruthy()
|
||||||
expect(typeof events![0][0]).toBe('string')
|
expect(events![0][0]).toEqual({ imageAsset: 'santa-hat' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits pick with the typed emoji when the input fires', async () => {
|
||||||
|
const wrapper = mount(StickerTray, { props: { modelValue: true } })
|
||||||
|
await flushPromises()
|
||||||
|
const input = wrapper.find('.sticker-tray__emoji-input')
|
||||||
|
expect(input.exists()).toBe(true)
|
||||||
|
;(input.element as HTMLInputElement).value = '🎉'
|
||||||
|
await input.trigger('input')
|
||||||
|
const events = wrapper.emitted('pick')
|
||||||
|
expect(events).toBeTruthy()
|
||||||
|
expect(events![0][0]).toEqual({ emoji: '🎉' })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clears the input after each emoji pick so the next pick is independent', async () => {
|
||||||
|
const wrapper = mount(StickerTray, { props: { modelValue: true } })
|
||||||
|
await flushPromises()
|
||||||
|
const input = wrapper.find('.sticker-tray__emoji-input').element as HTMLInputElement
|
||||||
|
input.value = '😀'
|
||||||
|
await wrapper.find('.sticker-tray__emoji-input').trigger('input')
|
||||||
|
expect(input.value).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('persists picks to a Recent row across mounts', async () => {
|
||||||
|
const first = mount(StickerTray, { props: { modelValue: true } })
|
||||||
|
await flushPromises()
|
||||||
|
const input = first.find('.sticker-tray__emoji-input')
|
||||||
|
;(input.element as HTMLInputElement).value = '🌈'
|
||||||
|
await input.trigger('input')
|
||||||
|
|
||||||
|
const second = mount(StickerTray, { props: { modelValue: true } })
|
||||||
|
await flushPromises()
|
||||||
|
const headings = second.findAll('.sticker-tray__heading').map(h => h.text())
|
||||||
|
expect(headings).toContain('Recent')
|
||||||
|
const recentChip = second.findAll('.sticker-tray__chip').find(c => c.attributes('aria-label') === '🌈')
|
||||||
|
expect(recentChip).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('forwards update:modelValue from the wrapped sheet', async () => {
|
it('forwards update:modelValue from the wrapped sheet', async () => {
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ export interface StickerLayer {
|
|||||||
y: number
|
y: number
|
||||||
scale: number
|
scale: number
|
||||||
rotation: number
|
rotation: number
|
||||||
|
/** New stickers carry the emoji glyph directly; legacy stickers leave this
|
||||||
|
* blank and the renderer falls back to the STICKERS lookup by `type`. */
|
||||||
|
emoji?: string
|
||||||
|
/** Set for image-based stickers (e.g. 'santa-hat') that aren't representable
|
||||||
|
* as a single Unicode emoji. Mutually exclusive with `emoji`. */
|
||||||
|
imageAsset?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Image {
|
export interface Image {
|
||||||
|
|||||||
@@ -94,8 +94,8 @@
|
|||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
<p class="install-modal__footer">
|
<p class="install-modal__footer">
|
||||||
The app will appear on your home screen and open without
|
The app will appear on your home screen. Open it from there
|
||||||
browser chrome the next time you launch it.
|
and it runs like a regular app — no address bar, no tabs.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
import{B as e,C as t,F as n,N as r,S as i,Y as a,_ as o,g as s,h as c,p as l,t as u,x as d,y as f}from"./_plugin-vue_export-helper-BNDVmFr7.js";import{n as p,t as m}from"./BaseBottomSheet-Bsol3Sat.js";var h={class:`device-picker__list`},g=[`checked`,`onChange`],_={class:`device-picker__name`},v={class:`device-picker__orientation`},y=u(t({__name:`DevicePicker`,props:{modelValue:{type:Boolean},devices:{},selected:{},uploading:{type:Boolean}},emits:[`update:modelValue`,`update:selected`,`confirm`],setup(t,{emit:u}){let y=t,b=u;function x(e){y.selected.includes(e)?b(`update:selected`,y.selected.filter(t=>t!==e)):b(`update:selected`,[...y.selected,e])}let S=c(()=>{let e=y.selected.length;return e===0?`Add to frame`:`Add to ${e} frame${e>1?`s`:``}`});return(c,u)=>(r(),o(m,{"model-value":t.modelValue,label:`Choose frames`,"onUpdate:modelValue":u[1]||=e=>c.$emit(`update:modelValue`,e)},{default:e(()=>[u[2]||=s(`h2`,{class:`device-picker__title`},`Add to frames`,-1),u[3]||=s(`p`,{class:`device-picker__sub`},`Choose which frames will show this photo.`,-1),s(`div`,h,[(r(!0),f(l,null,n(t.devices,e=>(r(),f(`label`,{key:e.id,class:`device-picker__row`},[s(`input`,{type:`checkbox`,class:`device-picker__check`,checked:t.selected.includes(e.id),onChange:t=>x(e.id)},null,40,g),s(`span`,_,a(e.name),1),s(`span`,v,a(e.orientation),1)]))),128))]),i(p,{variant:`primary`,class:`device-picker__confirm`,disabled:t.selected.length===0||t.uploading,onClick:u[0]||=e=>c.$emit(`confirm`)},{default:e(()=>[d(a(t.uploading?`Uploading…`:S.value),1)]),_:1},8,[`disabled`])]),_:1},8,[`model-value`]))}}),[[`__scopeId`,`data-v-a6466fa5`]]);export{y as t};
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{J as e,M as t,P as n,S as r,_ as i,b as a,g as o,h as s,p as c,t as l,x as u,y as d,z as f}from"./_plugin-vue_export-helper-eepT72yB.js";import{n as p,t as m}from"./BaseBottomSheet-BMI-Oljh.js";var h={class:`device-picker__list`},g=[`checked`,`onChange`],_={class:`device-picker__name`},v={class:`device-picker__orientation`},y=l(r({__name:`DevicePicker`,props:{modelValue:{type:Boolean},devices:{},selected:{},uploading:{type:Boolean}},emits:[`update:modelValue`,`update:selected`,`confirm`],setup(r,{emit:l}){let y=r,b=l;function x(e){y.selected.includes(e)?b(`update:selected`,y.selected.filter(t=>t!==e)):b(`update:selected`,[...y.selected,e])}let S=s(()=>{let e=y.selected.length;return e===0?`Add to frame`:`Add to ${e} frame${e>1?`s`:``}`});return(s,l)=>(t(),i(m,{"model-value":r.modelValue,label:`Choose frames`,"onUpdate:modelValue":l[1]||=e=>s.$emit(`update:modelValue`,e)},{default:f(()=>[l[2]||=o(`h2`,{class:`device-picker__title`},`Add to frames`,-1),l[3]||=o(`p`,{class:`device-picker__sub`},`Choose which frames will show this photo.`,-1),o(`div`,h,[(t(!0),d(c,null,n(r.devices,n=>(t(),d(`label`,{key:n.id,class:`device-picker__row`},[o(`input`,{type:`checkbox`,class:`device-picker__check`,checked:r.selected.includes(n.id),onChange:e=>x(n.id)},null,40,g),o(`span`,_,e(n.name),1),o(`span`,v,e(n.orientation),1)]))),128))]),u(p,{variant:`primary`,class:`device-picker__confirm`,disabled:r.selected.length===0||r.uploading,onClick:l[0]||=e=>s.$emit(`confirm`)},{default:f(()=>[a(e(r.uploading?`Uploading…`:S.value),1)]),_:1},8,[`disabled`])]),_:1},8,[`model-value`]))}}),[[`__scopeId`,`data-v-a6466fa5`]]);export{y as t};
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
@@ -1 +1 @@
|
|||||||
import{F as e,H as t,K as n,M as r,S as i,g as a,h as o,q as s,t as c,y as l}from"./_plugin-vue_export-helper-eepT72yB.js";var u={key:1,class:`ptr__spinner`,role:`status`,"aria-label":`Refreshing`},d=c(i({__name:`PullToRefresh`,props:{isAtTop:{},onRefresh:{},threshold:{default:80},maxPull:{default:140}},setup(i){let c=i,d=t(0),f=t(!1),p=0,m=0,h=!1,g=null,_=o(()=>Math.min(d.value/c.threshold,1)),v=o(()=>f.value?1:Math.min(d.value/c.threshold,1)),y=o(()=>({transform:`translateY(${d.value}px)`,transition:h?`none`:`transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1)`}));function b(e){f.value||c.isAtTop&&!c.isAtTop()||e.touches.length===1&&(p=e.touches[0].clientY,m=e.touches[0].clientX,h=!0,g=null)}function x(e){if(!h)return;let t=e.touches[0].clientX-m,n=e.touches[0].clientY-p;if(g===null){if(Math.abs(t)<6&&Math.abs(n)<6)return;g=Math.abs(t)>Math.abs(n)?`x`:`y`}if(g===`x`||n<=0){h=!1,d.value=0;return}if(c.isAtTop&&!c.isAtTop()){h=!1,d.value=0;return}let r=n<c.maxPull?n*.5:c.maxPull*.5+(n-c.maxPull)*.1;d.value=Math.min(r,c.maxPull*.7),e.cancelable&&e.preventDefault()}async function S(){if(h)if(h=!1,d.value>=c.threshold){f.value=!0,d.value=c.threshold*.7;try{await c.onRefresh()}catch(e){console.error(`Pull-to-refresh failed:`,e)}finally{f.value=!1,d.value=0}}else d.value=0}return(t,i)=>(r(),l(`div`,{class:`ptr`,onTouchstartPassive:b,onTouchmove:x,onTouchend:S,onTouchcancel:S},[a(`div`,{class:n([`ptr__indicator`,{"ptr__indicator--ready":_.value>=1}]),style:s({opacity:v.value,transform:`translateY(${d.value*.6}px)`}),"aria-hidden":`true`},[f.value?(r(),l(`div`,u)):(r(),l(`svg`,{key:0,class:`ptr__arrow`,viewBox:`0 0 24 24`,style:s({transform:`rotate(${_.value*180}deg)`})},[...i[0]||=[a(`line`,{x1:`12`,y1:`5`,x2:`12`,y2:`19`,stroke:`currentColor`,"stroke-width":`2.5`,"stroke-linecap":`round`},null,-1),a(`polyline`,{points:`6 13 12 19 18 13`,stroke:`currentColor`,"stroke-width":`2.5`,fill:`none`,"stroke-linecap":`round`,"stroke-linejoin":`round`},null,-1)]],4))],6),a(`div`,{class:`ptr__content`,style:s(y.value)},[e(t.$slots,`default`,{},void 0,!0)],4)],32))}}),[[`__scopeId`,`data-v-e2242f3c`]]);export{d as t};
|
import{C as e,I as t,J as n,N as r,U as i,g as a,h as o,q as s,t as c,y as l}from"./_plugin-vue_export-helper-BNDVmFr7.js";var u={key:1,class:`ptr__spinner`,role:`status`,"aria-label":`Refreshing`},d=c(e({__name:`PullToRefresh`,props:{isAtTop:{},onRefresh:{},threshold:{default:80},maxPull:{default:140}},setup(e){let c=e,d=i(0),f=i(!1),p=0,m=0,h=!1,g=null,_=o(()=>Math.min(d.value/c.threshold,1)),v=o(()=>f.value?1:Math.min(d.value/c.threshold,1)),y=o(()=>({transform:`translateY(${d.value}px)`,transition:h?`none`:`transform 240ms cubic-bezier(0.2, 0.8, 0.2, 1)`}));function b(e){f.value||c.isAtTop&&!c.isAtTop()||e.touches.length===1&&(p=e.touches[0].clientY,m=e.touches[0].clientX,h=!0,g=null)}function x(e){if(!h)return;let t=e.touches[0].clientX-m,n=e.touches[0].clientY-p;if(g===null){if(Math.abs(t)<6&&Math.abs(n)<6)return;g=Math.abs(t)>Math.abs(n)?`x`:`y`}if(g===`x`||n<=0){h=!1,d.value=0;return}if(c.isAtTop&&!c.isAtTop()){h=!1,d.value=0;return}let r=n<c.maxPull?n*.5:c.maxPull*.5+(n-c.maxPull)*.1;d.value=Math.min(r,c.maxPull*.7),e.cancelable&&e.preventDefault()}async function S(){if(h)if(h=!1,d.value>=c.threshold){f.value=!0,d.value=c.threshold*.7;try{await c.onRefresh()}catch(e){console.error(`Pull-to-refresh failed:`,e)}finally{f.value=!1,d.value=0}}else d.value=0}return(e,i)=>(r(),l(`div`,{class:`ptr`,onTouchstartPassive:b,onTouchmove:x,onTouchend:S,onTouchcancel:S},[a(`div`,{class:s([`ptr__indicator`,{"ptr__indicator--ready":_.value>=1}]),style:n({opacity:v.value,transform:`translateY(${d.value*.6}px)`}),"aria-hidden":`true`},[f.value?(r(),l(`div`,u)):(r(),l(`svg`,{key:0,class:`ptr__arrow`,viewBox:`0 0 24 24`,style:n({transform:`rotate(${_.value*180}deg)`})},[...i[0]||=[a(`line`,{x1:`12`,y1:`5`,x2:`12`,y2:`19`,stroke:`currentColor`,"stroke-width":`2.5`,"stroke-linecap":`round`},null,-1),a(`polyline`,{points:`6 13 12 19 18 13`,stroke:`currentColor`,"stroke-width":`2.5`,fill:`none`,"stroke-linecap":`round`,"stroke-linejoin":`round`},null,-1)]],4))],6),a(`div`,{class:`ptr__content`,style:n(y.value)},[t(e.$slots,`default`,{},void 0,!0)],4)],32))}}),[[`__scopeId`,`data-v-e2242f3c`]]);export{d as t};
|
||||||
@@ -1 +0,0 @@
|
|||||||
import{G as e,H as t,J as n,K as r,M as i,P as a,S as o,b as s,f as c,g as l,h as u,p as d,q as f,t as p,v as m,y as h}from"./_plugin-vue_export-helper-eepT72yB.js";import{n as g,r as _,t as v}from"./index-BO5caB_f.js";var y=t(null),b=t(!1);function x(){return typeof window>`u`?!1:window.matchMedia?.(`(display-mode: standalone)`).matches?!0:window.navigator.standalone===!0}function S(){if(typeof navigator>`u`)return!1;let e=navigator.userAgent,t=e.includes(`Mac`)&&navigator.maxTouchPoints>1;return/iPhone|iPod/.test(e)||t}var C=!1;function w(){C||typeof window>`u`||(C=!0,b.value=x(),window.addEventListener(`beforeinstallprompt`,e=>{e.preventDefault(),y.value=e}),window.addEventListener(`appinstalled`,()=>{y.value=null,b.value=!0}),window.matchMedia?.(`(display-mode: standalone)`).addEventListener(`change`,e=>{b.value=e.matches}))}w();function T(){let e=S(),t=u(()=>y.value!==null);async function n(){let e=y.value;if(!e)return!1;await e.prompt();let t=await e.userChoice;return y.value=null,t.outcome===`accepted`}return{isStandalone:b,isIOS:e,canPromptInstall:t,install:n}}var E={class:`settings`},D={key:0,class:`settings__section`},O={class:`settings__section`},k={class:`theme-grid`,role:`radiogroup`,"aria-label":`Choose theme`},A=[`aria-checked`,`aria-label`,`onClick`],j={class:`theme-swatch__label`},M={key:0,class:`theme-swatch__check`,"aria-hidden":`true`},N={class:`settings__section`},P={class:`settings__row`},F={class:`settings__row-value`},I={class:`install-modal__card`},L={id:`install-modal-title`,class:`install-modal__title`},R={class:`install-modal__steps`},z={key:0},B={key:1},V={key:0},H=p(o({__name:`SettingsView`,setup(o){let p=_(),{saveTheme:y}=g(),{isStandalone:b,isIOS:x,canPromptInstall:S,install:C}=T(),w=u(()=>p.user?.theme??`warm-craft`),H=t(!1);function U(e){y(e)}async function W(){!await C()&&!S.value&&(H.value=!0)}return(t,o)=>(i(),h(`main`,E,[o[18]||=l(`h1`,{class:`settings__title`},`Settings`,-1),e(b)?m(``,!0):(i(),h(`section`,D,[o[3]||=l(`h2`,{class:`settings__section-title`},`Install app`,-1),o[4]||=l(`p`,{class:`settings__hint`},` Pin pictureFrame to your home screen so it opens like a native app. `,-1),e(S)?(i(),h(`button`,{key:0,type:`button`,class:`settings__install`,onClick:W},` Install pictureFrame `)):(i(),h(`button`,{key:1,type:`button`,class:`settings__install`,onClick:o[0]||=e=>H.value=!0},` Add to Home Screen `))])),l(`section`,O,[o[6]||=l(`h2`,{class:`settings__section-title`},`Theme`,-1),l(`div`,k,[(i(!0),h(d,null,a(e(v),e=>(i(),h(`button`,{key:e.id,type:`button`,role:`radio`,"aria-checked":w.value===e.id,"aria-label":e.label,class:r([`theme-swatch`,{"theme-swatch--active":w.value===e.id}]),style:f({"--swatch-bg":e.bg,"--swatch-primary":e.primary,"--swatch-text":e.text}),onClick:t=>U(e.id)},[o[5]||=l(`span`,{class:`theme-swatch__preview`,"aria-hidden":`true`},[l(`span`,{class:`theme-swatch__bar`}),l(`span`,{class:`theme-swatch__dot`})],-1),l(`span`,j,n(e.label),1),w.value===e.id?(i(),h(`span`,M,`✓`)):m(``,!0)],14,A))),128))])]),l(`section`,N,[o[8]||=l(`h2`,{class:`settings__section-title`},`Account`,-1),l(`div`,P,[o[7]||=l(`span`,{class:`settings__row-label`},`Signed in as`,-1),l(`span`,F,n(e(p).user?.email),1)]),o[9]||=l(`a`,{href:`/logout`,class:`settings__logout`},`Sign out`,-1)]),H.value?(i(),h(`div`,{key:1,class:`install-modal`,role:`dialog`,"aria-modal":`true`,"aria-labelledby":`install-modal-title`,onClick:o[2]||=c(e=>H.value=!1,[`self`])},[l(`div`,I,[l(`button`,{type:`button`,class:`install-modal__close`,"aria-label":`Close`,onClick:o[1]||=e=>H.value=!1},`×`),l(`h2`,L,n(e(x)?`Add to your iPhone home screen`:`Add to your home screen`),1),l(`ol`,R,[e(x)?(i(),h(`li`,z,[...o[10]||=[s(` Tap the `,-1),l(`strong`,null,`Share`,-1),s(` icon at the bottom of Safari (the square with an up-arrow). `,-1)]])):(i(),h(`li`,B,[...o[11]||=[s(` Open your browser's menu (usually the three dots `,-1),l(`strong`,null,`⋮`,-1),s(` in the top right). `,-1)]])),l(`li`,null,[o[13]||=s(` Scroll down and tap `,-1),o[14]||=l(`strong`,null,`Add to Home Screen`,-1),e(x)?m(``,!0):(i(),h(`span`,V,[...o[12]||=[s(`or `,-1),l(`strong`,null,`Install app`,-1)]])),o[15]||=s(`. `,-1)]),o[16]||=l(`li`,null,[s(` Tap `),l(`strong`,null,`Add`),s(` in the top right to confirm. `)],-1)]),o[17]||=l(`p`,{class:`install-modal__footer`},` The app will appear on your home screen and open without browser chrome the next time you launch it. `,-1)])])):m(``,!0)]))}}),[[`__scopeId`,`data-v-6f8a8b72`]]);export{H as default};
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import{C as e,F as t,J as n,K as r,N as i,U as a,Y as o,f as s,g as c,h as l,p as u,q as d,t as f,v as p,x as m,y as h}from"./_plugin-vue_export-helper-BNDVmFr7.js";import{n as g,r as _,t as v}from"./index-DdJ5jHP4.js";var y=a(null),b=a(!1);function x(){return typeof window>`u`?!1:window.matchMedia?.(`(display-mode: standalone)`).matches?!0:window.navigator.standalone===!0}function S(){if(typeof navigator>`u`)return!1;let e=navigator.userAgent,t=e.includes(`Mac`)&&navigator.maxTouchPoints>1;return/iPhone|iPod/.test(e)||t}var C=!1;function w(){C||typeof window>`u`||(C=!0,b.value=x(),window.addEventListener(`beforeinstallprompt`,e=>{e.preventDefault(),y.value=e}),window.addEventListener(`appinstalled`,()=>{y.value=null,b.value=!0}),window.matchMedia?.(`(display-mode: standalone)`).addEventListener(`change`,e=>{b.value=e.matches}))}w();function T(){let e=S(),t=l(()=>y.value!==null);async function n(){let e=y.value;if(!e)return!1;await e.prompt();let t=await e.userChoice;return y.value=null,t.outcome===`accepted`}return{isStandalone:b,isIOS:e,canPromptInstall:t,install:n}}var E={class:`settings`},D={key:0,class:`settings__section`},O={class:`settings__section`},k={class:`theme-grid`,role:`radiogroup`,"aria-label":`Choose theme`},A=[`aria-checked`,`aria-label`,`onClick`],j={class:`theme-swatch__label`},M={key:0,class:`theme-swatch__check`,"aria-hidden":`true`},N={class:`settings__section`},P={class:`settings__row`},F={class:`settings__row-value`},I={class:`install-modal__card`},L={id:`install-modal-title`,class:`install-modal__title`},R={class:`install-modal__steps`},z={key:0},B={key:1},V={key:0},H=f(e({__name:`SettingsView`,setup(e){let f=_(),{saveTheme:y}=g(),{isStandalone:b,isIOS:x,canPromptInstall:S,install:C}=T(),w=l(()=>f.user?.theme??`warm-craft`),H=a(!1);function U(e){y(e)}async function W(){!await C()&&!S.value&&(H.value=!0)}return(e,a)=>(i(),h(`main`,E,[a[18]||=c(`h1`,{class:`settings__title`},`Settings`,-1),r(b)?p(``,!0):(i(),h(`section`,D,[a[3]||=c(`h2`,{class:`settings__section-title`},`Install app`,-1),a[4]||=c(`p`,{class:`settings__hint`},` Pin pictureFrame to your home screen so it opens like a native app. `,-1),r(S)?(i(),h(`button`,{key:0,type:`button`,class:`settings__install`,onClick:W},` Install pictureFrame `)):(i(),h(`button`,{key:1,type:`button`,class:`settings__install`,onClick:a[0]||=e=>H.value=!0},` Add to Home Screen `))])),c(`section`,O,[a[6]||=c(`h2`,{class:`settings__section-title`},`Theme`,-1),c(`div`,k,[(i(!0),h(u,null,t(r(v),e=>(i(),h(`button`,{key:e.id,type:`button`,role:`radio`,"aria-checked":w.value===e.id,"aria-label":e.label,class:d([`theme-swatch`,{"theme-swatch--active":w.value===e.id}]),style:n({"--swatch-bg":e.bg,"--swatch-primary":e.primary,"--swatch-text":e.text}),onClick:t=>U(e.id)},[a[5]||=c(`span`,{class:`theme-swatch__preview`,"aria-hidden":`true`},[c(`span`,{class:`theme-swatch__bar`}),c(`span`,{class:`theme-swatch__dot`})],-1),c(`span`,j,o(e.label),1),w.value===e.id?(i(),h(`span`,M,`✓`)):p(``,!0)],14,A))),128))])]),c(`section`,N,[a[8]||=c(`h2`,{class:`settings__section-title`},`Account`,-1),c(`div`,P,[a[7]||=c(`span`,{class:`settings__row-label`},`Signed in as`,-1),c(`span`,F,o(r(f).user?.email),1)]),a[9]||=c(`a`,{href:`/logout`,class:`settings__logout`},`Sign out`,-1)]),H.value?(i(),h(`div`,{key:1,class:`install-modal`,role:`dialog`,"aria-modal":`true`,"aria-labelledby":`install-modal-title`,onClick:a[2]||=s(e=>H.value=!1,[`self`])},[c(`div`,I,[c(`button`,{type:`button`,class:`install-modal__close`,"aria-label":`Close`,onClick:a[1]||=e=>H.value=!1},`×`),c(`h2`,L,o(r(x)?`Add to your iPhone home screen`:`Add to your home screen`),1),c(`ol`,R,[r(x)?(i(),h(`li`,z,[...a[10]||=[m(` Tap the `,-1),c(`strong`,null,`Share`,-1),m(` icon at the bottom of Safari (the square with an up-arrow). `,-1)]])):(i(),h(`li`,B,[...a[11]||=[m(` Open your browser's menu (usually the three dots `,-1),c(`strong`,null,`⋮`,-1),m(` in the top right). `,-1)]])),c(`li`,null,[a[13]||=m(` Scroll down and tap `,-1),a[14]||=c(`strong`,null,`Add to Home Screen`,-1),r(x)?p(``,!0):(i(),h(`span`,V,[...a[12]||=[m(`or `,-1),c(`strong`,null,`Install app`,-1)]])),a[15]||=m(`. `,-1)]),a[16]||=c(`li`,null,[m(` Tap `),c(`strong`,null,`Add`),m(` in the top right to confirm. `)],-1)]),a[17]||=c(`p`,{class:`install-modal__footer`},` The app will appear on your home screen. Open it from there and it runs like a regular app — no address bar, no tabs. `,-1)])])):p(``,!0)]))}}),[[`__scopeId`,`data-v-fb5d8496`]]);export{H as default};
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
.settings[data-v-6f8a8b72]{padding:var(--space-4) var(--space-4) calc(var(--bottom-nav-height) + var(--space-6));max-width:480px;margin:0 auto}.settings__title[data-v-6f8a8b72]{font-size:var(--text-xl);margin-bottom:var(--space-6);font-weight:700}.settings__section[data-v-6f8a8b72]{margin-bottom:var(--space-6)}.settings__section-title[data-v-6f8a8b72]{font-size:var(--text-sm);color:var(--color-text-muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:var(--space-3);font-weight:700}.settings__row[data-v-6f8a8b72]{padding:var(--space-3) 0;border-bottom:1px solid var(--color-border);font-size:var(--text-base);justify-content:space-between;align-items:center;display:flex}.settings__row-label[data-v-6f8a8b72]{color:var(--color-text-muted)}.settings__row-value[data-v-6f8a8b72]{font-weight:600}.settings__logout[data-v-6f8a8b72]{min-height:var(--touch-min);padding:var(--space-3) 0;color:var(--color-destructive);font-weight:600;font-size:var(--text-base);align-items:center;text-decoration:none;display:flex}.settings__hint[data-v-6f8a8b72]{color:var(--color-text-muted);font-size:var(--text-sm);margin-bottom:var(--space-3);line-height:1.4}.settings__install[data-v-6f8a8b72]{width:100%;min-height:var(--touch-min);background:var(--color-primary);color:var(--color-on-primary);border-radius:var(--radius-pill,9999px);font-size:var(--text-base);cursor:pointer;border:none;justify-content:center;align-items:center;font-weight:700;display:flex}.install-modal[data-v-6f8a8b72]{padding:var(--space-4);z-index:100;background:#00000080;justify-content:center;align-items:center;display:flex;position:fixed;inset:0}.install-modal__card[data-v-6f8a8b72]{background:var(--color-surface);color:var(--color-text);border-radius:var(--radius-lg,16px);padding:var(--space-5);width:100%;max-width:380px;position:relative;box-shadow:0 20px 60px #00000040}.install-modal__close[data-v-6f8a8b72]{top:var(--space-2);right:var(--space-3);color:var(--color-text-muted);cursor:pointer;padding:var(--space-1) var(--space-2);background:0 0;border:none;font-size:1.75rem;line-height:1;position:absolute}.install-modal__title[data-v-6f8a8b72]{font-size:var(--text-lg);margin-bottom:var(--space-3);padding-right:var(--space-5);font-weight:700}.install-modal__steps[data-v-6f8a8b72]{margin:0 0 var(--space-3) var(--space-4);padding:0;line-height:1.5}.install-modal__steps li[data-v-6f8a8b72]{margin-bottom:var(--space-2)}.install-modal__footer[data-v-6f8a8b72]{color:var(--color-text-muted);font-size:var(--text-sm);margin-top:var(--space-3);border-top:1px solid var(--color-border);padding-top:var(--space-3);line-height:1.4}.theme-grid[data-v-6f8a8b72]{gap:var(--space-3);grid-template-columns:repeat(3,1fr);display:grid}.theme-swatch[data-v-6f8a8b72]{align-items:center;gap:var(--space-2);padding:var(--space-3);background:var(--swatch-bg);border-radius:var(--radius-md);cursor:pointer;transition:border-color var(--duration-fast);min-height:var(--touch-min);border:2px solid #0000;flex-direction:column;display:flex;position:relative}.theme-swatch--active[data-v-6f8a8b72]{border-color:var(--swatch-primary)}.theme-swatch__preview[data-v-6f8a8b72]{border-radius:var(--radius-sm);background:var(--swatch-bg);border:1px solid color-mix(in srgb, var(--swatch-text) 15%, transparent);flex-direction:column;justify-content:center;gap:4px;width:100%;height:36px;padding:0 6px;display:flex}.theme-swatch__bar[data-v-6f8a8b72]{background:var(--swatch-primary);border-radius:3px;width:60%;height:6px;display:block}.theme-swatch__dot[data-v-6f8a8b72]{background:var(--swatch-text);opacity:.4;border-radius:2px;width:80%;height:4px;display:block}.theme-swatch__label[data-v-6f8a8b72]{font-size:var(--text-xs);color:var(--color-text);text-align:center;font-weight:600;line-height:1.2}.theme-swatch__check[data-v-6f8a8b72]{top:var(--space-1);right:var(--space-2);font-size:var(--text-sm);color:var(--swatch-primary);font-weight:700;position:absolute}
|
.settings[data-v-fb5d8496]{padding:var(--space-4) var(--space-4) calc(var(--bottom-nav-height) + var(--space-6));max-width:480px;margin:0 auto}.settings__title[data-v-fb5d8496]{font-size:var(--text-xl);margin-bottom:var(--space-6);font-weight:700}.settings__section[data-v-fb5d8496]{margin-bottom:var(--space-6)}.settings__section-title[data-v-fb5d8496]{font-size:var(--text-sm);color:var(--color-text-muted);text-transform:uppercase;letter-spacing:.06em;margin-bottom:var(--space-3);font-weight:700}.settings__row[data-v-fb5d8496]{padding:var(--space-3) 0;border-bottom:1px solid var(--color-border);font-size:var(--text-base);justify-content:space-between;align-items:center;display:flex}.settings__row-label[data-v-fb5d8496]{color:var(--color-text-muted)}.settings__row-value[data-v-fb5d8496]{font-weight:600}.settings__logout[data-v-fb5d8496]{min-height:var(--touch-min);padding:var(--space-3) 0;color:var(--color-destructive);font-weight:600;font-size:var(--text-base);align-items:center;text-decoration:none;display:flex}.settings__hint[data-v-fb5d8496]{color:var(--color-text-muted);font-size:var(--text-sm);margin-bottom:var(--space-3);line-height:1.4}.settings__install[data-v-fb5d8496]{width:100%;min-height:var(--touch-min);background:var(--color-primary);color:var(--color-on-primary);border-radius:var(--radius-pill,9999px);font-size:var(--text-base);cursor:pointer;border:none;justify-content:center;align-items:center;font-weight:700;display:flex}.install-modal[data-v-fb5d8496]{padding:var(--space-4);z-index:100;background:#00000080;justify-content:center;align-items:center;display:flex;position:fixed;inset:0}.install-modal__card[data-v-fb5d8496]{background:var(--color-surface);color:var(--color-text);border-radius:var(--radius-lg,16px);padding:var(--space-5);width:100%;max-width:380px;position:relative;box-shadow:0 20px 60px #00000040}.install-modal__close[data-v-fb5d8496]{top:var(--space-2);right:var(--space-3);color:var(--color-text-muted);cursor:pointer;padding:var(--space-1) var(--space-2);background:0 0;border:none;font-size:1.75rem;line-height:1;position:absolute}.install-modal__title[data-v-fb5d8496]{font-size:var(--text-lg);margin-bottom:var(--space-3);padding-right:var(--space-5);font-weight:700}.install-modal__steps[data-v-fb5d8496]{margin:0 0 var(--space-3) var(--space-4);padding:0;line-height:1.5}.install-modal__steps li[data-v-fb5d8496]{margin-bottom:var(--space-2)}.install-modal__footer[data-v-fb5d8496]{color:var(--color-text-muted);font-size:var(--text-sm);margin-top:var(--space-3);border-top:1px solid var(--color-border);padding-top:var(--space-3);line-height:1.4}.theme-grid[data-v-fb5d8496]{gap:var(--space-3);grid-template-columns:repeat(3,1fr);display:grid}.theme-swatch[data-v-fb5d8496]{align-items:center;gap:var(--space-2);padding:var(--space-3);background:var(--swatch-bg);border-radius:var(--radius-md);cursor:pointer;transition:border-color var(--duration-fast);min-height:var(--touch-min);border:2px solid #0000;flex-direction:column;display:flex;position:relative}.theme-swatch--active[data-v-fb5d8496]{border-color:var(--swatch-primary)}.theme-swatch__preview[data-v-fb5d8496]{border-radius:var(--radius-sm);background:var(--swatch-bg);border:1px solid color-mix(in srgb, var(--swatch-text) 15%, transparent);flex-direction:column;justify-content:center;gap:4px;width:100%;height:36px;padding:0 6px;display:flex}.theme-swatch__bar[data-v-fb5d8496]{background:var(--swatch-primary);border-radius:3px;width:60%;height:6px;display:block}.theme-swatch__dot[data-v-fb5d8496]{background:var(--swatch-text);opacity:.4;border-radius:2px;width:80%;height:4px;display:block}.theme-swatch__label[data-v-fb5d8496]{font-size:var(--text-xs);color:var(--color-text);text-align:center;font-weight:600;line-height:1.2}.theme-swatch__check[data-v-fb5d8496]{top:var(--space-1);right:var(--space-2);font-size:var(--text-sm);color:var(--swatch-primary);font-weight:700;position:absolute}
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -14,8 +14,8 @@
|
|||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||||
<meta name="apple-mobile-web-app-title" content="pictureFrame" />
|
<meta name="apple-mobile-web-app-title" content="pictureFrame" />
|
||||||
<script type="module" crossorigin src="/build/assets/index-BO5caB_f.js"></script>
|
<script type="module" crossorigin src="/build/assets/index-DdJ5jHP4.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/build/assets/_plugin-vue_export-helper-eepT72yB.js">
|
<link rel="modulepreload" crossorigin href="/build/assets/_plugin-vue_export-helper-BNDVmFr7.js">
|
||||||
<link rel="stylesheet" crossorigin href="/build/assets/index-BlLBHR1q.css">
|
<link rel="stylesheet" crossorigin href="/build/assets/index-BlLBHR1q.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -38,8 +38,8 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h1>Set up your frame</h1>
|
<h1>Link this frame</h1>
|
||||||
<p class="subtitle">Create an account or sign in to link this frame.</p>
|
<p class="subtitle">Create an account, or sign in if you already have one. The frame will link to whichever account you use here.</p>
|
||||||
|
|
||||||
{% if already_claimed %}
|
{% if already_claimed %}
|
||||||
<p class="claim-banner" role="status">
|
<p class="claim-banner" role="status">
|
||||||
|
|||||||
Reference in New Issue
Block a user