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
View File
+39
View File
@@ -0,0 +1,39 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
.eslintcache
# Cypress
/cypress/videos/
/cypress/screenshots/
# Vitest
__screenshots__/
# Vite
*.timestamp-*-*.mjs
+6
View File
@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}
+6
View File
@@ -0,0 +1,6 @@
{
"recommendations": [
"Vue.volar",
"esbenp.prettier-vscode"
]
}
+38
View File
@@ -0,0 +1,38 @@
# photobooth
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
## Recommended Browser Setup
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
- Firefox:
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
## Customize configuration
See [Vite Configuration Reference](https://vite.dev/config/).
## Project Setup
```sh
yarn
```
### Compile and Hot-Reload for Development
```sh
yarn dev
```
### Compile and Minify for Production
```sh
yarn build
```
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>
+8
View File
@@ -0,0 +1,8 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}
+52
View File
@@ -0,0 +1,52 @@
Add-Type -AssemblyName PresentationCore,WindowsBase
$path = 'c:\Users\Administrator\Documents\PaaS\photobooth\src\assets\1.png'
$fs = [System.IO.File]::OpenRead($path)
$decoder = [System.Windows.Media.Imaging.BitmapDecoder]::Create(
$fs,
[System.Windows.Media.Imaging.BitmapCreateOptions]::None,
[System.Windows.Media.Imaging.BitmapCacheOption]::OnLoad
)
$frame = $decoder.Frames[0]
$w = $frame.PixelWidth
$h = $frame.PixelHeight
$stride = $w * 4
$bytes = New-Object byte[] ($stride * $h)
$frame.CopyPixels($bytes, $stride, 0)
$fs.Close()
Write-Host "=== Measuring 1.png ($w x $h) ==="
# Manually check specific pixels to find exact boundaries
Write-Host "`nSlot 1 - around Y=180:"
$y = 180
for ($x = 70; $x -le 520; $x += 50) {
$i = ($y * $stride) + ($x * 4)
$b = $bytes[$i]
$g = $bytes[$i + 1]
$r = $bytes[$i + 2]
$char = if ($r -gt 200 -and $g -gt 200 -and $b -gt 200) { "" } else { "." }
Write-Host " x=$x : $char RGB($r,$g,$b)"
}
Write-Host "`nSlot 2 - around Y=700:"
$y = 700
for ($x = 70; $x -le 520; $x += 50) {
$i = ($y * $stride) + ($x * 4)
$b = $bytes[$i]
$g = $bytes[$i + 1]
$r = $bytes[$i + 2]
$char = if ($r -gt 200 -and $g -gt 200 -and $b -gt 200) { "" } else { "." }
Write-Host " x=$x : $char RGB($r,$g,$b)"
}
Write-Host "`nSlot 3 - around Y=1200:"
$y = 1200
for ($x = 70; $x -le 520; $x += 50) {
$i = ($y * $stride) + ($x * 4)
$b = $bytes[$i]
$g = $bytes[$i + 1]
$r = $bytes[$i + 2]
$char = if ($r -gt 200 -and $g -gt 200 -and $b -gt 200) { "" } else { "." }
Write-Host " x=$x : $char RGB($r,$g,$b)"
}
+72
View File
@@ -0,0 +1,72 @@
Add-Type -AssemblyName PresentationCore,WindowsBase
$path = 'c:\Users\Administrator\Documents\PaaS\photobooth\src\assets\1.png'
$fs = [System.IO.File]::OpenRead($path)
$decoder = [System.Windows.Media.Imaging.BitmapDecoder]::Create(
$fs,
[System.Windows.Media.Imaging.BitmapCreateOptions]::None,
[System.Windows.Media.Imaging.BitmapCacheOption]::OnLoad
)
$frame = $decoder.Frames[0]
$w = $frame.PixelWidth
$h = $frame.PixelHeight
$stride = $w * 4
$bytes = New-Object byte[] ($stride * $h)
$frame.CopyPixels($bytes, $stride, 0)
$fs.Close()
Write-Host "=== Finding exact slot boundaries in 1.png ==="
# Slot 1: should be around Y 178-457
Write-Host "`n=== SLOT 1 ==="
$minX = 600; $maxX = 0; $minY = 1800; $maxY = 0
for ($y = 160; $y -lt 470; $y++) {
for ($x = 80; $x -lt 530; $x++) {
$i = ($y * $stride) + ($x * 4)
$b = $bytes[$i]; $g = $bytes[$i + 1]; $r = $bytes[$i + 2]
if ($r -gt 230 -and $g -gt 200 -and $b -gt 200) {
if ($x -lt $minX) { $minX = $x }
if ($x -gt $maxX) { $maxX = $x }
if ($y -lt $minY) { $minY = $y }
if ($y -gt $maxY) { $maxY = $y }
}
}
}
Write-Host "Bounds: X=$minX-$maxX (width=$([math]::abs($maxX - $minX))), Y=$minY-$maxY (height=$([math]::abs($maxY - $minY)))"
Write-Host "CSS: x: $minX, y: $minY, width: $([math]::abs($maxX - $minX)), height: $([math]::abs($maxY - $minY))"
# Slot 2: should be around Y 615-894
Write-Host "`n=== SLOT 2 ==="
$minX = 600; $maxX = 0; $minY = 1800; $maxY = 0
for ($y = 595; $y -lt 920; $y++) {
for ($x = 80; $x -lt 530; $x++) {
$i = ($y * $stride) + ($x * 4)
$b = $bytes[$i]; $g = $bytes[$i + 1]; $r = $bytes[$i + 2]
if ($r -gt 230 -and $g -gt 200 -and $b -gt 200) {
if ($x -lt $minX) { $minX = $x }
if ($x -gt $maxX) { $maxX = $x }
if ($y -lt $minY) { $minY = $y }
if ($y -gt $maxY) { $maxY = $y }
}
}
}
Write-Host "Bounds: X=$minX-$maxX (width=$([math]::abs($maxX - $minX))), Y=$minY-$maxY (height=$([math]::abs($maxY - $minY)))"
Write-Host "CSS: x: $minX, y: $minY, width: $([math]::abs($maxX - $minX)), height: $([math]::abs($maxY - $minY))"
# Slot 3: should be around Y 1052-1331
Write-Host "`n=== SLOT 3 ==="
$minX = 600; $maxX = 0; $minY = 1800; $maxY = 0
for ($y = 1030; $y -lt 1360; $y++) {
for ($x = 80; $x -lt 530; $x++) {
$i = ($y * $stride) + ($x * 4)
$b = $bytes[$i]; $g = $bytes[$i + 1]; $r = $bytes[$i + 2]
if ($r -gt 230 -and $g -gt 200 -and $b -gt 200) {
if ($x -lt $minX) { $minX = $x }
if ($x -gt $maxX) { $maxX = $x }
if ($y -lt $minY) { $minY = $y }
if ($y -gt $maxY) { $maxY = $y }
}
}
}
Write-Host "Bounds: X=$minX-$maxX (width=$([math]::abs($maxX - $minX))), Y=$minY-$maxY (height=$([math]::abs($maxY - $minY)))"
Write-Host "CSS: x: $minX, y: $minY, width: $([math]::abs($maxX - $minX)), height: $([math]::abs($maxY - $minY))"
+4674
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
{
"name": "photobooth",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"format": "prettier --write --experimental-cli src/"
},
"dependencies": {
"@vueuse/core": "^14.3.0",
"@vueuse/motion": "^3.0.3",
"gsap": "^3.15.0",
"html2canvas": "^1.4.1",
"pinia": "^3.0.4",
"vue": "^3.5.32",
"vue-router": "^4.5.1"
},
"optionalDependencies": {
"sharp": "^0.32.0",
"potrace": "^2.0.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.6",
"prettier": "3.8.3",
"vite": "^8.0.8",
"vite-plugin-vue-devtools": "^8.1.1"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
}
+3301
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
allowBuilds:
sharp: set this to true or false
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+28
View File
@@ -0,0 +1,28 @@
Add-Type -AssemblyName PresentationCore,WindowsBase
$path = 'c:\Users\Administrator\Documents\PaaS\photobooth\src\assets\1.png'
$fs = [System.IO.File]::OpenRead($path)
$decoder = [System.Windows.Media.Imaging.BitmapDecoder]::Create(
$fs,
[System.Windows.Media.Imaging.BitmapCreateOptions]::None,
[System.Windows.Media.Imaging.BitmapCacheOption]::OnLoad
)
$frame = $decoder.Frames[0]
$w = $frame.PixelWidth
$h = $frame.PixelHeight
$stride = $w * 4
$bytes = New-Object byte[] ($stride * $h)
$frame.CopyPixels($bytes, $stride, 0)
$fs.Close()
# Check what colors are actually in the second slot area
Write-Host "Sampling SLOT 2 area (Y 600-700, X 80-530):"
for ($y = 600; $y -le 700; $y += 25) {
$colors = @()
for ($x = 150; $x -le 450; $x += 50) {
$i = ($y * $stride) + ($x * 4)
$b = $bytes[$i]; $g = $bytes[$i + 1]; $r = $bytes[$i + 2]
$colors += "R=$r"
}
Write-Host "Y=$y : $($colors -join ' | ')"
}
+51
View File
@@ -0,0 +1,51 @@
Add-Type -AssemblyName PresentationCore,WindowsBase
$path = 'c:\Users\Administrator\Documents\PaaS\photobooth\src\assets\1.png'
$fs = [System.IO.File]::OpenRead($path)
$decoder = [System.Windows.Media.Imaging.BitmapDecoder]::Create(
$fs,
[System.Windows.Media.Imaging.BitmapCreateOptions]::None,
[System.Windows.Media.Imaging.BitmapCacheOption]::OnLoad
)
$frame = $decoder.Frames[0]
$w = $frame.PixelWidth
$h = $frame.PixelHeight
$stride = $w * 4
$bytes = New-Object byte[] ($stride * $h)
$frame.CopyPixels($bytes, $stride, 0)
$fs.Close()
# Sample first slot to see what color it has
Write-Host "=== Sampling actual photo slot colors ==="
Write-Host "`nSlot 1 (Y=250, various X):"
$y = 250
for ($x = 100; $x -le 500; $x += 50) {
$i = ($y * $stride) + ($x * 4)
$b = $bytes[$i]; $g = $bytes[$i + 1]; $r = $bytes[$i + 2]
$light = if ($r -gt 180 -and $g -gt 180 -and $b -gt 180) { "WHITE-ish" }
elseif ($b -gt ($r + 20) -and $b -gt ($g + 20)) { "BLUE-ish" }
else { "OTHER" }
Write-Host " x=$x: RGB($r, $g, $b) = $light"
}
Write-Host "`nSlot 2 (Y=700, various X):"
$y = 700
for ($x = 100; $x -le 500; $x += 50) {
$i = ($y * $stride) + ($x * 4)
$b = $bytes[$i]; $g = $bytes[$i + 1]; $r = $bytes[$i + 2]
$light = if ($r -gt 180 -and $g -gt 180 -and $b -gt 180) { "WHITE-ish" }
elseif ($b -gt ($r + 20) -and $b -gt ($g + 20)) { "BLUE-ish" }
else { "OTHER" }
Write-Host " x=$x: RGB($r, $g, $b) = $light"
}
Write-Host "`nSlot 3 (Y=1250, various X):"
$y = 1250
for ($x = 100; $x -le 500; $x += 50) {
$i = ($y * $stride) + ($x * 4)
$b = $bytes[$i]; $g = $bytes[$i + 1]; $r = $bytes[$i + 2]
$light = if ($r -gt 180 -and $g -gt 180 -and $b -gt 180) { "WHITE-ish" }
elseif ($b -gt ($r + 20) -and $b -gt ($g + 20)) { "BLUE-ish" }
else { "OTHER" }
Write-Host " x=$x: RGB($r, $g, $b) = $light"
}
+116
View File
@@ -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>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 349 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 473 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 334 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 412 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 644 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 163 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

+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>
File diff suppressed because one or more lines are too long
+333
View File
@@ -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]))
+14
View File
@@ -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')
+269
View File
@@ -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>
+156
View File
@@ -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>
+505
View File
@@ -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>
+203
View File
@@ -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>
+38
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
export { default } from '../plugins/router'
+12
View File
@@ -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 }
})
+54
View File
@@ -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,
}
})
Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

+78
View File
@@ -0,0 +1,78 @@
import fs from 'fs'
import path from 'path'
import { trace } from 'potrace'
import sharp from 'sharp'
const assetsDir = path.resolve(process.cwd(), 'src', 'assets')
const outJson = path.resolve(process.cwd(), 'src', 'data', 'slotClipPaths.json')
// theme slot definitions to inspect (matching stripThemes.js layout)
const themesToTrace = {
pink: { file: '1.png', slots: [ { x:80,y:160,width:440,height:290 }, { x:80,y:615,width:440,height:290 }, { x:80,y:1070,width:440,height:290 } ] },
ticket: { file: '8.png', slots: [ { x:80,y:160,width:440,height:290 }, { x:80,y:615,width:440,height:290 }, { x:80,y:1070,width:440,height:290 } ] },
music: { file: '9.png', slots: [ { x:78,y:50,width:444,height:333 }, { x:78,y:545,width:444,height:333 }, { x:78,y:1040,width:444,height:333 } ] },
bowtie: { file: '12.png', slots: [ { x:77,y:160,width:380,height:271 }, { x:77,y:549,width:380,height:271 }, { x:77,y:939,width:380,height:271 } ] },
}
async function traceSlot(themeId, fileName, slot, index, options) {
const src = path.join(assetsDir, fileName)
const tmp = path.join(process.cwd(), 'tmp', `${themeId}-slot-${index}.png`)
await fs.promises.mkdir(path.dirname(tmp), { recursive: true })
// crop the slot area with a small padding to capture edges
const pad = 2
await sharp(src)
.extract({ left: Math.max(0, slot.x - pad), top: Math.max(0, slot.y - pad), width: slot.width + pad*2, height: slot.height + pad*2 })
.resize(slot.width, slot.height)
.toFile(tmp)
const traceOptions = Object.assign({ background: 'transparent' }, options || {})
return new Promise((resolve, reject) => {
trace(tmp, traceOptions, (err, svg) => {
if (err) return reject(err)
// extract path d attribute
const m = svg.match(/<path[^>]*d="([^"]+)"/)
const d = m ? m[1] : null
resolve(d)
})
})
}
async function main() {
const out = {}
// parse CLI args for tuning options: --turdSize=20 --threshold=128 --optTolerance=0.4
const argv = process.argv.slice(2)
const cli = {}
for (const a of argv) {
const parts = a.replace(/^--/, '').split('=')
cli[parts[0]] = parts[1] === undefined ? true : parts[1]
}
const options = {}
if (cli.turdSize) options.turdSize = parseInt(cli.turdSize, 10)
if (cli.threshold) options.threshold = parseInt(cli.threshold, 10)
if (cli.optTolerance) options.optTolerance = parseFloat(cli.optTolerance)
if (cli.turnPolicy) options.turnPolicy = cli.turnPolicy
if (cli.background) options.background = cli.background
console.log('Tracing with options:', options)
for (const [themeId, info] of Object.entries(themesToTrace)) {
const paths = []
for (let i = 0; i < info.slots.length; i++) {
try {
process.stdout.write(`Tracing ${themeId} slot ${i}... `)
const d = await traceSlot(themeId, info.file, info.slots[i], i, options)
paths.push(d)
console.log('ok')
} catch (err) {
console.error('failed', err.message)
paths.push(null)
}
}
out[themeId] = paths
}
await fs.promises.writeFile(outJson, JSON.stringify(out, null, 2), 'utf8')
console.log('Wrote', outJson)
}
main().catch(err => { console.error(err); process.exit(1) })
+18
View File
@@ -0,0 +1,18 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
})
+2280
View File
File diff suppressed because it is too large Load Diff