first commit
@@ -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,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,
|
||||
}
|
||||
})
|
||||