first commit
No files matched your search
@@ -0,0 +1,116 @@
|
||||
<script setup>
|
||||
import { RouterLink, RouterView } from 'vue-router'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-shell">
|
||||
<header class="app-header">
|
||||
<RouterLink to="/" class="brand">Luna Photobooth</RouterLink>
|
||||
<nav class="app-nav">
|
||||
<RouterLink to="/" class="nav-link">Home</RouterLink>
|
||||
<RouterLink to="/capture" class="nav-link">Capture</RouterLink>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="app-main">
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;600;700&display=swap');
|
||||
|
||||
:root {
|
||||
color-scheme: light;
|
||||
font-family: 'Playfair Display', serif;
|
||||
--blush-900: #8e2c4a;
|
||||
--blush-700: #c85a7c;
|
||||
--blush-500: #f1a3ba;
|
||||
--blush-300: #f6c7d7;
|
||||
--blush-150: #fbe6ef;
|
||||
--cream: #fff7fb;
|
||||
--ink: #33222b;
|
||||
--muted: #6f4b58;
|
||||
--surface: #ffffff;
|
||||
--soft-shadow: 0 20px 60px rgba(219, 146, 170, 0.25);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
background: radial-gradient(circle at top, #ffffff 0%, #ffeaf2 40%, #f9cfdc 100%);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 24px 24px 56px;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.app-nav {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
padding: 8px 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(200, 90, 124, 0.25);
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-link.router-link-exact-active,
|
||||
.nav-link:hover {
|
||||
background: #fff;
|
||||
border-color: rgba(200, 90, 124, 0.5);
|
||||
box-shadow: 0 12px 30px rgba(200, 90, 124, 0.2);
|
||||
}
|
||||
|
||||
.app-main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-shell {
|
||||
padding: 20px 16px 48px;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
After Width: | Height: | Size: 349 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.6 MiB |
|
After Width: | Height: | Size: 473 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 334 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 412 KiB |
|
After Width: | Height: | Size: 644 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 163 KiB |
|
After Width: | Height: | Size: 360 KiB |
@@ -0,0 +1,136 @@
|
||||
<script setup>
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useUserMedia } from '@vueuse/core'
|
||||
|
||||
const props = defineProps({
|
||||
deviceId: { type: String, default: '' },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['error'])
|
||||
|
||||
const videoRef = ref(null)
|
||||
|
||||
const errorMessage = ref('')
|
||||
|
||||
const { stream, start, restart, stop, constraints } = useUserMedia({
|
||||
enabled: false,
|
||||
autoSwitch: true,
|
||||
constraints: { video: { facingMode: 'user' }, audio: false },
|
||||
})
|
||||
|
||||
async function ensureStream() {
|
||||
try {
|
||||
if (stream.value) {
|
||||
await restart()
|
||||
} else {
|
||||
await start()
|
||||
}
|
||||
errorMessage.value = ''
|
||||
} catch (err) {
|
||||
errorMessage.value = err?.message || 'Camera access failed.'
|
||||
emit('error', err)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.deviceId,
|
||||
async (value) => {
|
||||
constraints.value = {
|
||||
video: value ? { deviceId: { exact: value } } : { facingMode: 'user' },
|
||||
audio: false,
|
||||
}
|
||||
await ensureStream()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(stream, (value) => {
|
||||
if (videoRef.value && value) {
|
||||
videoRef.value.srcObject = value
|
||||
videoRef.value.play().catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stop()
|
||||
})
|
||||
|
||||
function takeSnapshot() {
|
||||
const video = videoRef.value
|
||||
if (!video) return null
|
||||
|
||||
const videoWidth = video.videoWidth || 1360
|
||||
const videoHeight = video.videoHeight || 768
|
||||
|
||||
// Crop to 3:2 aspect ratio (matches template photo slots: 440×290 pixels = 1.517:1)
|
||||
const targetRatio = 3 / 2
|
||||
const videoRatio = videoWidth / videoHeight
|
||||
|
||||
let cropWidth = videoWidth
|
||||
let cropHeight = videoHeight
|
||||
let cropX = 0
|
||||
let cropY = 0
|
||||
|
||||
if (videoRatio > targetRatio) {
|
||||
// Video is wider than target, crop width
|
||||
cropWidth = videoHeight * targetRatio
|
||||
cropX = (videoWidth - cropWidth) / 2
|
||||
} else {
|
||||
// Video is taller than target, crop height
|
||||
cropHeight = videoWidth / targetRatio
|
||||
cropY = (videoHeight - cropHeight) / 2
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = cropWidth
|
||||
canvas.height = cropHeight
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return null
|
||||
|
||||
ctx.drawImage(video, cropX, cropY, cropWidth, cropHeight, 0, 0, cropWidth, cropHeight)
|
||||
return canvas.toDataURL('image/png')
|
||||
}
|
||||
|
||||
defineExpose({ takeSnapshot })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="camera-shell">
|
||||
<video ref="videoRef" class="camera-video" autoplay playsinline muted></video>
|
||||
<div v-if="errorMessage" class="camera-fallback">
|
||||
<p>Camera access is blocked. Please allow permissions and refresh.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.camera-shell {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: 32px;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
box-shadow: 0 18px 50px rgba(203, 115, 140, 0.2);
|
||||
}
|
||||
|
||||
.camera-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.camera-fallback {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: #7a3b4f;
|
||||
background: rgba(255, 235, 243, 0.92);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup>
|
||||
import { computed, watch } from 'vue'
|
||||
import { useInterval } from '@vueuse/core'
|
||||
|
||||
const props = defineProps({
|
||||
active: { type: Boolean, default: false },
|
||||
seconds: { type: Number, default: 3 },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['finished'])
|
||||
|
||||
const { counter, pause, resume, reset } = useInterval(1000, {
|
||||
controls: true,
|
||||
immediate: false,
|
||||
})
|
||||
|
||||
const remaining = computed(() => Math.max(props.seconds - counter.value, 0))
|
||||
|
||||
watch(
|
||||
() => props.active,
|
||||
(value) => {
|
||||
if (value) {
|
||||
reset()
|
||||
resume()
|
||||
return
|
||||
}
|
||||
|
||||
pause()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(remaining, (value) => {
|
||||
if (!props.active) return
|
||||
|
||||
if (value === 0) {
|
||||
pause()
|
||||
emit('finished')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="active" class="countdown">
|
||||
<span
|
||||
v-motion
|
||||
:initial="{ scale: 0.6, opacity: 0 }"
|
||||
:enter="{ scale: 1, opacity: 1, transition: { type: 'spring', stiffness: 300, damping: 16 } }"
|
||||
:key="remaining"
|
||||
class="countdown-number"
|
||||
>
|
||||
{{ remaining }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.countdown {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 32px;
|
||||
background: rgba(255, 230, 239, 0.65);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.countdown-number {
|
||||
font-size: clamp(2.5rem, 6vw, 4.5rem);
|
||||
font-weight: 700;
|
||||
color: #8e2c4a;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,117 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import html2canvas from 'html2canvas'
|
||||
import { STRIP_BASE_SIZE } from '../data/stripThemes'
|
||||
|
||||
const props = defineProps({
|
||||
targetRef: { type: Object, default: null },
|
||||
filename: { type: String, default: 'photobooth-strip.png' },
|
||||
width: { type: Number, default: STRIP_BASE_SIZE.width },
|
||||
height: { type: Number, default: STRIP_BASE_SIZE.height },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['render-start', 'render-end', 'rendered'])
|
||||
|
||||
const isWorking = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const isReady = computed(() => Boolean(props.targetRef?.value))
|
||||
|
||||
function canvasToBlob(canvas) {
|
||||
return new Promise((resolve) => {
|
||||
canvas.toBlob((blob) => resolve(blob), 'image/png')
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDownload() {
|
||||
if (isWorking.value) return
|
||||
if (!props.targetRef?.value) {
|
||||
errorMessage.value = 'Strip not ready yet. Please try again in a moment.'
|
||||
return
|
||||
}
|
||||
|
||||
isWorking.value = true
|
||||
errorMessage.value = ''
|
||||
emit('render-start')
|
||||
|
||||
await nextTick()
|
||||
|
||||
try {
|
||||
const canvas = await html2canvas(props.targetRef.value, {
|
||||
backgroundColor: null,
|
||||
useCORS: true,
|
||||
allowTaint: true,
|
||||
scale: 2,
|
||||
width: props.width,
|
||||
height: props.height,
|
||||
})
|
||||
|
||||
const blob = await canvasToBlob(canvas)
|
||||
if (blob) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = props.filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
emit('rendered', canvas.toDataURL('image/png'))
|
||||
return
|
||||
}
|
||||
|
||||
const dataUrl = canvas.toDataURL('image/png')
|
||||
const link = document.createElement('a')
|
||||
link.href = dataUrl
|
||||
link.download = props.filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
emit('rendered', dataUrl)
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.message || 'Download failed. Please try again.'
|
||||
} finally {
|
||||
emit('render-end')
|
||||
isWorking.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="download-block">
|
||||
<button class="download-btn" :disabled="!isReady || isWorking" @click="handleDownload">
|
||||
<span v-motion :initial="{ scale: 1 }" :enter="{ scale: 1 }" :hovered="{ scale: 1.05 }">
|
||||
Download strip
|
||||
</span>
|
||||
</button>
|
||||
<p v-if="errorMessage" class="download-error">{{ errorMessage }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.download-block {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.download-btn {
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 12px 24px;
|
||||
background: #c85a7c;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 16px 30px rgba(200, 90, 124, 0.3);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.download-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.download-error {
|
||||
margin: 0;
|
||||
color: #a13b56;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,187 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
const props = defineProps({
|
||||
src: { type: String, required: true },
|
||||
outputWidth: { type: Number, default: 440 },
|
||||
outputHeight: { type: Number, default: 290 },
|
||||
initialTransform: { type: Object, default: null },
|
||||
})
|
||||
const emit = defineEmits(["apply", "cancel"]);
|
||||
|
||||
const imgRef = ref(null)
|
||||
const containerRef = ref(null)
|
||||
const pos = ref({ x: 0, y: 0 })
|
||||
const scale = ref(1)
|
||||
const rotating = ref(0)
|
||||
let dragging = false
|
||||
let last = { x: 0, y: 0 }
|
||||
|
||||
onMounted(() => {
|
||||
// initialize scale to fit image into viewport once loaded
|
||||
const img = imgRef.value
|
||||
img.onload = () => {
|
||||
const sw = props.outputWidth
|
||||
const sh = props.outputHeight
|
||||
if (props.initialTransform) {
|
||||
// rehydrate previous transform
|
||||
scale.value = props.initialTransform.scale || 1
|
||||
rotating.value = props.initialTransform.rotate || 0
|
||||
pos.value = { x: props.initialTransform.x || 0, y: props.initialTransform.y || 0 }
|
||||
// if transform was created with different output size, scale positions accordingly
|
||||
if (props.initialTransform.outputWidth && props.initialTransform.outputWidth !== sw) {
|
||||
const sx = sw / props.initialTransform.outputWidth
|
||||
const sy = sh / props.initialTransform.outputHeight
|
||||
pos.value.x = pos.value.x * sx
|
||||
pos.value.y = pos.value.y * sy
|
||||
scale.value = scale.value * ((sx + sy) / 2)
|
||||
}
|
||||
} else {
|
||||
const r = Math.max(sw / img.naturalWidth, sh / img.naturalHeight)
|
||||
scale.value = r
|
||||
pos.value = { x: (sw - img.naturalWidth * scale.value) / 2, y: (sh - img.naturalHeight * scale.value) / 2 }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function onPointerDown(e) {
|
||||
dragging = true
|
||||
last = { x: e.clientX, y: e.clientY }
|
||||
window.addEventListener('pointermove', onPointerMove)
|
||||
window.addEventListener('pointerup', onPointerUp)
|
||||
}
|
||||
function onPointerMove(e) {
|
||||
if (!dragging) return
|
||||
const dx = e.clientX - last.x
|
||||
const dy = e.clientY - last.y
|
||||
pos.value.x += dx
|
||||
pos.value.y += dy
|
||||
last = { x: e.clientX, y: e.clientY }
|
||||
}
|
||||
function onPointerUp() {
|
||||
dragging = false
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
}
|
||||
|
||||
function onWheel(e) {
|
||||
e.preventDefault()
|
||||
const delta = -e.deltaY
|
||||
const factor = delta > 0 ? 1.08 : 0.92
|
||||
// zoom around mouse position
|
||||
const rect = containerRef.value.getBoundingClientRect()
|
||||
const mx = e.clientX - rect.left
|
||||
const my = e.clientY - rect.top
|
||||
const prevScale = scale.value
|
||||
scale.value = Math.max(0.2, Math.min(6, scale.value * factor))
|
||||
// adjust pos so point under cursor remains
|
||||
pos.value.x = mx - ((mx - pos.value.x) * (scale.value / prevScale))
|
||||
pos.value.y = my - ((my - pos.value.y) * (scale.value / prevScale))
|
||||
}
|
||||
|
||||
function presetFit() {
|
||||
const img = imgRef.value
|
||||
const sw = props.outputWidth
|
||||
const sh = props.outputHeight
|
||||
const r = Math.min(sw / img.naturalWidth, sh / img.naturalHeight)
|
||||
scale.value = r
|
||||
pos.value = { x: (sw - img.naturalWidth * r) / 2, y: (sh - img.naturalHeight * r) / 2 }
|
||||
}
|
||||
function presetFill() {
|
||||
const img = imgRef.value
|
||||
const sw = props.outputWidth
|
||||
const sh = props.outputHeight
|
||||
const r = Math.max(sw / img.naturalWidth, sh / img.naturalHeight)
|
||||
scale.value = r
|
||||
pos.value = { x: (sw - img.naturalWidth * r) / 2, y: (sh - img.naturalHeight * r) / 2 }
|
||||
}
|
||||
|
||||
async function apply() {
|
||||
const img = imgRef.value
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = props.outputWidth
|
||||
canvas.height = props.outputHeight
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.save()
|
||||
ctx.translate(pos.value.x, pos.value.y)
|
||||
ctx.rotate((rotating.value * Math.PI) / 180)
|
||||
ctx.scale(scale.value, scale.value)
|
||||
ctx.drawImage(img, 0, 0)
|
||||
ctx.restore()
|
||||
const dataUrl = canvas.toDataURL('image/png')
|
||||
|
||||
const transform = {
|
||||
x: pos.value.x,
|
||||
y: pos.value.y,
|
||||
scale: scale.value,
|
||||
rotate: rotating.value,
|
||||
outputWidth: props.outputWidth,
|
||||
outputHeight: props.outputHeight,
|
||||
naturalWidth: img.naturalWidth,
|
||||
naturalHeight: img.naturalHeight,
|
||||
}
|
||||
|
||||
emit('apply', { dataUrl, transform })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="editor-overlay">
|
||||
<div class="editor">
|
||||
<div class="viewport" :style="{ width: `${outputWidth}px`, height: `${outputHeight}px` }" ref="containerRef" @pointerdown="onPointerDown" @wheel.prevent="onWheel">
|
||||
<img :src="src" ref="imgRef" class="editable" :style="{ transform: `translate(${pos.x}px, ${pos.y}px) scale(${scale}) rotate(${rotating}deg)` }" />
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<div class="presets">
|
||||
<button @click="presetFit">Fit</button>
|
||||
<button @click="presetFill">Fill</button>
|
||||
<label>Rotate <input type="range" min="-180" max="180" v-model.number="rotating" /></label>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="ghost" @click="$emit('cancel')">Cancel</button>
|
||||
<button class="primary" @click="apply">Apply</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.editor-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 60;
|
||||
}
|
||||
.editor {
|
||||
background: #fff;
|
||||
padding: 18px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 50px rgba(0,0,0,0.4);
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.viewport {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #f3f3f3;
|
||||
}
|
||||
.editable {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.presets button { margin-right: 8px }
|
||||
.primary { background: #c85a7c; color: #fff; padding: 8px 14px; border: none; border-radius: 8px }
|
||||
.ghost { background: transparent; border: 1px solid #ddd; padding: 8px 14px; border-radius: 8px }
|
||||
</style>
|
||||
@@ -0,0 +1,105 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { DEFAULT_STRIP_THEME_ID, STRIP_THEME_MAP } from '../data/stripThemes'
|
||||
|
||||
const props = defineProps({
|
||||
photos: { type: Array, default: () => [] },
|
||||
theme: { type: String, default: DEFAULT_STRIP_THEME_ID },
|
||||
})
|
||||
|
||||
const themeConfig = computed(
|
||||
() => STRIP_THEME_MAP[props.theme] || STRIP_THEME_MAP[DEFAULT_STRIP_THEME_ID],
|
||||
)
|
||||
const borderColor = computed(() => themeConfig.value?.borderColor || '#ffffff')
|
||||
const stripShadow = computed(
|
||||
() => themeConfig.value?.shadow || '0 24px 60px rgba(0, 0, 0, 0.18)',
|
||||
)
|
||||
|
||||
const stripSize = computed(() => themeConfig.value.size)
|
||||
const frames = computed(() => themeConfig.value.slots || [])
|
||||
|
||||
function slotStyle(rect) {
|
||||
return {
|
||||
left: `${rect.x + 8}px`,
|
||||
top: `${rect.y + 8}px`,
|
||||
width: `${Math.max(rect.width - 16, 0)}px`,
|
||||
height: `${Math.max(rect.height - 16, 0)}px`,
|
||||
}
|
||||
}
|
||||
|
||||
function clipStyle(rect) {
|
||||
return {
|
||||
border: `8px solid ${borderColor.value}`,
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="strip-root"
|
||||
:style="{
|
||||
width: `${stripSize.width}px`,
|
||||
height: `${stripSize.height}px`,
|
||||
backgroundImage: `url('${themeConfig.image}')`,
|
||||
boxShadow: stripShadow,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-for="(rect, index) in frames"
|
||||
:key="`slot-${index}`"
|
||||
class="photo-slot"
|
||||
:style="slotStyle(rect)"
|
||||
>
|
||||
<template v-if="photos[index]">
|
||||
<template v-if="rect.radius || rect.clipPath">
|
||||
<svg :width="Math.max(rect.width - 16, 0)" :height="Math.max(rect.height - 16, 0)" :viewBox="`0 0 ${Math.max(rect.width - 16, 0)} ${Math.max(rect.height - 16, 0)}`" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<clipPath :id="`clip-${props.theme}-${index}`">
|
||||
<template v-if="rect.clipPath">
|
||||
<path :d="rect.clipPath" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<rect x="0" y="0" :width="Math.max(rect.width - 16, 0)" :height="Math.max(rect.height - 16, 0)" :rx="rect.radius" :ry="rect.radius" />
|
||||
</template>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<image :href="(photos[index].preview !== undefined) ? (photos[index].preview || photos[index].src) : ((photos[index].src !== undefined) ? photos[index].src : photos[index])" x="0" y="0" :width="Math.max(rect.width - 16, 0)" :height="Math.max(rect.height - 16, 0)" :clip-path="`url(#clip-${props.theme}-${index})`" preserveAspectRatio="xMidYMid slice" />
|
||||
<template v-if="rect.clipPath">
|
||||
<path :d="rect.clipPath" fill="none" :stroke="borderColor" stroke-width="12" stroke-linejoin="round" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<rect x="6" y="6" :width="Math.max(rect.width - 28, 0)" :height="Math.max(rect.height - 28, 0)" :rx="Math.max((rect.radius || 0) - 2, 0)" :ry="Math.max((rect.radius || 0) - 2, 0)" fill="none" :stroke="borderColor" stroke-width="10" />
|
||||
</template>
|
||||
</svg>
|
||||
</template>
|
||||
<template v-else>
|
||||
<img :src="(photos[index].preview !== undefined) ? (photos[index].preview || photos[index].src) : ((photos[index].src !== undefined) ? photos[index].src : photos[index])" :alt="`Captured photo ${index + 1}`" class="frame-photo" :style="clipStyle(rect)" />
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.strip-root {
|
||||
position: relative;
|
||||
border-radius: 40px;
|
||||
background-size: 100% 100%;
|
||||
background-repeat: no-repeat;
|
||||
/* box-shadow is set inline per theme */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.photo-slot {
|
||||
position: absolute;
|
||||
border-radius: 24px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.frame-photo {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
type: { type: String, required: true },
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="sticker"
|
||||
v-motion
|
||||
:initial="{ y: 0, rotate: 0 }"
|
||||
:enter="{ y: 0, rotate: 0, transition: { duration: 0.4 } }"
|
||||
:hovered="{ y: -6, rotate: -6 }"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<svg v-if="type === 'flowers'" viewBox="0 0 120 120" aria-hidden="true">
|
||||
<circle cx="34" cy="36" r="16" fill="#f7b0c7" />
|
||||
<circle cx="34" cy="36" r="6" fill="#fff3f7" />
|
||||
<circle cx="68" cy="26" r="14" fill="#f3a2bd" />
|
||||
<circle cx="68" cy="26" r="5" fill="#fff3f7" />
|
||||
<circle cx="84" cy="54" r="18" fill="#f5b7cc" />
|
||||
<circle cx="84" cy="54" r="6" fill="#fff3f7" />
|
||||
<circle cx="18" cy="72" r="10" fill="#f3a2bd" />
|
||||
<circle cx="18" cy="72" r="4" fill="#fff3f7" />
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="type === 'bunny'" viewBox="0 0 120 140" aria-hidden="true">
|
||||
<ellipse cx="60" cy="84" rx="38" ry="34" fill="#fff" />
|
||||
<ellipse cx="42" cy="22" rx="12" ry="26" fill="#fff" />
|
||||
<ellipse cx="78" cy="22" rx="12" ry="26" fill="#fff" />
|
||||
<circle cx="48" cy="80" r="5" fill="#333" />
|
||||
<circle cx="72" cy="80" r="5" fill="#333" />
|
||||
<path d="M60 86c-6 6-12 6-18 0" stroke="#333" stroke-width="3" fill="none" />
|
||||
<path d="M60 86c6 6 12 6 18 0" stroke="#333" stroke-width="3" fill="none" />
|
||||
<circle cx="84" cy="104" r="12" fill="#f4a6c1" />
|
||||
<path d="M84 104l12-8" stroke="#f4a6c1" stroke-width="6" />
|
||||
<path d="M84 104l-12-8" stroke="#f4a6c1" stroke-width="6" />
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="type === 'star'" viewBox="0 0 120 120" aria-hidden="true">
|
||||
<path d="M60 10l14 30 32 4-24 22 6 32-28-14-28 14 6-32-24-22 32-4z" fill="#f7a6c8" />
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="type === 'bow'" viewBox="0 0 140 120" aria-hidden="true">
|
||||
<path d="M40 60c-28-30-22-50 6-44 20 4 40 24 40 24s-10 26-46 20z" fill="#f1a3ba" />
|
||||
<path d="M100 60c28-30 22-50-6-44-20 4-40 24-40 24s10 26 46 20z" fill="#f1a3ba" />
|
||||
<circle cx="70" cy="60" r="12" fill="#d9779a" />
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sticker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
filter: drop-shadow(0 8px 12px rgba(201, 110, 135, 0.25));
|
||||
}
|
||||
|
||||
.sticker svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { usePhotoboothStore } from '../stores/photoboothStore'
|
||||
import { STRIP_THEMES } from '../data/stripThemes'
|
||||
|
||||
const store = usePhotoboothStore()
|
||||
|
||||
const activeTheme = computed(() => store.stripTheme)
|
||||
|
||||
function pickTheme(themeId) {
|
||||
store.setTheme(themeId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="theme-picker">
|
||||
<p class="theme-title">Select strip theme</p>
|
||||
<div class="theme-grid">
|
||||
<button
|
||||
v-for="theme in STRIP_THEMES"
|
||||
:key="theme.id"
|
||||
class="theme-chip"
|
||||
:class="{ active: activeTheme === theme.id }"
|
||||
@click="pickTheme(theme.id)"
|
||||
>
|
||||
<span class="thumb" :style="{ backgroundImage: `url('${theme.image}')` }"></span>
|
||||
<span>{{ theme.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.theme-picker {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
padding: 18px 20px;
|
||||
border-radius: 24px;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
border: 1px solid rgba(200, 90, 124, 0.2);
|
||||
box-shadow: 0 16px 40px rgba(205, 119, 143, 0.2);
|
||||
}
|
||||
|
||||
.theme-title {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #7c3b54;
|
||||
}
|
||||
|
||||
.theme-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.theme-chip {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 16px;
|
||||
border: 2px solid transparent;
|
||||
background: #fff;
|
||||
color: #4b2b38;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
border: 1px solid rgba(124, 59, 84, 0.15);
|
||||
}
|
||||
|
||||
.theme-chip.active {
|
||||
border-color: #c85a7c;
|
||||
box-shadow: 0 10px 20px rgba(200, 90, 124, 0.2);
|
||||
}
|
||||
|
||||
.theme-chip:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"pink": [
|
||||
null,
|
||||
null,
|
||||
null
|
||||
],
|
||||
"ticket": [
|
||||
"M -0 39.500 L 0 45.072 9.750 44.786 L 19.500 44.500 19.500 39.500 L 19.500 34.500 9.750 34.214 L -0 33.928 -0 39.500 M 30.687 34.750 C 30.450 35.163, 30.311 37.525, 30.378 40 L 30.500 44.500 46.750 44.775 L 63 45.050 63 39.525 L 63 34 47.059 34 C 38.291 34, 30.924 34.337, 30.687 34.750 M 74 39.500 L 74 45 90.500 45 L 107 45 107 39.500 L 107 34 90.500 34 L 74 34 74 39.500 M 118 39.500 L 118 45 134 45 L 150 45 150 39.500 L 150 34 134 34 L 118 34 118 39.500 M 161.667 34.667 C 161.300 35.033, 161 37.508, 161 40.167 L 161 45 177.500 45 L 194 45 194 39.500 L 194 34 178.167 34 C 169.458 34, 162.033 34.300, 161.667 34.667 M 205 39.500 L 205 45.050 221.250 44.775 L 237.500 44.500 237.500 39.500 L 237.500 34.500 221.250 34.225 L 205 33.950 205 39.500 M 248.917 34.416 C 248.688 34.645, 248.656 37.120, 248.847 39.916 L 249.193 45 265.097 45 L 281 45 281 39.500 L 281 34 265.167 34 C 256.458 34, 249.146 34.187, 248.917 34.416 M 292 39.500 L 292 45 308.500 45 L 325 45 325 39.500 L 325 34 308.500 34 L 292 34 292 39.500 M 336 39.500 L 336 45 352 45 L 368 45 368 39.500 L 368 34 352 34 L 336 34 336 39.500 M 379.667 34.667 C 379.300 35.033, 379 37.508, 379 40.167 L 379 45 395.500 45 L 412 45 412 39.500 L 412 34 396.167 34 C 387.458 34, 380.033 34.300, 379.667 34.667 M 423 39.500 L 423 45 431.500 45 L 440 45 440 39.500 L 440 34 431.500 34 L 423 34 423 39.500 M 6.923 55.923 C 3.367 59.480, 3.310 60.401, 6.455 63.545 C 13.067 70.158, 22.325 62.019, 15.961 55.189 C 13.189 52.213, 10.407 52.440, 6.923 55.923 M 53.635 55.073 C 49.608 58.240, 49.999 62.555, 54.509 64.706 C 59.549 67.109, 63.386 65.648, 63.819 61.161 C 64.498 54.118, 59.016 50.840, 53.635 55.073 M 101.017 54.286 C 96.779 56.430, 95.997 60.740, 99.326 63.604 C 106.341 69.638, 114.570 62.808, 108.927 55.635 C 106.637 52.723, 104.743 52.400, 101.017 54.286 M 140.866 54.511 C 136.849 56.694, 136.212 60.925, 139.478 63.735 C 146.365 69.658, 154.484 62.699, 148.927 55.635 C 146.603 52.680, 144.719 52.418, 140.866 54.511 M 182.923 55.923 C 179.367 59.480, 179.310 60.401, 182.455 63.545 C 189.067 70.158, 198.325 62.019, 191.961 55.189 C 189.189 52.213, 186.407 52.440, 182.923 55.923 M 231.098 54.245 C 225.796 57.016, 225.725 62.815, 230.968 64.987 C 237.762 67.801, 242.772 62.728, 239.221 56.629 C 237.179 53.123, 234.659 52.383, 231.098 54.245 M 273.866 54.511 C 269.849 56.694, 269.212 60.925, 272.478 63.735 C 277.503 68.057, 283.333 66.029, 283.333 59.960 C 283.333 54.213, 279.002 51.721, 273.866 54.511 M 315.923 55.923 C 312.367 59.480, 312.310 60.401, 315.455 63.545 C 320.084 68.175, 327 66.008, 327 59.929 C 327 53.375, 320.737 51.110, 315.923 55.923 M 360.098 54.265 C 354.796 56.992, 354.725 62.815, 359.968 64.987 C 367.663 68.174, 372.652 61.106, 367.193 54.750 C 365.429 52.696, 363.416 52.559, 360.098 54.265 M 404.098 54.265 C 398.736 57.022, 398.727 62.816, 404.081 65.034 C 410.902 67.859, 415.670 61.724, 411.546 55.429 C 409.781 52.735, 407.703 52.411, 404.098 54.265 M 35.870 56.387 C 33.504 61.579, 40.306 68.489, 45.231 65.896 C 49.551 63.620, 50.675 56.967, 47.066 55.035 C 43.266 53.002, 37.071 53.749, 35.870 56.387 M 83.200 55.200 C 81.716 56.684, 81.649 60.476, 83.061 63.114 C 86.366 69.290, 95.853 66.087, 95.986 58.750 C 96.053 55.079, 86.088 52.312, 83.200 55.200 M 123.200 55.200 C 121.716 56.684, 121.649 60.476, 123.061 63.114 C 126.366 69.290, 135.853 66.087, 135.986 58.750 C 136.053 55.079, 126.088 52.312, 123.200 55.200 M 166.200 55.200 C 163.337 58.063, 166.321 65.423, 170.712 66.325 C 176.621 67.539, 181.329 60.231, 177.420 55.912 C 175.430 53.713, 168.149 53.251, 166.200 55.200 M 211.776 56.593 C 208.562 63.646, 219.934 70.042, 224.235 63.600 C 225.756 61.323, 225.042 56.093, 223.066 55.035 C 219.106 52.916, 213.071 53.749, 211.776 56.593 M 256.200 55.200 C 253.417 57.983, 255.459 64.464, 259.617 66.044 C 266.161 68.532, 272.354 59.462, 266.777 55.557 C 264.143 53.711, 257.899 53.501, 256.200 55.200 M 299.200 55.200 C 296.337 58.063, 299.321 65.423, 303.712 66.325 C 309.621 67.539, 314.329 60.231, 310.420 55.912 C 308.430 53.713, 301.149 53.251, 299.200 55.200 M 342.427 54.765 C 338.970 57.133, 340.985 64.283, 345.604 66.040 C 350.822 68.023, 356.006 62.776, 354.011 57.529 C 352.777 54.283, 345.624 52.576, 342.427 54.765 M 386.427 54.765 C 380.899 58.551, 388.334 68.999, 394.315 65.850 C 398.555 63.619, 399.635 56.945, 396.066 55.035 C 393.841 53.845, 388.010 53.681, 386.427 54.765 M 429.242 55.158 C 426.334 58.066, 429.288 65.429, 433.712 66.303 C 438.465 67.242, 441 64.753, 441 59.148 L 441 54.342 435.742 54.130 C 432.616 54.003, 429.980 54.420, 429.242 55.158 M 0 189 L 0 290 6.968 290 L 13.936 290 14.218 234.250 L 14.500 178.500 17.295 172.808 C 21.627 163.987, 27.981 157.613, 36.777 153.266 L 44.396 149.500 217.948 149.500 L 391.500 149.500 398.223 152.685 C 407.182 156.929, 414.218 163.667, 418.259 171.873 L 421.500 178.455 421.774 234.265 L 422.049 290.074 431.274 289.787 L 440.500 289.500 440.757 188.500 L 441.014 87.500 431.507 87.819 C 423.Line truncated
|
||||
"M 0 2.500 L 0 5 11.383 5 C 22.732 5, 22.768 4.992, 23.245 2.500 L 23.723 -0 11.861 -0 L 0 0 0 2.500 M 56.180 2.250 C 56.473 4.311, 57.100 4.524, 63.660 4.792 L 70.820 5.084 78.486 14.048 C 87.582 24.682, 88.705 25.421, 93.754 24.081 C 95.814 23.535, 106.500 21.259, 117.500 19.024 C 146.194 13.194, 142.980 15.025, 148.695 1.250 C 149.104 0.265, 142.168 0, 115.933 0 L 82.651 0 85.014 2.515 C 86.982 4.610, 87.216 5.485, 86.418 7.765 C 84.338 13.703, 78 10.324, 78 3.277 L 78 0 66.930 0 C 56.023 0, 55.866 0.033, 56.180 2.250 M 175.015 1.250 C 175.058 4.726, 176.497 5.064, 190.078 4.780 C 203.138 4.508, 203.509 4.439, 203.820 2.250 C 204.138 0.005, 204.107 -0, 189.570 -0 C 178.572 -0, 175.004 0.306, 175.015 1.250 M 236.760 2.527 L 237.244 5.055 250.759 4.777 C 263.741 4.511, 264.306 4.411, 265.105 2.250 L 265.936 0 251.106 -0 L 236.277 -0 236.760 2.527 M 298.180 2.250 C 298.492 4.443, 298.849 4.507, 312.250 4.778 C 320.427 4.944, 326 4.673, 326 4.110 C 326 3.589, 326.273 2.452, 326.607 1.582 C 327.143 0.184, 325.510 0, 312.537 0 C 297.889 0, 297.862 0.004, 298.180 2.250 M 359.755 2.500 L 360.233 5 374.010 5 C 388.079 5, 389 4.749, 389 0.918 C 389 0.402, 382.497 -0, 374.139 -0 L 359.277 -0 359.755 2.500 M 421.755 2.500 C 422.222 4.943, 422.435 5, 431.117 5 C 439.926 5, 440 4.979, 440 2.500 C 440 0.011, 439.957 -0, 430.639 -0 L 421.277 -0 421.755 2.500 M 0 202 L 0 290 8.969 290 L 17.939 290 18.219 246.750 L 18.500 203.500 21.442 197.500 C 25.196 189.843, 32.843 182.196, 40.500 178.442 L 46.500 175.500 222 175.500 L 397.500 175.500 403.351 178.373 C 410.828 182.044, 418.967 190.183, 422.632 197.653 L 425.500 203.500 425.790 246.793 L 426.080 290.086 433.290 289.793 L 440.500 289.500 440.758 201.500 L 441.015 113.500 431.572 113.817 L 422.129 114.134 421.496 117.020 C 419.547 125.893, 407.634 131.438, 398.718 127.623 C 394.582 125.853, 390.119 121.092, 389.132 117.396 L 388.359 114.500 374.407 114.224 L 360.456 113.948 358.978 118.185 C 354.241 131.763, 334.397 132.409, 328 119.194 L 325.500 114.029 311.861 114.015 L 298.223 114 297.580 117.424 C 296.901 121.047, 291.761 126.380, 287.324 128.067 C 278.885 131.275, 267.345 125.402, 265.490 116.953 L 264.841 114 251 114 L 237.159 114 236.510 116.953 C 234.504 126.085, 222.289 131.603, 213.407 127.389 C 208.919 125.259, 204 119.547, 204 116.465 L 204 114 190.244 114 L 176.488 114 174.227 118.929 C 167.761 133.024, 146.176 131.502, 142.458 116.689 L 141.783 114 129.527 114 L 117.272 114 115.532 118.554 C 110.045 132.923, 87.199 131.594, 83.458 116.689 L 82.783 114 69.392 114 L 56 114 56 116.423 C 56 121.690, 46.532 129.023, 39.790 128.978 C 32.502 128.929, 24.886 123.311, 23.490 116.953 L 22.841 114 11.421 114 L 0 114 0 202 M 0.479 202 C 0.479 250.675, 0.601 270.588, 0.750 246.250 C 0.899 221.913, 0.899 182.088, 0.750 157.750 C 0.601 133.413, 0.479 153.325, 0.479 202",
|
||||
"M 0 5.559 C 0 11.984, 0.500 11.932, 4.776 5.054 L 7.917 -0 3.959 -0 L 0 0 0 5.559 M 14.491 1.015 C 14.146 1.573, 12.399 2.642, 10.608 3.390 C 8.818 4.138, 5.699 7.004, 3.677 9.759 L -0 14.768 -0 29.368 L 0 43.968 3.487 42.305 C 7.372 40.452, 9.581 41.371, 6.557 43.582 C 4.101 45.378, 5.374 45.416, 8.846 43.650 C 12.865 41.605, 21.357 39.020, 21.964 39.656 C 22.259 39.966, 21.488 40.899, 20.250 41.731 C 17.659 43.473, 17.326 45.528, 19.500 46.362 C 21.858 47.266, 23.951 46.041, 23.250 44.167 C 22.725 42.767, 22.875 42.702, 24.180 43.762 C 25.447 44.791, 25.967 44.745, 26.990 43.512 C 27.680 42.681, 29.090 42, 30.122 42 C 31.315 42, 32 41.291, 32 40.057 C 32 38.988, 32.897 37.485, 33.994 36.717 C 35.090 35.949, 35.690 35.023, 35.327 34.660 C 34.964 34.297, 35.119 34, 35.672 34 C 36.226 34, 37.538 32.205, 38.589 30.011 C 39.640 27.818, 40.894 26.018, 41.375 26.011 C 41.856 26.005, 41.983 24.667, 41.658 23.039 C 41.332 21.410, 41.481 19.821, 41.989 19.507 C 42.497 19.193, 42.659 18.275, 42.349 17.468 C 41.498 15.249, 42.678 15.649, 49.082 19.750 C 53.245 22.416, 55.181 24.367, 55.781 26.500 L 56.624 29.500 69.812 29.779 C 82.154 30.040, 83 29.937, 83 28.177 C 83 24.506, 87.356 19.500, 90.551 19.500 C 92.650 19.500, 93.784 18.852, 94.487 17.250 C 95.314 15.365, 96.255 15, 100.287 15 C 108.073 15, 114.986 20.108, 116.495 26.976 L 117.159 30 128.962 30 L 140.765 30 143.360 24.697 C 150.080 10.961, 172.006 11.996, 174.903 26.185 L 175.579 29.500 189.310 29.778 C 201.732 30.030, 203.096 29.887, 203.614 28.278 C 206.583 19.067, 210.422 15.814, 219.060 15.189 C 228.236 14.526, 237 20.979, 237 28.401 C 237 29.787, 238.798 30, 250.525 30 L 264.050 30 265.045 27.250 C 268.290 18.291, 273.866 14.533, 282.940 15.189 C 291.568 15.813, 295.428 19.080, 298.375 28.250 C 298.874 29.802, 300.459 30, 312.389 30 L 325.841 30 326.505 26.976 C 329.971 11.197, 356.516 11.328, 359.678 27.140 L 360.250 30 374.156 30 C 384.438 30, 388.149 29.674, 388.395 28.750 C 390.803 19.687, 395.240 15.827, 403.977 15.195 C 412.253 14.597, 417.809 17.805, 420.887 24.960 L 423.056 30 431.482 30 L 439.909 30 440.258 36.750 C 440.479 40.999, 440.679 39.055, 440.799 31.500 C 440.904 24.900, 440.699 15.113, 440.344 9.750 L 439.698 0 227.408 0 C 84.848 0, 14.912 0.333, 14.491 1.015 M 0.402 29 C 0.402 37.525, 0.556 41.013, 0.743 36.750 C 0.931 32.488, 0.931 25.513, 0.743 21.250 C 0.556 16.988, 0.402 20.475, 0.402 29 M 0 214.500 L 0 290 6.947 290 L 13.893 290 14.197 259.250 L 14.500 228.500 17.430 222.618 C 21.164 215.119, 28.435 207.849, 36.317 203.731 L 42.500 200.500 195.750 200.237 C 304.821 200.051, 349 200.286, 349 201.053 C 349 201.646, 347.792 206.636, 346.315 212.143 C 343.121 224.057, 341.728 222.447, 360.500 228.545 C 368.200 231.046, 375.162 233.321, 375.972 233.600 C 377.100 233.989, 377.153 234.288, 376.197 234.878 C 375.383 235.382, 376.226 236.041, 378.625 236.777 L 382.300 237.905 379.201 234.702 C 377.497 232.941, 375.167 229.381, 374.023 226.792 C 372.567 223.495, 371.503 222.254, 370.472 222.649 C 369.463 223.036, 369 222.551, 369 221.107 C 369 219.948, 369.626 219, 370.392 219 C 371.157 219, 372.088 217.763, 372.460 216.250 C 373.723 211.116, 378.018 205.039, 381.790 203.047 C 383.875 201.946, 384.760 201.069, 383.809 201.044 C 382.879 201.020, 381.883 201.381, 381.595 201.846 C 380.718 203.265, 353.643 195.850, 352.409 193.852 C 351.403 192.225, 354.157 181.877, 355.759 181.262 C 357.252 180.689, 368.710 183.755, 370.706 185.262 C 371.370 185.762, 372.299 185.933, 372.772 185.641 C 373.245 185.348, 376.912 185.788, 380.920 186.618 C 384.928 187.447, 388.421 187.912, 388.682 187.651 C 389.328 187.005, 404.330 191.338, 410.500 193.952 C 415.200 195.944, 415.281 196.037, 411.849 195.509 L 408.199 194.947 406.910 199.973 C 405.216 206.582, 403.678 206.423, 404.515 199.726 L 405.174 194.452 397.416 192.169 L 389.657 189.887 386.831 193.193 C 383.608 196.964, 383.395 197.831, 385.494 198.636 C 386.316 198.952, 386.740 199.612, 386.435 200.105 C 386.131 200.597, 387.821 200.986, 390.191 200.970 C 393.920 200.943, 394.231 200.786, 392.500 199.805 C 389.197 197.932, 388.471 196.382, 389.867 194.180 C 391.331 191.872, 403.023 194.801, 402.921 197.450 C 402.878 198.578, 402.615 198.938, 402.338 198.250 C 402.060 197.563, 401.008 197, 400 197 C 398.992 197, 397.981 196.443, 397.754 195.762 C 397.429 194.786, 396.738 194.798, 394.491 195.822 L 391.640 197.121 396.570 199.523 C 400.550 201.461, 400.922 201.834, 398.500 201.456 L 395.500 200.988 398.500 202.115 C 415.734 208.590, 414.167 234.118, 396.221 239.239 L 392.500 240.301 397.500 241.631 C 403.858 243.323, 405.326 243.288, 401.500 241.536 C 397.263 239.596, 398.994 239.704, 407.833 241.933 C 412.050 242.996, 416.850 244.008, 418.500 244.183 L 421.500 244.500 421.769 267.287 L 422.038 290.075 431.269 289.787 L 440.500 289.500 440.758 214 L 441.016 138.500 429.621 138.809 L 418.227 139.117 417.615 142.174 C 416.017 150.165, 405.479 155.552, 396.287 153.077 C 391.378 151.756, 385 145.211, Line truncated
|
||||
],
|
||||
"music": [
|
||||
"M 362 329.234 C 362 329.729, 361.172 329.872, 360.161 329.551 C 358.461 329.012, 351 331.419, 351 332.506 C 351 333.699, 379.349 332.460, 379.751 331.250 C 379.980 330.563, 379.428 330, 378.524 330 C 377.621 330, 377.160 330.450, 377.500 331 C 377.840 331.550, 377.668 332, 377.118 332 C 376.568 332, 375.872 331.602, 375.571 331.115 C 374.849 329.947, 369 329.109, 369 330.173 C 369 331.535, 365.978 331.106, 365.426 329.667 C 364.856 328.181, 362 327.821, 362 329.234",
|
||||
"M 5 24.392 L 5 48.783 2.500 49.411 C 0.246 49.976, 0 50.536, 0 55.086 C 0 60.757, -0.450 60.662, 9 56.989 L 13.500 55.240 14 41.639 L 14.500 28.038 21.238 36.769 C 24.943 41.571, 28.601 46.375, 29.366 47.444 C 30.721 49.338, 31.093 49.265, 43.520 44.669 C 50.539 42.073, 56.437 40.104, 56.627 40.293 C 56.817 40.483, 53.394 45.738, 49.021 51.971 C 44.647 58.204, 41.236 63.573, 41.439 63.902 C 41.643 64.231, 45.226 68.775, 49.401 74 C 53.576 79.225, 56.994 83.775, 56.996 84.112 C 56.998 84.449, 51.713 83.221, 45.250 81.384 C 38.788 79.548, 33.050 78.054, 32.500 78.065 C 31.950 78.076, 27.900 83.237, 23.500 89.534 L 15.500 100.983 15 86.315 L 14.500 71.647 9.500 70.497 C 6.750 69.865, 3.488 69.014, 2.250 68.606 C 0.113 67.902, 0 68.123, 0 72.985 C 0 77.767, 0.196 78.172, 2.983 79.144 L 5.966 80.184 6.233 105.257 L 6.500 130.331 20.500 109.699 C 28.200 98.352, 35.072 89.052, 35.771 89.034 C 36.470 89.015, 46.522 91.724, 58.108 95.054 C 69.694 98.384, 79.641 100.953, 80.211 100.763 C 80.782 100.573, 75.210 92.336, 67.828 82.458 C 60.446 72.581, 54.079 63.963, 53.679 63.307 C 53.054 62.281, 63.136 47.162, 78.805 25.627 C 82.190 20.975, 82.241 20.786, 79.926 21.466 C 78.125 21.995, 46.144 33.644, 35.400 37.685 C 34.795 37.913, 27.881 29.527, 20.037 19.050 C 12.193 8.572, 5.600 0, 5.387 0 C 5.174 0, 5 10.976, 5 24.392 M 17 51.961 C 17 55.865, 14.534 57.995, 6.571 60.973 C -1.502 63.993, -1.681 65.398, 5.750 67.432 C 16.784 70.453, 16.430 69.962, 17 83.031 L 17.500 94.492 23.418 85.996 C 31.529 74.352, 30.981 74.564, 43 78.385 C 47.125 79.697, 50.673 80.611, 50.885 80.416 C 51.341 79.998, 48.146 74.938, 46.518 73.500 C 43.818 71.116, 40 64.959, 40 62.990 C 40 61.811, 41.832 58.293, 44.070 55.173 L 48.141 49.500 44.122 49.195 C 41.912 49.027, 38.167 49.549, 35.802 50.355 C 32.097 51.617, 31.281 51.625, 29.921 50.410 C 28.956 49.548, 26.139 49, 22.671 49 C 17.053 49, 17 49.028, 17 51.961 M 62.156 54.250 C 55.326 63.424, 55.250 62.358, 63.532 73.540 C 81.782 98.179, 83.854 101.217, 83.334 102.573 C 82.559 104.591, 82.241 104.535, 63.963 99.147 C 39.769 92.014, 37.177 91.423, 35.864 92.736 C 35.248 93.352, 29.038 102.325, 22.063 112.678 C 11.401 128.503, 8.993 131.500, 6.941 131.500 L 4.500 131.500 4.232 107.048 C 3.969 83.075, 3.701 81.076, 0.750 81.015 C 0.337 81.007, 0 133.997, 0 198.772 L 0 316.544 8.271 324.772 L 16.542 333 110.771 333 L 205 333 205 275 C 205 243.100, 204.835 217, 204.633 217 C 204.431 217, 203.123 218.727, 201.726 220.838 C 188.568 240.720, 163.044 251.540, 138.807 247.510 C 134.788 246.841, 130.262 245.778, 128.750 245.147 C 127.237 244.516, 124.763 243.475, 123.250 242.834 C 119.130 241.087, 114.880 241.175, 115.272 243 C 115.450 243.825, 114.615 246.377, 113.418 248.671 C 111.784 251.803, 111.523 253.148, 112.371 254.068 C 113.245 255.017, 113.048 255.123, 111.500 254.536 C 110.400 254.119, 108.825 253.715, 108 253.639 C 107.175 253.563, 106.133 253.050, 105.684 252.500 C 105.236 251.950, 105.154 252.512, 105.502 253.750 C 106.012 255.562, 105.732 256, 104.067 256 C 101.718 256, 101.275 257.785, 103.421 258.608 C 104.592 259.057, 104.490 259.750, 102.842 262.544 C 101.741 264.408, 101.176 266.475, 101.585 267.137 C 102.094 267.961, 101.804 268.139, 100.664 267.701 C 99.749 267.350, 99 267.499, 99 268.031 C 99 268.564, 99.563 269.014, 100.250 269.030 C 100.944 269.047, 100.721 269.514, 99.750 270.080 C 98.787 270.641, 98 271.752, 98 272.550 C 98 274.756, 96.747 274.348, 90.364 270.067 C 80.732 263.607, 75.531 257.723, 77.154 255.124 C 77.884 253.956, 79.096 253, 79.848 253 C 80.630 253, 80.976 252.381, 80.658 251.551 C 80.286 250.582, 80.582 250.286, 81.551 250.658 C 82.348 250.964, 83 250.788, 83 250.268 C 83 249.233, 87.998 246.530, 89.321 246.849 C 89.772 246.958, 91.044 246.024, 92.147 244.774 C 93.250 243.523, 93.886 243.031, 93.560 243.681 C 93.234 244.330, 93.331 245.087, 93.776 245.362 C 95.009 246.123, 95.056 241.772, 93.831 240.296 C 92.528 238.726, 94.151 238.551, 95.695 240.095 C 96.488 240.888, 97.892 239.717, 100.789 235.845 L 104.788 230.500 102.394 227.500 C 82.093 202.063, 81.733 168.599, 101.504 144.813 C 118.857 123.934, 149.875 117.122, 176.389 128.365 C 178.999 129.472, 180.668 129.691, 181.463 129.031 C 182.254 128.374, 183.067 128.455, 183.910 129.274 C 184.916 130.251, 185.108 130.094, 184.854 128.500 C 184.678 127.400, 185.540 124.573, 186.768 122.219 C 188.597 118.711, 188.743 117.839, 187.578 117.391 C 186.795 117.091, 185.885 116.142, 185.554 115.281 C 185.041 113.943, 185.188 113.912, 186.578 115.065 C 187.471 115.806, 188.719 116.521, 189.351 116.654 C 195.076 117.856, 196.376 117.962, 195.103 117.122 C 193.965 116.371, 194.090 115.961, 195.779 114.906 C 197.525 113.816, 197.625 113.446, 196.411 112.556 C 195.192 111.662, 195.355 110.937, 197.469 107.828 C 199.232 105.236, 199.604 104.009, 198.733 103.657 C 198.055 103.382, 198.568 103.122, 199.872 103.079 C 201.857 103.013, 202.043 102.757, 201.010 101.512 C 200.010 100.307, 200.081 99.906, 201.382 99.407 C 20Line truncated
|
||||
"M 372 0.514 C 372 0.796, 372.556 1.918, 373.236 3.006 C 374.504 5.037, 369.815 10.497, 352.531 27.117 C 349.798 29.745, 347.791 32.124, 348.071 32.404 C 348.351 32.684, 356.597 31.417, 366.395 29.589 L 384.210 26.264 392.171 42.732 C 396.550 51.790, 400.326 59.007, 400.563 58.771 C 400.908 58.425, 407 24.182, 407 22.586 C 407 22.113, 410.721 21.336, 429 17.993 C 435.325 16.836, 441.175 15.649, 442 15.355 C 442.888 15.038, 437.588 11.820, 429 7.462 L 414.500 0.105 393.250 0.052 C 381.563 0.024, 372 0.231, 372 0.514 M 46.423 273.643 C 44.265 275.783, 40.052 280.506, 37.061 284.138 L 31.622 290.741 22.765 290.508 C 14.857 290.300, 9.251 290.846, 2.250 292.505 C 0.853 292.836, -0 293.789, -0 295.019 C 0 297.398, -0.367 297.384, 11.695 295.443 C 17.015 294.587, 24.098 294, 27.434 294.138 C 35.031 294.452, 36.463 293.558, 43.201 284.296 C 46.116 280.290, 48.805 277.010, 49.178 277.006 C 49.551 277.003, 50.138 279.587, 50.483 282.750 C 51.467 291.765, 53.083 298.112, 55.665 303.094 C 57.099 305.860, 58.293 310.336, 58.668 314.348 L 59.290 321.008 56.161 320.382 C 51.640 319.478, 40.967 324.379, 35.759 329.750 L 32.608 333 42.304 333 C 53.050 333, 52.870 333.096, 49.821 328.972 C 47.141 325.347, 47.599 324.841, 54.133 324.213 L 59.311 323.716 58.675 328.358 C 58.160 332.115, 58.327 333, 59.551 333 C 60.382 333, 61.326 332.313, 61.649 331.473 C 61.971 330.634, 64.319 328.744, 66.867 327.273 C 71.264 324.736, 80.930 318.383, 81.998 317.329 C 83.217 316.126, 76.244 318.468, 74 320.015 C 71.280 321.890, 63.411 326, 62.540 326 C 62.243 326, 61.993 323.188, 61.985 319.750 C 61.968 312.624, 59.628 301.607, 57.413 298.226 C 56.590 296.970, 55.445 292.243, 54.869 287.721 C 54.293 283.200, 53.632 278.116, 53.398 276.424 C 52.507 269.957, 50.818 269.283, 46.423 273.643 M 98.316 314.979 C 89.094 316.030, 86.595 318.032, 87.378 323.742 C 87.720 326.238, 88 329.322, 88 330.598 L 88 332.916 130.554 332.944 L 173.108 332.973 172.804 328.237 L 172.500 323.501 166.754 321.226 C 151.668 315.254, 120.700 312.427, 98.316 314.979 M 99.285 317.830 C 95.764 318.210, 92.389 319.182, 91.498 320.072 C 89.500 322.071, 90.563 325.414, 92.879 324.417 C 100.490 321.139, 104.452 320.837, 125 321.965 C 152.523 323.475, 161.813 324.677, 165.348 327.185 C 167.275 328.551, 168.524 328.876, 169.211 328.189 C 175.371 322.029, 126.922 314.852, 99.285 317.830 M 0 329.981 L 0 333 6.059 333 C 13.779 333, 11.806 329.793, 2.750 327.621 C 0.212 327.013, 0 327.194, 0 329.981"
|
||||
],
|
||||
"bowtie": [
|
||||
"M 339 3.529 C 339 9.396, 344.223 18.422, 349.211 21.173 C 352.677 23.085, 353.828 24.478, 355.282 28.523 C 357.380 34.361, 361.879 38, 366.997 38 C 368.947 38, 371.243 38.813, 372.436 39.926 C 373.571 40.985, 375.738 42.159, 377.250 42.534 L 380 43.217 380 21.608 L 380 0 359.500 0 L 339 0 339 3.529",
|
||||
null,
|
||||
null
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
export const STRIP_BASE_SIZE = { width: 600, height: 1800 }
|
||||
|
||||
export const DEFAULT_STRIP_THEME_ID = 'neon-night'
|
||||
|
||||
const STACKED_SLOTS = [
|
||||
{ x: 80, y: 160, width: 440, height: 290, radius: 24 },
|
||||
{ x: 80, y: 615, width: 440, height: 290, radius: 24 },
|
||||
{ x: 80, y: 1070, width: 440, height: 290, radius: 24 },
|
||||
]
|
||||
|
||||
function svgToDataUri(svg) {
|
||||
const cleaned = svg.replace(/\s+/g, ' ').trim()
|
||||
// encodeURIComponent leaves characters like () and ' unescaped, which can break CSS url(...) parsing.
|
||||
const encoded = encodeURIComponent(cleaned)
|
||||
.replace(/\(/g, '%28')
|
||||
.replace(/\)/g, '%29')
|
||||
.replace(/'/g, '%27')
|
||||
return `data:image/svg+xml,${encoded}`
|
||||
}
|
||||
|
||||
function slotGuidesSvg({ stroke = 'rgba(255,255,255,0.18)', dash = '10 10' } = {}) {
|
||||
return STACKED_SLOTS.map((s, i) => {
|
||||
const innerX = s.x + 8
|
||||
const innerY = s.y + 8
|
||||
const innerW = s.width - 16
|
||||
const innerH = s.height - 16
|
||||
const labelY = innerY + innerH / 2 + 14
|
||||
return `
|
||||
<rect x="${innerX}" y="${innerY}" width="${innerW}" height="${innerH}" rx="${Math.max(
|
||||
(s.radius || 0) - 4,
|
||||
0,
|
||||
)}" fill="rgba(255,255,255,0.04)" stroke="${stroke}" stroke-width="2" stroke-dasharray="${dash}" />
|
||||
<text x="${innerX + innerW / 2}" y="${labelY}" text-anchor="middle" font-family="ui-sans-serif, system-ui" font-size="28" fill="${stroke}">PHOTO ${
|
||||
i + 1
|
||||
}</text>
|
||||
`
|
||||
}).join('')
|
||||
}
|
||||
|
||||
function ticketClipPath(w, h, cornerR = 22, notchR = 28) {
|
||||
const mid = h / 2
|
||||
return [
|
||||
`M${cornerR} 0`,
|
||||
`H${w - cornerR}`,
|
||||
`Q${w} 0 ${w} ${cornerR}`,
|
||||
`V${mid - notchR}`,
|
||||
`C${w - notchR} ${mid - notchR} ${w - notchR} ${mid + notchR} ${w} ${mid + notchR}`,
|
||||
`V${h - cornerR}`,
|
||||
`Q${w} ${h} ${w - cornerR} ${h}`,
|
||||
`H${cornerR}`,
|
||||
`Q0 ${h} 0 ${h - cornerR}`,
|
||||
`V${mid + notchR}`,
|
||||
`C${notchR} ${mid + notchR} ${notchR} ${mid - notchR} 0 ${mid - notchR}`,
|
||||
`V${cornerR}`,
|
||||
`Q0 0 ${cornerR} 0`,
|
||||
'Z',
|
||||
].join(' ')
|
||||
}
|
||||
|
||||
const TICKET_SLOTS = STACKED_SLOTS.map((s) => ({
|
||||
...s,
|
||||
radius: 22,
|
||||
clipPath: ticketClipPath(s.width - 16, s.height - 16, 22, 28),
|
||||
}))
|
||||
|
||||
const NEON_NIGHT = svgToDataUri(`
|
||||
<svg width="600" height="1800" viewBox="0 0 600 1800" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#0a0320"/>
|
||||
<stop offset="1" stop-color="#1a0b3d"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="glow" cx="0.5" cy="0.2" r="0.8">
|
||||
<stop offset="0" stop-color="#ff4fd8" stop-opacity="0.35"/>
|
||||
<stop offset="1" stop-color="#ff4fd8" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<pattern id="stars" width="120" height="120" patternUnits="userSpaceOnUse">
|
||||
<circle cx="18" cy="20" r="2" fill="#ffffff" opacity="0.55"/>
|
||||
<circle cx="88" cy="28" r="1.5" fill="#a7b7ff" opacity="0.55"/>
|
||||
<circle cx="64" cy="82" r="1.2" fill="#ffffff" opacity="0.35"/>
|
||||
<circle cx="102" cy="96" r="1.8" fill="#7ff8ff" opacity="0.45"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
|
||||
<rect width="600" height="1800" fill="url(#bg)"/>
|
||||
<rect width="600" height="1800" fill="url(#glow)"/>
|
||||
<rect width="600" height="1800" fill="url(#stars)" opacity="0.5"/>
|
||||
|
||||
<path d="M-40 360 C120 260 260 440 420 340 C520 280 620 320 700 260" fill="none" stroke="#7ff8ff" stroke-width="10" opacity="0.35"/>
|
||||
<path d="M-60 520 C140 420 240 610 420 510 C540 444 640 520 720 440" fill="none" stroke="#ff4fd8" stroke-width="12" opacity="0.22"/>
|
||||
|
||||
<text x="300" y="120" text-anchor="middle" font-family="ui-sans-serif, system-ui" font-size="34" fill="#ffffff" opacity="0.85" letter-spacing="6">AFTERDARK</text>
|
||||
<text x="300" y="160" text-anchor="middle" font-family="ui-sans-serif, system-ui" font-size="16" fill="#c9c2ff" opacity="0.9" letter-spacing="4">LUMI PHOTO STRIP</text>
|
||||
|
||||
${slotGuidesSvg({ stroke: 'rgba(255,255,255,0.22)' })}
|
||||
|
||||
<rect x="90" y="1670" width="420" height="70" rx="24" fill="rgba(255,255,255,0.06)" stroke="rgba(255,255,255,0.2)"/>
|
||||
<text x="300" y="1716" text-anchor="middle" font-family="ui-monospace, SFMono-Regular" font-size="18" fill="#ffffff" opacity="0.65">SAY CHEESE • 3 SHOTS</text>
|
||||
</svg>
|
||||
`)
|
||||
|
||||
const SAKURA_WAVE = svgToDataUri(`
|
||||
<svg width="600" height="1800" viewBox="0 0 600 1800" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#fff3f8"/>
|
||||
<stop offset="0.55" stop-color="#f7f1ff"/>
|
||||
<stop offset="1" stop-color="#e7f7ff"/>
|
||||
</linearGradient>
|
||||
<pattern id="dots" width="26" height="26" patternUnits="userSpaceOnUse">
|
||||
<circle cx="4" cy="4" r="2" fill="#ff7aa2" opacity="0.25"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
|
||||
<rect width="600" height="1800" fill="url(#bg)"/>
|
||||
<rect width="600" height="1800" fill="url(#dots)" opacity="0.6"/>
|
||||
|
||||
<path d="M0 240 C120 320 240 150 360 230 C470 304 540 260 600 230 L600 0 L0 0 Z" fill="#ffb3cc" opacity="0.55"/>
|
||||
<path d="M0 300 C140 380 260 220 380 300 C480 365 560 330 600 300" fill="none" stroke="#ff7aa2" stroke-width="10" opacity="0.35"/>
|
||||
|
||||
<path d="M0 1600 C120 1520 220 1700 340 1610 C460 1520 530 1620 600 1550 L600 1800 L0 1800 Z" fill="#bfe7ff" opacity="0.55"/>
|
||||
<path d="M40 1540 C160 1460 250 1610 360 1520 C470 1450 540 1500 560 1485" fill="none" stroke="#7ab7ff" stroke-width="10" opacity="0.35"/>
|
||||
|
||||
<text x="300" y="140" text-anchor="middle" font-family="ui-sans-serif, system-ui" font-size="30" fill="#7c3b54" opacity="0.9" letter-spacing="4">SAKURA WAVE</text>
|
||||
|
||||
${slotGuidesSvg({ stroke: 'rgba(124,59,84,0.28)', dash: '8 8' })}
|
||||
|
||||
<g opacity="0.55">
|
||||
<path d="M86 330 C96 312 122 312 132 330 C122 348 96 348 86 330 Z" fill="#ff7aa2"/>
|
||||
<path d="M132 330 C150 320 160 340 146 352 C134 352 126 340 132 330 Z" fill="#ff9bb8"/>
|
||||
<path d="M86 330 C70 320 62 342 76 352 C88 352 98 340 86 330 Z" fill="#ff9bb8"/>
|
||||
</g>
|
||||
</svg>
|
||||
`)
|
||||
|
||||
const RETRO_FILM = svgToDataUri(`
|
||||
<svg width="600" height="1800" viewBox="0 0 600 1800" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="paper" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#fff8ea"/>
|
||||
<stop offset="1" stop-color="#fff0d6"/>
|
||||
</linearGradient>
|
||||
<pattern id="grain" width="90" height="90" patternUnits="userSpaceOnUse">
|
||||
<circle cx="16" cy="20" r="1.8" fill="#000" opacity="0.06"/>
|
||||
<circle cx="52" cy="46" r="1.3" fill="#000" opacity="0.05"/>
|
||||
<circle cx="78" cy="18" r="1.2" fill="#000" opacity="0.04"/>
|
||||
<circle cx="32" cy="76" r="1.6" fill="#000" opacity="0.045"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
|
||||
<rect width="600" height="1800" fill="url(#paper)"/>
|
||||
<rect width="600" height="1800" fill="url(#grain)"/>
|
||||
|
||||
<!-- film rails -->
|
||||
<rect x="34" y="0" width="80" height="1800" rx="24" fill="#1b1b1b" opacity="0.92"/>
|
||||
<rect x="486" y="0" width="80" height="1800" rx="24" fill="#1b1b1b" opacity="0.92"/>
|
||||
|
||||
<!-- sprockets -->
|
||||
<g fill="#fff" opacity="0.75">
|
||||
${Array.from({ length: 18 })
|
||||
.map((_, i) => {
|
||||
const y = 70 + i * 96
|
||||
return `<rect x="58" y="${y}" width="32" height="54" rx="10"/><rect x="510" y="${y}" width="32" height="54" rx="10"/>`
|
||||
})
|
||||
.join('')}
|
||||
</g>
|
||||
|
||||
<text x="300" y="128" text-anchor="middle" font-family="ui-monospace, SFMono-Regular" font-size="22" fill="#1b1b1b" opacity="0.75" letter-spacing="4">RETRO FILM</text>
|
||||
<text x="300" y="160" text-anchor="middle" font-family="ui-monospace, SFMono-Regular" font-size="14" fill="#1b1b1b" opacity="0.55" letter-spacing="3">FRAME 03 • TAKE 01</text>
|
||||
|
||||
${slotGuidesSvg({ stroke: 'rgba(27,27,27,0.22)', dash: '12 8' })}
|
||||
|
||||
<path d="M120 1705 H480" stroke="#1b1b1b" stroke-width="3" opacity="0.25"/>
|
||||
<text x="300" y="1740" text-anchor="middle" font-family="ui-monospace, SFMono-Regular" font-size="14" fill="#1b1b1b" opacity="0.6">LUMI LAB • DEVELOPED DIGITALLY</text>
|
||||
</svg>
|
||||
`)
|
||||
|
||||
const COMIC_POP = svgToDataUri(`
|
||||
<svg width="600" height="1800" viewBox="0 0 600 1800" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#fff3a7"/>
|
||||
<stop offset="1" stop-color="#ffd1e8"/>
|
||||
</linearGradient>
|
||||
<pattern id="halftone" width="18" height="18" patternUnits="userSpaceOnUse">
|
||||
<circle cx="4" cy="4" r="3" fill="#000" opacity="0.07"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
|
||||
<rect width="600" height="1800" fill="url(#bg)"/>
|
||||
<rect width="600" height="1800" fill="url(#halftone)"/>
|
||||
|
||||
<path d="M0 220 L600 80" stroke="#111" stroke-width="8" opacity="0.14"/>
|
||||
<path d="M0 360 L600 220" stroke="#111" stroke-width="8" opacity="0.12"/>
|
||||
<path d="M0 500 L600 360" stroke="#111" stroke-width="8" opacity="0.1"/>
|
||||
|
||||
<g>
|
||||
<path d="M90 90 H390 Q420 90 432 112 L470 176 Q482 198 462 208 H140 Q112 208 104 184 L78 120 Q70 98 90 90 Z" fill="#ffffff" opacity="0.9" stroke="#111" stroke-width="6"/>
|
||||
<text x="270" y="160" text-anchor="middle" font-family="ui-sans-serif, system-ui" font-size="34" fill="#111" letter-spacing="3">SNAP!</text>
|
||||
</g>
|
||||
|
||||
${slotGuidesSvg({ stroke: 'rgba(17,17,17,0.2)', dash: '6 10' })}
|
||||
|
||||
<text x="300" y="1740" text-anchor="middle" font-family="ui-sans-serif, system-ui" font-size="18" fill="#111" opacity="0.6" letter-spacing="3">THREE PANEL STORY</text>
|
||||
</svg>
|
||||
`)
|
||||
|
||||
const KRAFT_MINIMAL = svgToDataUri(`
|
||||
<svg width="600" height="1800" viewBox="0 0 600 1800" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="kraft" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#d6b892"/>
|
||||
<stop offset="1" stop-color="#caa87a"/>
|
||||
</linearGradient>
|
||||
<pattern id="fibers" width="80" height="80" patternUnits="userSpaceOnUse">
|
||||
<path d="M6 16 C24 8 32 28 52 18" stroke="#000" stroke-width="2" opacity="0.05" fill="none"/>
|
||||
<path d="M10 52 C22 44 38 66 62 56" stroke="#000" stroke-width="2" opacity="0.05" fill="none"/>
|
||||
<circle cx="62" cy="18" r="2" fill="#000" opacity="0.05"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
|
||||
<rect width="600" height="1800" fill="url(#kraft)"/>
|
||||
<rect width="600" height="1800" fill="url(#fibers)"/>
|
||||
|
||||
<text x="300" y="130" text-anchor="middle" font-family="ui-sans-serif, system-ui" font-size="28" fill="#3a2a1d" opacity="0.8" letter-spacing="6">KRAFT</text>
|
||||
<text x="300" y="164" text-anchor="middle" font-family="ui-sans-serif, system-ui" font-size="14" fill="#3a2a1d" opacity="0.55" letter-spacing="4">MINIMAL STRIP</text>
|
||||
|
||||
${slotGuidesSvg({ stroke: 'rgba(58,42,29,0.22)', dash: '14 10' })}
|
||||
|
||||
<rect x="86" y="1540" width="428" height="190" rx="26" fill="rgba(255,255,255,0.35)" stroke="rgba(58,42,29,0.18)" stroke-width="3"/>
|
||||
<text x="110" y="1600" font-family="ui-monospace, SFMono-Regular" font-size="14" fill="#3a2a1d" opacity="0.65">NOTES:</text>
|
||||
<path d="M110 1630 H490" stroke="#3a2a1d" opacity="0.22" stroke-width="3"/>
|
||||
<path d="M110 1668 H490" stroke="#3a2a1d" opacity="0.18" stroke-width="3"/>
|
||||
<path d="M110 1706 H490" stroke="#3a2a1d" opacity="0.14" stroke-width="3"/>
|
||||
</svg>
|
||||
`)
|
||||
|
||||
const CINEMA_TICKET = svgToDataUri(`
|
||||
<svg width="600" height="1800" viewBox="0 0 600 1800" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#111018"/>
|
||||
<stop offset="1" stop-color="#22122a"/>
|
||||
</linearGradient>
|
||||
<pattern id="specks" width="70" height="70" patternUnits="userSpaceOnUse">
|
||||
<circle cx="10" cy="18" r="1.4" fill="#fff" opacity="0.10"/>
|
||||
<circle cx="44" cy="40" r="1.8" fill="#fff" opacity="0.08"/>
|
||||
<circle cx="60" cy="16" r="1.2" fill="#fff" opacity="0.07"/>
|
||||
<circle cx="22" cy="58" r="1.5" fill="#fff" opacity="0.06"/>
|
||||
</pattern>
|
||||
</defs>
|
||||
|
||||
<rect width="600" height="1800" fill="url(#bg)"/>
|
||||
<rect width="600" height="1800" fill="url(#specks)"/>
|
||||
|
||||
<text x="300" y="128" text-anchor="middle" font-family="ui-monospace, SFMono-Regular" font-size="24" fill="#f7d26a" opacity="0.9" letter-spacing="6">CINEMA TICKET</text>
|
||||
<text x="300" y="162" text-anchor="middle" font-family="ui-monospace, SFMono-Regular" font-size="12" fill="#ffffff" opacity="0.55" letter-spacing="5">ADMIT ONE • 3 PHOTOS</text>
|
||||
|
||||
${slotGuidesSvg({ stroke: 'rgba(247,210,106,0.28)', dash: '10 12' })}
|
||||
|
||||
<!-- perforation vibe -->
|
||||
<g opacity="0.35" fill="#f7d26a">
|
||||
${Array.from({ length: 30 })
|
||||
.map((_, i) => {
|
||||
const y = 220 + i * 48
|
||||
return `<circle cx="34" cy="${y}" r="5"/><circle cx="566" cy="${y}" r="5"/>`
|
||||
})
|
||||
.join('')}
|
||||
</g>
|
||||
|
||||
<rect x="110" y="1670" width="380" height="86" rx="24" fill="rgba(255,255,255,0.06)" stroke="rgba(247,210,106,0.25)" stroke-width="2"/>
|
||||
<text x="300" y="1722" text-anchor="middle" font-family="ui-monospace, SFMono-Regular" font-size="16" fill="#ffffff" opacity="0.72">SEAT: A-03 • SHOW: 19:30</text>
|
||||
</svg>
|
||||
`)
|
||||
|
||||
export const STRIP_THEMES = [
|
||||
{
|
||||
id: 'neon-night',
|
||||
label: 'Neon Night',
|
||||
image: NEON_NIGHT,
|
||||
borderColor: '#ff4fd8',
|
||||
size: STRIP_BASE_SIZE,
|
||||
layout: 'stacked',
|
||||
slots: STACKED_SLOTS,
|
||||
},
|
||||
{
|
||||
id: 'sakura-wave',
|
||||
label: 'Sakura Wave',
|
||||
image: SAKURA_WAVE,
|
||||
borderColor: '#7c3b54',
|
||||
size: STRIP_BASE_SIZE,
|
||||
layout: 'stacked',
|
||||
slots: STACKED_SLOTS,
|
||||
},
|
||||
{
|
||||
id: 'retro-film',
|
||||
label: 'Retro Film',
|
||||
image: RETRO_FILM,
|
||||
borderColor: '#1b1b1b',
|
||||
size: STRIP_BASE_SIZE,
|
||||
layout: 'stacked',
|
||||
slots: STACKED_SLOTS,
|
||||
},
|
||||
{
|
||||
id: 'comic-pop',
|
||||
label: 'Comic Pop',
|
||||
image: COMIC_POP,
|
||||
borderColor: '#111111',
|
||||
size: STRIP_BASE_SIZE,
|
||||
layout: 'stacked',
|
||||
slots: STACKED_SLOTS,
|
||||
},
|
||||
{
|
||||
id: 'kraft-minimal',
|
||||
label: 'Kraft Minimal',
|
||||
image: KRAFT_MINIMAL,
|
||||
borderColor: '#3a2a1d',
|
||||
size: STRIP_BASE_SIZE,
|
||||
layout: 'stacked',
|
||||
slots: STACKED_SLOTS,
|
||||
},
|
||||
{
|
||||
id: 'cinema-ticket',
|
||||
label: 'Cinema Ticket',
|
||||
image: CINEMA_TICKET,
|
||||
borderColor: '#f7d26a',
|
||||
size: STRIP_BASE_SIZE,
|
||||
layout: 'stacked',
|
||||
slots: TICKET_SLOTS,
|
||||
},
|
||||
]
|
||||
|
||||
export const STRIP_THEME_MAP = Object.fromEntries(STRIP_THEMES.map((theme) => [theme.id, theme]))
|
||||
@@ -0,0 +1,14 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import { MotionPlugin } from '@vueuse/motion'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './plugins/router'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(MotionPlugin)
|
||||
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,269 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useDevicesList, useFullscreen, useWindowSize } from '@vueuse/core'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { usePhotoboothStore } from '../stores/photoboothStore'
|
||||
import CameraCapture from '../components/CameraCapture.vue'
|
||||
import CountdownTimer from '../components/CountdownTimer.vue'
|
||||
|
||||
const store = usePhotoboothStore()
|
||||
const router = useRouter()
|
||||
|
||||
const { width } = useWindowSize()
|
||||
const isCompact = computed(() => width.value < 860)
|
||||
|
||||
const cameraRef = ref(null)
|
||||
const stageRef = ref(null)
|
||||
const cameraError = ref('')
|
||||
|
||||
const { devices } = useDevicesList({ requestPermissions: true })
|
||||
const videoInputs = computed(() => devices.value.filter((device) => device.kind === 'videoinput'))
|
||||
const selectedDeviceId = ref('')
|
||||
|
||||
const {
|
||||
isSupported: fullscreenSupported,
|
||||
toggle: toggleFullscreen,
|
||||
isFullscreen,
|
||||
} = useFullscreen(stageRef)
|
||||
|
||||
const countdownActive = ref(false)
|
||||
const isRunning = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
store.setStep(1)
|
||||
})
|
||||
|
||||
function setCameraError(error) {
|
||||
cameraError.value = error?.message || 'Camera access failed.'
|
||||
}
|
||||
|
||||
function pickDefaultDevice() {
|
||||
if (selectedDeviceId.value || videoInputs.value.length === 0) return
|
||||
selectedDeviceId.value = videoInputs.value[0].deviceId
|
||||
}
|
||||
|
||||
watch(videoInputs, pickDefaultDevice, { immediate: true })
|
||||
|
||||
async function startSequence() {
|
||||
if (isRunning.value) return
|
||||
|
||||
store.resetSession()
|
||||
store.setSessionActive(true)
|
||||
isRunning.value = true
|
||||
countdownActive.value = true
|
||||
}
|
||||
|
||||
function retake() {
|
||||
store.resetSession()
|
||||
countdownActive.value = false
|
||||
isRunning.value = false
|
||||
}
|
||||
|
||||
async function handleCountdownFinished() {
|
||||
countdownActive.value = false
|
||||
|
||||
const snapshot = cameraRef.value?.takeSnapshot()
|
||||
if (snapshot) {
|
||||
store.addPhoto(snapshot)
|
||||
}
|
||||
|
||||
if (store.photos.length < 3) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 700))
|
||||
countdownActive.value = true
|
||||
return
|
||||
}
|
||||
|
||||
isRunning.value = false
|
||||
}
|
||||
|
||||
function goNext() {
|
||||
if (store.photos.length < 3) return
|
||||
router.push('/customize')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="capture" :class="{ compact: isCompact }">
|
||||
<section class="capture-panel">
|
||||
<div class="panel-header">
|
||||
<div>
|
||||
<h1>Capture your shots</h1>
|
||||
<p>Get ready for three timed captures.</p>
|
||||
</div>
|
||||
<button v-if="fullscreenSupported" class="ghost-button" @click="toggleFullscreen">
|
||||
{{ isFullscreen ? 'Exit fullscreen' : 'Go fullscreen' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="camera-stage" ref="stageRef">
|
||||
<CameraCapture ref="cameraRef" :device-id="selectedDeviceId" @error="setCameraError" />
|
||||
<CountdownTimer :active="countdownActive" @finished="handleCountdownFinished" />
|
||||
</div>
|
||||
|
||||
<div class="capture-controls">
|
||||
<div class="device-select">
|
||||
<label for="camera">Camera</label>
|
||||
<select id="camera" v-model="selectedDeviceId" @change="pickDefaultDevice">
|
||||
<option v-for="device in videoInputs" :key="device.deviceId" :value="device.deviceId">
|
||||
{{ device.label || 'Camera' }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="buttons">
|
||||
<button class="primary" @click="startSequence">Start countdown</button>
|
||||
<button class="ghost-button" @click="retake">Retake</button>
|
||||
<button class="primary" :class="{ disabled: store.photos.length < 3 }" @click="goNext">
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="cameraError" class="error">{{ cameraError }}</p>
|
||||
</section>
|
||||
|
||||
<section class="thumb-panel">
|
||||
<h2>Shots</h2>
|
||||
<div class="thumbs">
|
||||
<div v-for="index in 3" :key="index" class="thumb">
|
||||
<img v-if="store.photos[index - 1]" :src="store.photos[index - 1].preview || store.photos[index - 1].src || store.photos[index - 1]" alt="Captured" />
|
||||
<span v-else>Shot {{ index }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.capture {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
.capture.compact {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.capture-panel {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
padding: 28px;
|
||||
border-radius: 32px;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
box-shadow: var(--soft-shadow);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.panel-header h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.panel-header p {
|
||||
margin: 6px 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.camera-stage {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.capture-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.device-select {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.device-select select {
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(200, 90, 124, 0.3);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.primary,
|
||||
.ghost-button {
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 10px 20px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.primary {
|
||||
background: #c85a7c;
|
||||
color: #fff;
|
||||
box-shadow: 0 12px 24px rgba(200, 90, 124, 0.25);
|
||||
}
|
||||
|
||||
.primary.disabled {
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.ghost-button {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
border: 1px solid rgba(200, 90, 124, 0.3);
|
||||
color: #7c3b54;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
color: #a13b56;
|
||||
}
|
||||
|
||||
.thumb-panel {
|
||||
padding: 24px;
|
||||
border-radius: 28px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
box-shadow: var(--soft-shadow);
|
||||
}
|
||||
|
||||
.thumb-panel h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.thumbs {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
width: 100%;
|
||||
aspect-ratio: 4 / 3;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 236, 243, 0.7);
|
||||
border: 2px dashed rgba(200, 90, 124, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--muted);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,156 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useWindowSize } from '@vueuse/core'
|
||||
import { usePhotoboothStore } from '../stores/photoboothStore'
|
||||
import { DEFAULT_STRIP_THEME_ID, STRIP_THEME_MAP } from '../data/stripThemes'
|
||||
import PhotoStrip from '../components/PhotoStrip.vue'
|
||||
import ThemeSelector from '../components/ThemeSelector.vue'
|
||||
import ImageEditor from '../components/ImageEditor.vue'
|
||||
|
||||
const store = usePhotoboothStore()
|
||||
const { width } = useWindowSize()
|
||||
|
||||
const stripSize = computed(() => {
|
||||
const theme = STRIP_THEME_MAP[store.stripTheme] || STRIP_THEME_MAP[DEFAULT_STRIP_THEME_ID]
|
||||
return theme.size
|
||||
})
|
||||
|
||||
const stripScale = computed(() =>
|
||||
Math.min(1, Math.max(0.45, (width.value - 48) / stripSize.value.width)),
|
||||
)
|
||||
const stripReady = computed(() => store.photos.length >= 3)
|
||||
|
||||
// local preview copy (non-persistent) used for manual placement prototype
|
||||
const previewPhotos = ref([...store.photos])
|
||||
const editorIndex = ref(-1)
|
||||
|
||||
watch(
|
||||
() => store.photos,
|
||||
(v) => {
|
||||
previewPhotos.value = [...v]
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
store.setStep(2)
|
||||
})
|
||||
|
||||
function handleCancel() {
|
||||
editorIndex.value = -1
|
||||
}
|
||||
|
||||
function handleApply(payload) {
|
||||
// payload: { dataUrl, transform }
|
||||
const { dataUrl, transform } = payload
|
||||
if (editorIndex.value >= 0) {
|
||||
const original = (store.photos[editorIndex.value] && (store.photos[editorIndex.value].src || store.photos[editorIndex.value])) || (previewPhotos.value[editorIndex.value] && (previewPhotos.value[editorIndex.value].src || previewPhotos.value[editorIndex.value])) || null
|
||||
const obj = { src: original, preview: dataUrl, transform }
|
||||
previewPhotos.value[editorIndex.value] = obj
|
||||
// persist to store as well
|
||||
if (store.photos[editorIndex.value]) {
|
||||
store.photos[editorIndex.value] = obj
|
||||
}
|
||||
}
|
||||
editorIndex.value = -1
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="customize">
|
||||
<section class="strip-panel">
|
||||
<div class="strip-wrapper" :style="{ transform: `scale(${stripScale})` }">
|
||||
<PhotoStrip
|
||||
v-motion
|
||||
:initial="{ opacity: 0, y: 40 }"
|
||||
:enter="{ opacity: 1, y: 0, transition: { duration: 0.5 } }"
|
||||
:photos="previewPhotos"
|
||||
:theme="store.stripTheme"
|
||||
/>
|
||||
</div>
|
||||
<div class="edit-thumbs">
|
||||
<h3>Edit photos</h3>
|
||||
<div class="thumbs-row">
|
||||
<div v-for="(p, i) in previewPhotos" :key="i" class="edit-thumb">
|
||||
<img v-if="p" :src="p.preview || p.src || p" />
|
||||
<button @click="editorIndex = i">Edit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!stripReady" class="hint">Capture three shots to fill the strip.</p>
|
||||
</section>
|
||||
|
||||
<section class="options-panel">
|
||||
<ThemeSelector />
|
||||
<RouterLink class="primary" to="/result">Generate strip</RouterLink>
|
||||
</section>
|
||||
<ImageEditor v-if="editorIndex >= 0" :src="(store.photos[editorIndex] && (store.photos[editorIndex].src || store.photos[editorIndex])) || previewPhotos[editorIndex]" :initialTransform="(store.photos[editorIndex] && store.photos[editorIndex].transform) || null" :outputWidth="440" :outputHeight="290" @cancel="handleCancel" @apply="handleApply" />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.customize {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
|
||||
gap: 28px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.strip-panel {
|
||||
padding: 28px;
|
||||
border-radius: 32px;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
box-shadow: var(--soft-shadow);
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.strip-wrapper {
|
||||
transform-origin: top center;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.edit-thumbs {
|
||||
width: 100%;
|
||||
}
|
||||
.thumbs-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.edit-thumb {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.edit-thumb img { width: 84px; height: 64px; object-fit: cover; border-radius: 8px }
|
||||
|
||||
.options-panel {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.primary {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 12px 20px;
|
||||
border-radius: 999px;
|
||||
background: #c85a7c;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 16px 30px rgba(200, 90, 124, 0.3);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.customize {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,505 @@
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useWindowSize } from '@vueuse/core'
|
||||
import { usePhotoboothStore } from '../stores/photoboothStore'
|
||||
import { STRIP_THEMES } from '../data/stripThemes'
|
||||
|
||||
const store = usePhotoboothStore()
|
||||
const { width } = useWindowSize()
|
||||
|
||||
const isCompact = computed(() => width.value < 960)
|
||||
const activeTheme = computed(() => store.stripTheme)
|
||||
|
||||
function selectTheme(themeId) {
|
||||
store.setTheme(themeId)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
store.setStep(0)
|
||||
store.setSessionActive(false)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="home">
|
||||
<section class="hero-grid" :class="{ compact: isCompact }">
|
||||
<div class="hero-copy">
|
||||
<p class="eyebrow">Aesthetic keepsakes</p>
|
||||
<h1
|
||||
v-motion
|
||||
:initial="{ opacity: 0, y: 40 }"
|
||||
:enter="{ opacity: 1, y: 0, transition: { duration: 0.6 } }"
|
||||
>
|
||||
Capture three dreamy snaps and print a cute strip.
|
||||
</h1>
|
||||
<p class="sub">
|
||||
Transform spontaneous moments into nostalgic digital memories with a soft glow and pastel
|
||||
charm.
|
||||
</p>
|
||||
<div class="cta-row">
|
||||
<RouterLink class="primary-cta" to="/capture">Start Photobooth</RouterLink>
|
||||
<button class="ghost-cta" type="button">View Samples</button>
|
||||
</div>
|
||||
<div class="mini-steps">
|
||||
<span>01 Choose theme</span>
|
||||
<span>02 Snap three shots</span>
|
||||
<span>03 Save the strip</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside
|
||||
class="theme-card"
|
||||
v-motion
|
||||
:initial="{ opacity: 0, y: 30 }"
|
||||
:enter="{ opacity: 1, y: 0 }"
|
||||
>
|
||||
<div class="theme-card-header">
|
||||
<h2>Select Strip Theme</h2>
|
||||
<p>Choose your signature look for the session.</p>
|
||||
</div>
|
||||
<div class="theme-list">
|
||||
<button
|
||||
v-for="theme in STRIP_THEMES"
|
||||
:key="theme.id"
|
||||
class="theme-option"
|
||||
:class="{ active: activeTheme === theme.id }"
|
||||
@click="selectTheme(theme.id)"
|
||||
>
|
||||
<span class="tone" :style="{ backgroundImage: `url('${theme.image}')` }"></span>
|
||||
<span class="theme-text">
|
||||
<strong>{{ theme.label }}</strong>
|
||||
<small>Signature strip</small>
|
||||
</span>
|
||||
<span class="radio"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="signature">
|
||||
<span>Lumi Signature</span>
|
||||
<button class="signature-pill" type="button">Tokyo Studio • 2024</button>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<section class="works">
|
||||
<div class="section-title">
|
||||
<h2>How Lumi Works</h2>
|
||||
<p>Three simple steps to create your perfect digital souvenir.</p>
|
||||
</div>
|
||||
<div class="works-grid">
|
||||
<article class="work-card">
|
||||
<div class="icon blush">😊</div>
|
||||
<h3>Smile</h3>
|
||||
<p>Get into position and let your personality shine through the lens.</p>
|
||||
</article>
|
||||
<article class="work-card">
|
||||
<div class="icon peach">📷</div>
|
||||
<h3>Snap</h3>
|
||||
<p>Three high-fidelity shots with gentle lighting and soft-focus styling.</p>
|
||||
</article>
|
||||
<article class="work-card">
|
||||
<div class="icon blue">☁️</div>
|
||||
<h3>Save</h3>
|
||||
<p>Instantly receive your digital strip ready for sharing or printing.</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="aesthetic">
|
||||
<div class="section-title inline">
|
||||
<div>
|
||||
<h2>Choose Your Aesthetic</h2>
|
||||
<p>From Y2K nostalgia to clean minimal vibes, find the theme that fits your mood.</p>
|
||||
</div>
|
||||
<div class="slider-controls">
|
||||
<button class="circle-btn" type="button">‹</button>
|
||||
<button class="circle-btn" type="button">›</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="aesthetic-grid">
|
||||
<article class="aesthetic-card pastel">
|
||||
<div class="card-image"></div>
|
||||
<h3>Cotton Candy</h3>
|
||||
<span>Minimalist</span>
|
||||
</article>
|
||||
<article class="aesthetic-card noir">
|
||||
<div class="card-image"></div>
|
||||
<h3>Midnight Film</h3>
|
||||
<span>Dramatic</span>
|
||||
</article>
|
||||
<article class="aesthetic-card retro">
|
||||
<div class="card-image"></div>
|
||||
<h3>Sugar Pop</h3>
|
||||
<span>Y2K Retro</span>
|
||||
</article>
|
||||
<article class="aesthetic-card glow">
|
||||
<div class="card-image"></div>
|
||||
<h3>Golden Hour</h3>
|
||||
<span>Elegant</span>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<RouterLink class="floating" to="/capture" aria-label="Start capture">📸</RouterLink>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home {
|
||||
display: grid;
|
||||
gap: 64px;
|
||||
}
|
||||
|
||||
.hero-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
|
||||
gap: 40px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hero-grid.compact {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.hero-copy {
|
||||
padding: 40px;
|
||||
border-radius: 36px;
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
box-shadow: var(--soft-shadow);
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.25em;
|
||||
font-size: 0.7rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.hero-copy h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(2.4rem, 4vw, 3.6rem);
|
||||
}
|
||||
|
||||
.sub {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.cta-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.primary-cta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 14px 30px;
|
||||
border-radius: 999px;
|
||||
background: #7c3b54;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 18px 40px rgba(124, 59, 84, 0.25);
|
||||
}
|
||||
|
||||
.ghost-cta {
|
||||
border: 1px solid rgba(124, 59, 84, 0.3);
|
||||
background: transparent;
|
||||
color: #7c3b54;
|
||||
padding: 12px 26px;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mini-steps {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
}
|
||||
|
||||
.theme-card {
|
||||
padding: 32px;
|
||||
border-radius: 36px;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
box-shadow: 0 22px 50px rgba(190, 120, 150, 0.2);
|
||||
border: 1px solid rgba(200, 90, 124, 0.2);
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.theme-card-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.theme-card-header p {
|
||||
margin: 6px 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.theme-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.theme-option {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr 20px;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(124, 59, 84, 0.2);
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.theme-option.active {
|
||||
border-color: rgba(124, 59, 84, 0.5);
|
||||
box-shadow: 0 12px 26px rgba(124, 59, 84, 0.16);
|
||||
}
|
||||
|
||||
.tone {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(124, 59, 84, 0.15);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.theme-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.theme-text strong {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.theme-text small {
|
||||
color: var(--muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.radio {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(124, 59, 84, 0.3);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.theme-option.active .radio::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 3px;
|
||||
border-radius: 50%;
|
||||
background: #7c3b54;
|
||||
}
|
||||
|
||||
.signature {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
border-radius: 18px;
|
||||
background: rgba(124, 59, 84, 0.08);
|
||||
color: #7c3b54;
|
||||
}
|
||||
|
||||
.signature-pill {
|
||||
border: none;
|
||||
background: rgba(124, 59, 84, 0.2);
|
||||
color: #7c3b54;
|
||||
padding: 8px 16px;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.works {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
text-align: center;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.section-title.inline {
|
||||
text-align: left;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.section-title h2 {
|
||||
margin: 0;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.section-title p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.works-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.work-card {
|
||||
padding: 26px;
|
||||
border-radius: 28px;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
box-shadow: 0 16px 36px rgba(200, 90, 124, 0.15);
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.work-card h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.work-card p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 18px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 1.4rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.icon.blush {
|
||||
background: #f5ccd9;
|
||||
}
|
||||
|
||||
.icon.peach {
|
||||
background: #f7d3c4;
|
||||
}
|
||||
|
||||
.icon.blue {
|
||||
background: #d7e7f6;
|
||||
}
|
||||
|
||||
.aesthetic {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.slider-controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.circle-btn {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(124, 59, 84, 0.12);
|
||||
color: #7c3b54;
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.aesthetic-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.aesthetic-card {
|
||||
padding: 16px;
|
||||
border-radius: 24px;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
box-shadow: 0 18px 40px rgba(200, 90, 124, 0.12);
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.aesthetic-card h3 {
|
||||
margin: 0;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.aesthetic-card span {
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.card-image {
|
||||
height: 180px;
|
||||
border-radius: 18px;
|
||||
background: linear-gradient(145deg, #f6d1dd, #f9f0f4);
|
||||
}
|
||||
|
||||
.aesthetic-card.noir .card-image {
|
||||
background: linear-gradient(160deg, #2b2730, #6a646f);
|
||||
}
|
||||
|
||||
.aesthetic-card.retro .card-image {
|
||||
background: linear-gradient(160deg, #f3b6c7, #d782b4);
|
||||
}
|
||||
|
||||
.aesthetic-card.glow .card-image {
|
||||
background: linear-gradient(160deg, #f5c6a1, #f9e1ba);
|
||||
}
|
||||
|
||||
.floating {
|
||||
position: fixed;
|
||||
right: 32px;
|
||||
bottom: 32px;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
background: #7c3b54;
|
||||
color: #fff;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
box-shadow: 0 18px 40px rgba(124, 59, 84, 0.3);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.hero-copy,
|
||||
.theme-card {
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.section-title.inline {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,203 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
import { useClipboard, useShare, useWindowSize } from '@vueuse/core'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMotion } from '@vueuse/motion'
|
||||
import html2canvas from 'html2canvas'
|
||||
|
||||
import { usePhotoboothStore } from '../stores/photoboothStore'
|
||||
import { DEFAULT_STRIP_THEME_ID, STRIP_THEME_MAP } from '../data/stripThemes'
|
||||
import PhotoStrip from '../components/PhotoStrip.vue'
|
||||
|
||||
const store = usePhotoboothStore()
|
||||
const router = useRouter()
|
||||
const stripRef = ref(null)
|
||||
const wrapperRef = ref(null)
|
||||
const isExporting = ref(false)
|
||||
const stripDataUrl = ref('')
|
||||
const stripSize = computed(() => {
|
||||
const theme = STRIP_THEME_MAP[store.stripTheme] || STRIP_THEME_MAP[DEFAULT_STRIP_THEME_ID]
|
||||
return theme.size
|
||||
})
|
||||
|
||||
const { width } = useWindowSize()
|
||||
const { share, isSupported: shareSupported } = useShare()
|
||||
const { copy, copied } = useClipboard()
|
||||
|
||||
const stripScale = computed(() => {
|
||||
if (isExporting.value) return 1
|
||||
return Math.min(1, Math.max(0.45, (width.value - 48) / stripSize.value.width))
|
||||
})
|
||||
|
||||
useMotion(wrapperRef, {
|
||||
initial: { opacity: 0, y: 40, scale: 0.96 },
|
||||
enter: { opacity: 1, y: 0, scale: 1, transition: { type: 'spring', stiffness: 140 } },
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
store.setStep(3)
|
||||
})
|
||||
|
||||
async function renderStrip() {
|
||||
if (!stripRef.value) return ''
|
||||
|
||||
isExporting.value = true
|
||||
|
||||
await nextTick()
|
||||
|
||||
try {
|
||||
const canvas = await html2canvas(stripRef.value, {
|
||||
backgroundColor: null,
|
||||
useCORS: true,
|
||||
allowTaint: true,
|
||||
scale: 2,
|
||||
width: stripSize.value.width,
|
||||
height: stripSize.value.height,
|
||||
})
|
||||
|
||||
const dataUrl = canvas.toDataURL('image/png')
|
||||
stripDataUrl.value = dataUrl
|
||||
return dataUrl
|
||||
} finally {
|
||||
isExporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleShare() {
|
||||
const dataUrl = stripDataUrl.value || (await renderStrip())
|
||||
if (!dataUrl || !shareSupported.value) return
|
||||
|
||||
await share({
|
||||
title: 'Photobooth strip',
|
||||
text: 'Made with Luna Photobooth',
|
||||
url: dataUrl,
|
||||
})
|
||||
}
|
||||
|
||||
async function handleCopy() {
|
||||
const dataUrl = stripDataUrl.value || (await renderStrip())
|
||||
if (!dataUrl) return
|
||||
await copy(dataUrl)
|
||||
}
|
||||
|
||||
async function handleDownload() {
|
||||
const dataUrl = stripDataUrl.value || (await renderStrip())
|
||||
if (!dataUrl) return
|
||||
|
||||
const link = document.createElement('a')
|
||||
link.href = dataUrl
|
||||
link.download = 'photobooth-strip.png'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
}
|
||||
|
||||
function startOver() {
|
||||
store.resetSession()
|
||||
router.push('/')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="result">
|
||||
<section class="strip-panel">
|
||||
<div ref="wrapperRef" class="strip-wrapper" :style="{ transform: `scale(${stripScale})` }">
|
||||
<div ref="stripRef">
|
||||
<PhotoStrip :photos="store.photos" :theme="store.stripTheme" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="action-panel">
|
||||
<h1>Your strip is ready</h1>
|
||||
<p>Download, share, or copy your finished photobooth strip.</p>
|
||||
<div class="actions">
|
||||
<button class="primary" :disabled="isExporting" @click="handleDownload">
|
||||
Download strip
|
||||
</button>
|
||||
<button class="ghost-button" :disabled="!shareSupported" @click="handleShare">Share</button>
|
||||
<button class="ghost-button" @click="handleCopy">
|
||||
{{ copied ? 'Copied!' : 'Copy to clipboard' }}
|
||||
</button>
|
||||
<button class="ghost-button" @click="startOver">Start over</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.result {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
|
||||
gap: 28px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.strip-panel {
|
||||
padding: 28px;
|
||||
border-radius: 32px;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
box-shadow: var(--soft-shadow);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.strip-wrapper {
|
||||
transform-origin: top center;
|
||||
}
|
||||
|
||||
.action-panel {
|
||||
padding: 28px;
|
||||
border-radius: 32px;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
box-shadow: var(--soft-shadow);
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.action-panel h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.action-panel p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.primary {
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 12px 20px;
|
||||
background: #c85a7c;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 16px 30px rgba(200, 90, 124, 0.3);
|
||||
}
|
||||
|
||||
.primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ghost-button {
|
||||
border: 1px solid rgba(200, 90, 124, 0.3);
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
color: #7c3b54;
|
||||
padding: 10px 16px;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.result {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,38 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { usePhotoboothStore } from '../stores/photoboothStore'
|
||||
|
||||
import Home from '../pages/Home.vue'
|
||||
import Capture from '../pages/Capture.vue'
|
||||
import Customize from '../pages/Customize.vue'
|
||||
import Result from '../pages/Result.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{ path: '/', name: 'home', component: Home },
|
||||
{ path: '/capture', name: 'capture', component: Capture },
|
||||
{
|
||||
path: '/customize',
|
||||
name: 'customize',
|
||||
component: Customize,
|
||||
meta: { requiresPhotos: true },
|
||||
},
|
||||
{
|
||||
path: '/result',
|
||||
name: 'result',
|
||||
component: Result,
|
||||
meta: { requiresPhotos: true },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach((to) => {
|
||||
if (!to.meta?.requiresPhotos) return true
|
||||
|
||||
const store = usePhotoboothStore()
|
||||
if (store.photos.length >= 3) return true
|
||||
|
||||
return { name: 'home' }
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from '../plugins/router'
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useCounterStore = defineStore('counter', () => {
|
||||
const count = ref(0)
|
||||
const doubleCount = computed(() => count.value * 2)
|
||||
function increment() {
|
||||
count.value++
|
||||
}
|
||||
|
||||
return { count, doubleCount, increment }
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import { ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import { DEFAULT_STRIP_THEME_ID, STRIP_THEME_MAP } from '../data/stripThemes'
|
||||
|
||||
export const usePhotoboothStore = defineStore('photobooth', () => {
|
||||
// photos: array of objects { src: string, transform: { x,y,scale,rotate, outputWidth, outputHeight } | null }
|
||||
const photos = ref([])
|
||||
const currentStep = ref(0)
|
||||
const savedTheme = useLocalStorage('photobooth:theme', DEFAULT_STRIP_THEME_ID)
|
||||
if (!STRIP_THEME_MAP[savedTheme.value]) {
|
||||
savedTheme.value = DEFAULT_STRIP_THEME_ID
|
||||
}
|
||||
const stripTheme = ref(savedTheme.value)
|
||||
const sessionActive = ref(false)
|
||||
|
||||
function addPhoto(base64) {
|
||||
if (photos.value.length >= 3) return
|
||||
// store original `src`, optional `preview` for flattened edited image, and `transform` metadata
|
||||
photos.value.push({ src: base64, preview: null, transform: null })
|
||||
}
|
||||
|
||||
function resetSession() {
|
||||
photos.value = []
|
||||
currentStep.value = 0
|
||||
sessionActive.value = false
|
||||
}
|
||||
|
||||
function setTheme(theme) {
|
||||
if (!STRIP_THEME_MAP[theme]) return
|
||||
stripTheme.value = theme
|
||||
savedTheme.value = theme
|
||||
}
|
||||
|
||||
function setStep(step) {
|
||||
currentStep.value = step
|
||||
}
|
||||
|
||||
function setSessionActive(active) {
|
||||
sessionActive.value = active
|
||||
}
|
||||
|
||||
return {
|
||||
photos,
|
||||
currentStep,
|
||||
stripTheme,
|
||||
sessionActive,
|
||||
addPhoto,
|
||||
resetSession,
|
||||
setTheme,
|
||||
setStep,
|
||||
setSessionActive,
|
||||
}
|
||||
})
|
||||