first commit

This commit is contained in:
2026-05-25 19:07:12 +07:00
commit f29c67bff4
62 changed files with 13196 additions and 0 deletions
+136
View File
@@ -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>
+74
View File
@@ -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>
+117
View File
@@ -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>
+187
View File
@@ -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>
+105
View File
@@ -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>
+64
View File
@@ -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>
+90
View File
@@ -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>