first commit
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
<script setup>
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useUserMedia } from '@vueuse/core'
|
||||
|
||||
const props = defineProps({
|
||||
deviceId: { type: String, default: '' },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['error'])
|
||||
|
||||
const videoRef = ref(null)
|
||||
|
||||
const errorMessage = ref('')
|
||||
|
||||
const { stream, start, restart, stop, constraints } = useUserMedia({
|
||||
enabled: false,
|
||||
autoSwitch: true,
|
||||
constraints: { video: { facingMode: 'user' }, audio: false },
|
||||
})
|
||||
|
||||
async function ensureStream() {
|
||||
try {
|
||||
if (stream.value) {
|
||||
await restart()
|
||||
} else {
|
||||
await start()
|
||||
}
|
||||
errorMessage.value = ''
|
||||
} catch (err) {
|
||||
errorMessage.value = err?.message || 'Camera access failed.'
|
||||
emit('error', err)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.deviceId,
|
||||
async (value) => {
|
||||
constraints.value = {
|
||||
video: value ? { deviceId: { exact: value } } : { facingMode: 'user' },
|
||||
audio: false,
|
||||
}
|
||||
await ensureStream()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(stream, (value) => {
|
||||
if (videoRef.value && value) {
|
||||
videoRef.value.srcObject = value
|
||||
videoRef.value.play().catch(() => {})
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stop()
|
||||
})
|
||||
|
||||
function takeSnapshot() {
|
||||
const video = videoRef.value
|
||||
if (!video) return null
|
||||
|
||||
const videoWidth = video.videoWidth || 1360
|
||||
const videoHeight = video.videoHeight || 768
|
||||
|
||||
// Crop to 3:2 aspect ratio (matches template photo slots: 440×290 pixels = 1.517:1)
|
||||
const targetRatio = 3 / 2
|
||||
const videoRatio = videoWidth / videoHeight
|
||||
|
||||
let cropWidth = videoWidth
|
||||
let cropHeight = videoHeight
|
||||
let cropX = 0
|
||||
let cropY = 0
|
||||
|
||||
if (videoRatio > targetRatio) {
|
||||
// Video is wider than target, crop width
|
||||
cropWidth = videoHeight * targetRatio
|
||||
cropX = (videoWidth - cropWidth) / 2
|
||||
} else {
|
||||
// Video is taller than target, crop height
|
||||
cropHeight = videoWidth / targetRatio
|
||||
cropY = (videoHeight - cropHeight) / 2
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = cropWidth
|
||||
canvas.height = cropHeight
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return null
|
||||
|
||||
ctx.drawImage(video, cropX, cropY, cropWidth, cropHeight, 0, 0, cropWidth, cropHeight)
|
||||
return canvas.toDataURL('image/png')
|
||||
}
|
||||
|
||||
defineExpose({ takeSnapshot })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="camera-shell">
|
||||
<video ref="videoRef" class="camera-video" autoplay playsinline muted></video>
|
||||
<div v-if="errorMessage" class="camera-fallback">
|
||||
<p>Camera access is blocked. Please allow permissions and refresh.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.camera-shell {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: 32px;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
box-shadow: 0 18px 50px rgba(203, 115, 140, 0.2);
|
||||
}
|
||||
|
||||
.camera-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transform: scaleX(-1);
|
||||
}
|
||||
|
||||
.camera-fallback {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
color: #7a3b4f;
|
||||
background: rgba(255, 235, 243, 0.92);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script setup>
|
||||
import { computed, watch } from 'vue'
|
||||
import { useInterval } from '@vueuse/core'
|
||||
|
||||
const props = defineProps({
|
||||
active: { type: Boolean, default: false },
|
||||
seconds: { type: Number, default: 3 },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['finished'])
|
||||
|
||||
const { counter, pause, resume, reset } = useInterval(1000, {
|
||||
controls: true,
|
||||
immediate: false,
|
||||
})
|
||||
|
||||
const remaining = computed(() => Math.max(props.seconds - counter.value, 0))
|
||||
|
||||
watch(
|
||||
() => props.active,
|
||||
(value) => {
|
||||
if (value) {
|
||||
reset()
|
||||
resume()
|
||||
return
|
||||
}
|
||||
|
||||
pause()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
watch(remaining, (value) => {
|
||||
if (!props.active) return
|
||||
|
||||
if (value === 0) {
|
||||
pause()
|
||||
emit('finished')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="active" class="countdown">
|
||||
<span
|
||||
v-motion
|
||||
:initial="{ scale: 0.6, opacity: 0 }"
|
||||
:enter="{ scale: 1, opacity: 1, transition: { type: 'spring', stiffness: 300, damping: 16 } }"
|
||||
:key="remaining"
|
||||
class="countdown-number"
|
||||
>
|
||||
{{ remaining }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.countdown {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 32px;
|
||||
background: rgba(255, 230, 239, 0.65);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.countdown-number {
|
||||
font-size: clamp(2.5rem, 6vw, 4.5rem);
|
||||
font-weight: 700;
|
||||
color: #8e2c4a;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,117 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import html2canvas from 'html2canvas'
|
||||
import { STRIP_BASE_SIZE } from '../data/stripThemes'
|
||||
|
||||
const props = defineProps({
|
||||
targetRef: { type: Object, default: null },
|
||||
filename: { type: String, default: 'photobooth-strip.png' },
|
||||
width: { type: Number, default: STRIP_BASE_SIZE.width },
|
||||
height: { type: Number, default: STRIP_BASE_SIZE.height },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['render-start', 'render-end', 'rendered'])
|
||||
|
||||
const isWorking = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const isReady = computed(() => Boolean(props.targetRef?.value))
|
||||
|
||||
function canvasToBlob(canvas) {
|
||||
return new Promise((resolve) => {
|
||||
canvas.toBlob((blob) => resolve(blob), 'image/png')
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDownload() {
|
||||
if (isWorking.value) return
|
||||
if (!props.targetRef?.value) {
|
||||
errorMessage.value = 'Strip not ready yet. Please try again in a moment.'
|
||||
return
|
||||
}
|
||||
|
||||
isWorking.value = true
|
||||
errorMessage.value = ''
|
||||
emit('render-start')
|
||||
|
||||
await nextTick()
|
||||
|
||||
try {
|
||||
const canvas = await html2canvas(props.targetRef.value, {
|
||||
backgroundColor: null,
|
||||
useCORS: true,
|
||||
allowTaint: true,
|
||||
scale: 2,
|
||||
width: props.width,
|
||||
height: props.height,
|
||||
})
|
||||
|
||||
const blob = await canvasToBlob(canvas)
|
||||
if (blob) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = props.filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
emit('rendered', canvas.toDataURL('image/png'))
|
||||
return
|
||||
}
|
||||
|
||||
const dataUrl = canvas.toDataURL('image/png')
|
||||
const link = document.createElement('a')
|
||||
link.href = dataUrl
|
||||
link.download = props.filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
emit('rendered', dataUrl)
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.message || 'Download failed. Please try again.'
|
||||
} finally {
|
||||
emit('render-end')
|
||||
isWorking.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="download-block">
|
||||
<button class="download-btn" :disabled="!isReady || isWorking" @click="handleDownload">
|
||||
<span v-motion :initial="{ scale: 1 }" :enter="{ scale: 1 }" :hovered="{ scale: 1.05 }">
|
||||
Download strip
|
||||
</span>
|
||||
</button>
|
||||
<p v-if="errorMessage" class="download-error">{{ errorMessage }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.download-block {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.download-btn {
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 12px 24px;
|
||||
background: #c85a7c;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 16px 30px rgba(200, 90, 124, 0.3);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.download-btn:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.download-error {
|
||||
margin: 0;
|
||||
color: #a13b56;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,187 @@
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
const props = defineProps({
|
||||
src: { type: String, required: true },
|
||||
outputWidth: { type: Number, default: 440 },
|
||||
outputHeight: { type: Number, default: 290 },
|
||||
initialTransform: { type: Object, default: null },
|
||||
})
|
||||
const emit = defineEmits(["apply", "cancel"]);
|
||||
|
||||
const imgRef = ref(null)
|
||||
const containerRef = ref(null)
|
||||
const pos = ref({ x: 0, y: 0 })
|
||||
const scale = ref(1)
|
||||
const rotating = ref(0)
|
||||
let dragging = false
|
||||
let last = { x: 0, y: 0 }
|
||||
|
||||
onMounted(() => {
|
||||
// initialize scale to fit image into viewport once loaded
|
||||
const img = imgRef.value
|
||||
img.onload = () => {
|
||||
const sw = props.outputWidth
|
||||
const sh = props.outputHeight
|
||||
if (props.initialTransform) {
|
||||
// rehydrate previous transform
|
||||
scale.value = props.initialTransform.scale || 1
|
||||
rotating.value = props.initialTransform.rotate || 0
|
||||
pos.value = { x: props.initialTransform.x || 0, y: props.initialTransform.y || 0 }
|
||||
// if transform was created with different output size, scale positions accordingly
|
||||
if (props.initialTransform.outputWidth && props.initialTransform.outputWidth !== sw) {
|
||||
const sx = sw / props.initialTransform.outputWidth
|
||||
const sy = sh / props.initialTransform.outputHeight
|
||||
pos.value.x = pos.value.x * sx
|
||||
pos.value.y = pos.value.y * sy
|
||||
scale.value = scale.value * ((sx + sy) / 2)
|
||||
}
|
||||
} else {
|
||||
const r = Math.max(sw / img.naturalWidth, sh / img.naturalHeight)
|
||||
scale.value = r
|
||||
pos.value = { x: (sw - img.naturalWidth * scale.value) / 2, y: (sh - img.naturalHeight * scale.value) / 2 }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
function onPointerDown(e) {
|
||||
dragging = true
|
||||
last = { x: e.clientX, y: e.clientY }
|
||||
window.addEventListener('pointermove', onPointerMove)
|
||||
window.addEventListener('pointerup', onPointerUp)
|
||||
}
|
||||
function onPointerMove(e) {
|
||||
if (!dragging) return
|
||||
const dx = e.clientX - last.x
|
||||
const dy = e.clientY - last.y
|
||||
pos.value.x += dx
|
||||
pos.value.y += dy
|
||||
last = { x: e.clientX, y: e.clientY }
|
||||
}
|
||||
function onPointerUp() {
|
||||
dragging = false
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
}
|
||||
|
||||
function onWheel(e) {
|
||||
e.preventDefault()
|
||||
const delta = -e.deltaY
|
||||
const factor = delta > 0 ? 1.08 : 0.92
|
||||
// zoom around mouse position
|
||||
const rect = containerRef.value.getBoundingClientRect()
|
||||
const mx = e.clientX - rect.left
|
||||
const my = e.clientY - rect.top
|
||||
const prevScale = scale.value
|
||||
scale.value = Math.max(0.2, Math.min(6, scale.value * factor))
|
||||
// adjust pos so point under cursor remains
|
||||
pos.value.x = mx - ((mx - pos.value.x) * (scale.value / prevScale))
|
||||
pos.value.y = my - ((my - pos.value.y) * (scale.value / prevScale))
|
||||
}
|
||||
|
||||
function presetFit() {
|
||||
const img = imgRef.value
|
||||
const sw = props.outputWidth
|
||||
const sh = props.outputHeight
|
||||
const r = Math.min(sw / img.naturalWidth, sh / img.naturalHeight)
|
||||
scale.value = r
|
||||
pos.value = { x: (sw - img.naturalWidth * r) / 2, y: (sh - img.naturalHeight * r) / 2 }
|
||||
}
|
||||
function presetFill() {
|
||||
const img = imgRef.value
|
||||
const sw = props.outputWidth
|
||||
const sh = props.outputHeight
|
||||
const r = Math.max(sw / img.naturalWidth, sh / img.naturalHeight)
|
||||
scale.value = r
|
||||
pos.value = { x: (sw - img.naturalWidth * r) / 2, y: (sh - img.naturalHeight * r) / 2 }
|
||||
}
|
||||
|
||||
async function apply() {
|
||||
const img = imgRef.value
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = props.outputWidth
|
||||
canvas.height = props.outputHeight
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.save()
|
||||
ctx.translate(pos.value.x, pos.value.y)
|
||||
ctx.rotate((rotating.value * Math.PI) / 180)
|
||||
ctx.scale(scale.value, scale.value)
|
||||
ctx.drawImage(img, 0, 0)
|
||||
ctx.restore()
|
||||
const dataUrl = canvas.toDataURL('image/png')
|
||||
|
||||
const transform = {
|
||||
x: pos.value.x,
|
||||
y: pos.value.y,
|
||||
scale: scale.value,
|
||||
rotate: rotating.value,
|
||||
outputWidth: props.outputWidth,
|
||||
outputHeight: props.outputHeight,
|
||||
naturalWidth: img.naturalWidth,
|
||||
naturalHeight: img.naturalHeight,
|
||||
}
|
||||
|
||||
emit('apply', { dataUrl, transform })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="editor-overlay">
|
||||
<div class="editor">
|
||||
<div class="viewport" :style="{ width: `${outputWidth}px`, height: `${outputHeight}px` }" ref="containerRef" @pointerdown="onPointerDown" @wheel.prevent="onWheel">
|
||||
<img :src="src" ref="imgRef" class="editable" :style="{ transform: `translate(${pos.x}px, ${pos.y}px) scale(${scale}) rotate(${rotating}deg)` }" />
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<div class="presets">
|
||||
<button @click="presetFit">Fit</button>
|
||||
<button @click="presetFill">Fill</button>
|
||||
<label>Rotate <input type="range" min="-180" max="180" v-model.number="rotating" /></label>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="ghost" @click="$emit('cancel')">Cancel</button>
|
||||
<button class="primary" @click="apply">Apply</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.editor-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 60;
|
||||
}
|
||||
.editor {
|
||||
background: #fff;
|
||||
padding: 18px;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 50px rgba(0,0,0,0.4);
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.viewport {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: #f3f3f3;
|
||||
}
|
||||
.editable {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.presets button { margin-right: 8px }
|
||||
.primary { background: #c85a7c; color: #fff; padding: 8px 14px; border: none; border-radius: 8px }
|
||||
.ghost { background: transparent; border: 1px solid #ddd; padding: 8px 14px; border-radius: 8px }
|
||||
</style>
|
||||
@@ -0,0 +1,105 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { DEFAULT_STRIP_THEME_ID, STRIP_THEME_MAP } from '../data/stripThemes'
|
||||
|
||||
const props = defineProps({
|
||||
photos: { type: Array, default: () => [] },
|
||||
theme: { type: String, default: DEFAULT_STRIP_THEME_ID },
|
||||
})
|
||||
|
||||
const themeConfig = computed(
|
||||
() => STRIP_THEME_MAP[props.theme] || STRIP_THEME_MAP[DEFAULT_STRIP_THEME_ID],
|
||||
)
|
||||
const borderColor = computed(() => themeConfig.value?.borderColor || '#ffffff')
|
||||
const stripShadow = computed(
|
||||
() => themeConfig.value?.shadow || '0 24px 60px rgba(0, 0, 0, 0.18)',
|
||||
)
|
||||
|
||||
const stripSize = computed(() => themeConfig.value.size)
|
||||
const frames = computed(() => themeConfig.value.slots || [])
|
||||
|
||||
function slotStyle(rect) {
|
||||
return {
|
||||
left: `${rect.x + 8}px`,
|
||||
top: `${rect.y + 8}px`,
|
||||
width: `${Math.max(rect.width - 16, 0)}px`,
|
||||
height: `${Math.max(rect.height - 16, 0)}px`,
|
||||
}
|
||||
}
|
||||
|
||||
function clipStyle(rect) {
|
||||
return {
|
||||
border: `8px solid ${borderColor.value}`,
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="strip-root"
|
||||
:style="{
|
||||
width: `${stripSize.width}px`,
|
||||
height: `${stripSize.height}px`,
|
||||
backgroundImage: `url('${themeConfig.image}')`,
|
||||
boxShadow: stripShadow,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
v-for="(rect, index) in frames"
|
||||
:key="`slot-${index}`"
|
||||
class="photo-slot"
|
||||
:style="slotStyle(rect)"
|
||||
>
|
||||
<template v-if="photos[index]">
|
||||
<template v-if="rect.radius || rect.clipPath">
|
||||
<svg :width="Math.max(rect.width - 16, 0)" :height="Math.max(rect.height - 16, 0)" :viewBox="`0 0 ${Math.max(rect.width - 16, 0)} ${Math.max(rect.height - 16, 0)}`" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<clipPath :id="`clip-${props.theme}-${index}`">
|
||||
<template v-if="rect.clipPath">
|
||||
<path :d="rect.clipPath" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<rect x="0" y="0" :width="Math.max(rect.width - 16, 0)" :height="Math.max(rect.height - 16, 0)" :rx="rect.radius" :ry="rect.radius" />
|
||||
</template>
|
||||
</clipPath>
|
||||
</defs>
|
||||
<image :href="(photos[index].preview !== undefined) ? (photos[index].preview || photos[index].src) : ((photos[index].src !== undefined) ? photos[index].src : photos[index])" x="0" y="0" :width="Math.max(rect.width - 16, 0)" :height="Math.max(rect.height - 16, 0)" :clip-path="`url(#clip-${props.theme}-${index})`" preserveAspectRatio="xMidYMid slice" />
|
||||
<template v-if="rect.clipPath">
|
||||
<path :d="rect.clipPath" fill="none" :stroke="borderColor" stroke-width="12" stroke-linejoin="round" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<rect x="6" y="6" :width="Math.max(rect.width - 28, 0)" :height="Math.max(rect.height - 28, 0)" :rx="Math.max((rect.radius || 0) - 2, 0)" :ry="Math.max((rect.radius || 0) - 2, 0)" fill="none" :stroke="borderColor" stroke-width="10" />
|
||||
</template>
|
||||
</svg>
|
||||
</template>
|
||||
<template v-else>
|
||||
<img :src="(photos[index].preview !== undefined) ? (photos[index].preview || photos[index].src) : ((photos[index].src !== undefined) ? photos[index].src : photos[index])" :alt="`Captured photo ${index + 1}`" class="frame-photo" :style="clipStyle(rect)" />
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.strip-root {
|
||||
position: relative;
|
||||
border-radius: 40px;
|
||||
background-size: 100% 100%;
|
||||
background-repeat: no-repeat;
|
||||
/* box-shadow is set inline per theme */
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.photo-slot {
|
||||
position: absolute;
|
||||
border-radius: 24px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.frame-photo {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
type: { type: String, required: true },
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="sticker"
|
||||
v-motion
|
||||
:initial="{ y: 0, rotate: 0 }"
|
||||
:enter="{ y: 0, rotate: 0, transition: { duration: 0.4 } }"
|
||||
:hovered="{ y: -6, rotate: -6 }"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<svg v-if="type === 'flowers'" viewBox="0 0 120 120" aria-hidden="true">
|
||||
<circle cx="34" cy="36" r="16" fill="#f7b0c7" />
|
||||
<circle cx="34" cy="36" r="6" fill="#fff3f7" />
|
||||
<circle cx="68" cy="26" r="14" fill="#f3a2bd" />
|
||||
<circle cx="68" cy="26" r="5" fill="#fff3f7" />
|
||||
<circle cx="84" cy="54" r="18" fill="#f5b7cc" />
|
||||
<circle cx="84" cy="54" r="6" fill="#fff3f7" />
|
||||
<circle cx="18" cy="72" r="10" fill="#f3a2bd" />
|
||||
<circle cx="18" cy="72" r="4" fill="#fff3f7" />
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="type === 'bunny'" viewBox="0 0 120 140" aria-hidden="true">
|
||||
<ellipse cx="60" cy="84" rx="38" ry="34" fill="#fff" />
|
||||
<ellipse cx="42" cy="22" rx="12" ry="26" fill="#fff" />
|
||||
<ellipse cx="78" cy="22" rx="12" ry="26" fill="#fff" />
|
||||
<circle cx="48" cy="80" r="5" fill="#333" />
|
||||
<circle cx="72" cy="80" r="5" fill="#333" />
|
||||
<path d="M60 86c-6 6-12 6-18 0" stroke="#333" stroke-width="3" fill="none" />
|
||||
<path d="M60 86c6 6 12 6 18 0" stroke="#333" stroke-width="3" fill="none" />
|
||||
<circle cx="84" cy="104" r="12" fill="#f4a6c1" />
|
||||
<path d="M84 104l12-8" stroke="#f4a6c1" stroke-width="6" />
|
||||
<path d="M84 104l-12-8" stroke="#f4a6c1" stroke-width="6" />
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="type === 'star'" viewBox="0 0 120 120" aria-hidden="true">
|
||||
<path d="M60 10l14 30 32 4-24 22 6 32-28-14-28 14 6-32-24-22 32-4z" fill="#f7a6c8" />
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="type === 'bow'" viewBox="0 0 140 120" aria-hidden="true">
|
||||
<path d="M40 60c-28-30-22-50 6-44 20 4 40 24 40 24s-10 26-46 20z" fill="#f1a3ba" />
|
||||
<path d="M100 60c28-30 22-50-6-44-20 4-40 24-40 24s10 26 46 20z" fill="#f1a3ba" />
|
||||
<circle cx="70" cy="60" r="12" fill="#d9779a" />
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sticker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
filter: drop-shadow(0 8px 12px rgba(201, 110, 135, 0.25));
|
||||
}
|
||||
|
||||
.sticker svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { usePhotoboothStore } from '../stores/photoboothStore'
|
||||
import { STRIP_THEMES } from '../data/stripThemes'
|
||||
|
||||
const store = usePhotoboothStore()
|
||||
|
||||
const activeTheme = computed(() => store.stripTheme)
|
||||
|
||||
function pickTheme(themeId) {
|
||||
store.setTheme(themeId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="theme-picker">
|
||||
<p class="theme-title">Select strip theme</p>
|
||||
<div class="theme-grid">
|
||||
<button
|
||||
v-for="theme in STRIP_THEMES"
|
||||
:key="theme.id"
|
||||
class="theme-chip"
|
||||
:class="{ active: activeTheme === theme.id }"
|
||||
@click="pickTheme(theme.id)"
|
||||
>
|
||||
<span class="thumb" :style="{ backgroundImage: `url('${theme.image}')` }"></span>
|
||||
<span>{{ theme.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.theme-picker {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
padding: 18px 20px;
|
||||
border-radius: 24px;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
border: 1px solid rgba(200, 90, 124, 0.2);
|
||||
box-shadow: 0 16px 40px rgba(205, 119, 143, 0.2);
|
||||
}
|
||||
|
||||
.theme-title {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #7c3b54;
|
||||
}
|
||||
|
||||
.theme-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.theme-chip {
|
||||
display: grid;
|
||||
grid-template-columns: 40px 1fr;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 16px;
|
||||
border: 2px solid transparent;
|
||||
background: #fff;
|
||||
color: #4b2b38;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 12px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
border: 1px solid rgba(124, 59, 84, 0.15);
|
||||
}
|
||||
|
||||
.theme-chip.active {
|
||||
border-color: #c85a7c;
|
||||
box-shadow: 0 10px 20px rgba(200, 90, 124, 0.2);
|
||||
}
|
||||
|
||||
.theme-chip:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user