database integration
Deploy photobooth / deploy (push) Has been cancelled

This commit is contained in:
2026-05-26 09:07:16 +07:00
parent a90b3a85a2
commit 100e5c3a87
18 changed files with 1068 additions and 108 deletions
+27
View File
@@ -1,5 +1,20 @@
<script setup>
import { computed, onMounted } from 'vue'
import { RouterLink, RouterView } from 'vue-router'
import { useAuthStore } from './stores/authStore'
const auth = useAuthStore()
const accountLabel = computed(() => auth.user?.name || auth.user?.email || 'Account')
onMounted(() => {
auth.bootstrap()
})
async function handleLogout() {
await auth.logout()
}
</script>
<template>
@@ -9,6 +24,13 @@ import { RouterLink, RouterView } from 'vue-router'
<nav class="app-nav">
<RouterLink to="/" class="nav-link">Home</RouterLink>
<RouterLink to="/capture" class="nav-link">Capture</RouterLink>
<RouterLink v-if="auth.isAuthenticated" to="/account" class="nav-link">{{ accountLabel }}</RouterLink>
<RouterLink v-else to="/login" class="nav-link">Login</RouterLink>
<button v-if="auth.isAuthenticated" type="button" class="nav-link nav-button" @click="handleLogout">
Logout
</button>
</nav>
</header>
@@ -94,6 +116,11 @@ h1 {
transition: all 0.2s ease;
}
.nav-button {
cursor: pointer;
font: inherit;
}
.nav-link.router-link-exact-active,
.nav-link:hover {
background: #fff;
+92
View File
@@ -0,0 +1,92 @@
const API_BASE_URL = String(import.meta.env.VITE_API_BASE_URL || '').replace(/\/$/, '')
let accessToken = ''
let refreshHandler = null
export function setAccessToken(token) {
accessToken = token || ''
}
export function clearAccessToken() {
accessToken = ''
}
export function setRefreshHandler(handler) {
refreshHandler = handler
}
function buildUrl(path) {
if (!API_BASE_URL) return path
if (/^https?:\/\//i.test(path)) return path
return `${API_BASE_URL}${path.startsWith('/') ? '' : '/'}${path}`
}
async function readBody(res) {
if (res.status === 204) return null
const contentType = res.headers.get('content-type') || ''
if (contentType.includes('application/json')) return res.json()
return res.text()
}
function normalizeError(data) {
if (!data) return new Error('Request failed')
if (typeof data === 'string') return new Error(data)
if (typeof data?.message === 'string') return new Error(data.message)
return new Error('Request failed')
}
async function tryRefresh() {
if (!refreshHandler) return false
try {
const token = await refreshHandler()
if (!token) {
clearAccessToken()
return false
}
setAccessToken(token)
return true
} catch {
clearAccessToken()
return false
}
}
export async function apiRequest(
path,
{ method = 'GET', body, headers = {}, skipAuth = false, skipRefresh = false } = {},
) {
const url = buildUrl(path)
const finalHeaders = new Headers(headers)
if (!skipAuth && accessToken) {
finalHeaders.set('Authorization', `Bearer ${accessToken}`)
}
let finalBody = body
if (body && !(body instanceof FormData) && typeof body === 'object') {
finalHeaders.set('Content-Type', 'application/json')
finalBody = JSON.stringify(body)
}
const res = await fetch(url, {
method,
headers: finalHeaders,
body: finalBody,
credentials: 'include',
})
if (res.status === 401 && !skipRefresh) {
const refreshed = await tryRefresh()
if (refreshed) {
return apiRequest(path, { method, body, headers, skipAuth, skipRefresh: true })
}
}
const data = await readBody(res)
if (!res.ok) {
throw normalizeError(data)
}
return data
}
+183
View File
@@ -0,0 +1,183 @@
<script setup>
import { computed, onMounted, ref } from 'vue'
import { apiRequest } from '../api'
import { useAuthStore } from '../stores/authStore'
const auth = useAuthStore()
const items = ref([])
const loading = ref(false)
const error = ref('')
const title = computed(() => auth.user?.name || auth.user?.email || 'Your account')
async function load() {
loading.value = true
error.value = ''
try {
const data = await apiRequest('/media?type=strip')
items.value = data?.items || data || []
} catch (err) {
error.value = err?.message || 'Failed to load saved items.'
} finally {
loading.value = false
}
}
async function remove(itemId) {
try {
await apiRequest(`/media/${encodeURIComponent(itemId)}`, { method: 'DELETE' })
items.value = items.value.filter((i) => i.id !== itemId)
} catch (err) {
error.value = err?.message || 'Failed to delete.'
}
}
onMounted(load)
</script>
<template>
<main class="account">
<section class="panel">
<div class="header">
<div>
<h1>{{ title }}</h1>
<p>Your saved photobooth strips.</p>
</div>
<button class="ghost" type="button" @click="load" :disabled="loading">Refresh</button>
</div>
<p v-if="error" class="error">{{ error }}</p>
<p v-else-if="loading" class="muted">Loading</p>
<p v-else-if="items.length === 0" class="muted">No saved strips yet.</p>
<div class="grid" v-if="items.length">
<article v-for="item in items" :key="item.id" class="card">
<img class="thumb" :src="item.thumbUrl || item.url" :alt="item.type" />
<div class="meta">
<small>{{ item.createdAt ? new Date(item.createdAt).toLocaleString() : '' }}</small>
<small v-if="item.themeId">Theme: {{ item.themeId }}</small>
</div>
<div class="actions">
<a class="primary" :href="item.url" target="_blank" rel="noreferrer">Open</a>
<button class="danger" type="button" @click="remove(item.id)">Delete</button>
</div>
</article>
</div>
</section>
</main>
</template>
<style scoped>
.account {
display: grid;
padding: 8px 0 28px;
}
.panel {
padding: 28px;
border-radius: 32px;
background: rgba(255, 255, 255, 0.85);
box-shadow: var(--soft-shadow);
display: grid;
gap: 16px;
}
.header {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
}
.header h1 {
margin: 0;
}
.header p {
margin: 6px 0 0;
color: var(--muted);
}
.ghost {
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;
}
.error {
margin: 0;
color: #a13b56;
background: rgba(255, 236, 243, 0.7);
border: 1px solid rgba(200, 90, 124, 0.25);
padding: 10px 12px;
border-radius: 14px;
}
.muted {
margin: 0;
color: var(--muted);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
}
.card {
padding: 16px;
border-radius: 24px;
background: rgba(255, 255, 255, 0.9);
border: 1px solid rgba(200, 90, 124, 0.18);
display: grid;
gap: 10px;
}
.thumb {
width: 100%;
aspect-ratio: 2 / 5;
object-fit: cover;
border-radius: 16px;
background: rgba(255, 236, 243, 0.7);
}
.meta {
display: grid;
gap: 4px;
color: var(--muted);
}
.actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.primary {
text-align: center;
border: none;
border-radius: 999px;
padding: 10px 16px;
background: #c85a7c;
color: #fff;
font-weight: 600;
box-shadow: 0 14px 24px rgba(200, 90, 124, 0.22);
}
.danger {
border: 1px solid rgba(161, 59, 86, 0.35);
background: rgba(255, 255, 255, 0.85);
color: #a13b56;
padding: 10px 16px;
border-radius: 999px;
font-weight: 700;
cursor: pointer;
}
</style>
-102
View File
@@ -104,41 +104,6 @@ onMounted(() => {
</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>
@@ -410,73 +375,6 @@ onMounted(() => {
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;
+146
View File
@@ -0,0 +1,146 @@
<script setup>
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/authStore'
const auth = useAuthStore()
const router = useRouter()
const route = useRoute()
const email = ref('')
const password = ref('')
const error = ref('')
const busy = ref(false)
const returnTo = computed(() => {
const value = route.query.returnTo
return typeof value === 'string' && value.length ? value : '/account'
})
async function submit() {
error.value = ''
busy.value = true
try {
await auth.login({ email: email.value.trim(), password: password.value })
await router.push(returnTo.value)
} catch (err) {
error.value = err?.message || 'Login failed.'
} finally {
busy.value = false
}
}
</script>
<template>
<main class="auth">
<section class="panel">
<h1>Welcome back</h1>
<p>Log in to save your strips and access your gallery.</p>
<form class="form" @submit.prevent="submit">
<label>
<span>Email</span>
<input v-model="email" type="email" autocomplete="email" required />
</label>
<label>
<span>Password</span>
<input v-model="password" type="password" autocomplete="current-password" required />
</label>
<p v-if="error" class="error">{{ error }}</p>
<button class="primary" type="submit" :disabled="busy">
{{ busy ? 'Logging in…' : 'Login' }}
</button>
<RouterLink class="helper" :to="{ name: 'signup', query: { returnTo } }"
>No account? Sign up</RouterLink
>
</form>
</section>
</main>
</template>
<style scoped>
.auth {
display: grid;
place-items: center;
padding: 24px 0;
}
.panel {
width: min(520px, 100%);
padding: 28px;
border-radius: 32px;
background: rgba(255, 255, 255, 0.85);
box-shadow: var(--soft-shadow);
display: grid;
gap: 14px;
}
.panel h1 {
margin: 0;
}
.panel p {
margin: 0;
color: var(--muted);
}
.form {
display: grid;
gap: 12px;
margin-top: 8px;
}
label {
display: grid;
gap: 6px;
}
label span {
font-weight: 600;
font-size: 0.9rem;
}
input {
padding: 12px 14px;
border-radius: 14px;
border: 1px solid rgba(200, 90, 124, 0.28);
background: rgba(255, 255, 255, 0.9);
}
.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;
}
.error {
margin: 0;
color: #a13b56;
background: rgba(255, 236, 243, 0.7);
border: 1px solid rgba(200, 90, 124, 0.25);
padding: 10px 12px;
border-radius: 14px;
}
.helper {
justify-self: center;
color: #7c3b54;
font-weight: 600;
}
</style>
+53
View File
@@ -5,16 +5,23 @@ import { useRouter } from 'vue-router'
import { useMotion } from '@vueuse/motion'
import html2canvas from 'html2canvas'
import { apiRequest } from '../api'
import { useAuthStore } from '../stores/authStore'
import { usePhotoboothStore } from '../stores/photoboothStore'
import { DEFAULT_STRIP_THEME_ID, STRIP_THEME_MAP } from '../data/stripThemes'
import PhotoStrip from '../components/PhotoStrip.vue'
const auth = useAuthStore()
const store = usePhotoboothStore()
const router = useRouter()
const stripRef = ref(null)
const wrapperRef = ref(null)
const isExporting = ref(false)
const stripDataUrl = ref('')
const isSaving = ref(false)
const saveError = ref('')
const saved = ref(false)
const stripSize = computed(() => {
const theme = STRIP_THEME_MAP[store.stripTheme] || STRIP_THEME_MAP[DEFAULT_STRIP_THEME_ID]
return theme.size
@@ -92,6 +99,39 @@ async function handleDownload() {
link.remove()
}
async function handleSave() {
saveError.value = ''
if (!auth.isAuthenticated) {
router.push({ name: 'login', query: { returnTo: '/result' } })
return
}
if (saved.value || isSaving.value) return
isSaving.value = true
try {
const dataUrl = stripDataUrl.value || (await renderStrip())
if (!dataUrl) return
await apiRequest('/media', {
method: 'POST',
body: {
type: 'strip',
dataUrl,
themeId: store.stripTheme,
meta: { photoCount: 3 },
},
})
saved.value = true
} catch (err) {
saveError.value = err?.message || 'Failed to save.'
} finally {
isSaving.value = false
}
}
function startOver() {
store.resetSession()
router.push('/')
@@ -115,12 +155,16 @@ function startOver() {
<button class="primary" :disabled="isExporting" @click="handleDownload">
Download strip
</button>
<button class="ghost-button" :disabled="isSaving || saved" @click="handleSave">
{{ saved ? 'Saved to account' : isSaving ? 'Saving' : 'Save to account' }}
</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>
<p v-if="saveError" class="save-error">{{ saveError }}</p>
</section>
</main>
</template>
@@ -169,6 +213,15 @@ function startOver() {
gap: 12px;
}
.save-error {
margin: 0;
color: #a13b56;
background: rgba(255, 236, 243, 0.7);
border: 1px solid rgba(200, 90, 124, 0.25);
padding: 10px 12px;
border-radius: 14px;
}
.primary {
border: none;
border-radius: 999px;
+156
View File
@@ -0,0 +1,156 @@
<script setup>
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from '../stores/authStore'
const auth = useAuthStore()
const router = useRouter()
const route = useRoute()
const name = ref('')
const email = ref('')
const password = ref('')
const error = ref('')
const busy = ref(false)
const returnTo = computed(() => {
const value = route.query.returnTo
return typeof value === 'string' && value.length ? value : '/account'
})
async function submit() {
error.value = ''
busy.value = true
try {
await auth.signup({
name: name.value.trim() || undefined,
email: email.value.trim(),
password: password.value,
})
await router.push(returnTo.value)
} catch (err) {
error.value = err?.message || 'Signup failed.'
} finally {
busy.value = false
}
}
</script>
<template>
<main class="auth">
<section class="panel">
<h1>Create your account</h1>
<p>Save your photobooth strips and access them anytime.</p>
<form class="form" @submit.prevent="submit">
<label>
<span>Name (optional)</span>
<input v-model="name" type="text" autocomplete="name" />
</label>
<label>
<span>Email</span>
<input v-model="email" type="email" autocomplete="email" required />
</label>
<label>
<span>Password</span>
<input v-model="password" type="password" autocomplete="new-password" required />
</label>
<p v-if="error" class="error">{{ error }}</p>
<button class="primary" type="submit" :disabled="busy">
{{ busy ? 'Creating…' : 'Sign up' }}
</button>
<RouterLink class="helper" :to="{ name: 'login', query: { returnTo } }"
>Already have an account? Login</RouterLink
>
</form>
</section>
</main>
</template>
<style scoped>
.auth {
display: grid;
place-items: center;
padding: 24px 0;
}
.panel {
width: min(520px, 100%);
padding: 28px;
border-radius: 32px;
background: rgba(255, 255, 255, 0.85);
box-shadow: var(--soft-shadow);
display: grid;
gap: 14px;
}
.panel h1 {
margin: 0;
}
.panel p {
margin: 0;
color: var(--muted);
}
.form {
display: grid;
gap: 12px;
margin-top: 8px;
}
label {
display: grid;
gap: 6px;
}
label span {
font-weight: 600;
font-size: 0.9rem;
}
input {
padding: 12px 14px;
border-radius: 14px;
border: 1px solid rgba(200, 90, 124, 0.28);
background: rgba(255, 255, 255, 0.9);
}
.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;
}
.error {
margin: 0;
color: #a13b56;
background: rgba(255, 236, 243, 0.7);
border: 1px solid rgba(200, 90, 124, 0.25);
padding: 10px 12px;
border-radius: 14px;
}
.helper {
justify-self: center;
color: #7c3b54;
font-weight: 600;
}
</style>
+25 -5
View File
@@ -5,6 +5,11 @@ import Home from '../pages/Home.vue'
import Capture from '../pages/Capture.vue'
import Customize from '../pages/Customize.vue'
import Result from '../pages/Result.vue'
import Login from '../pages/Login.vue'
import Signup from '../pages/Signup.vue'
import Account from '../pages/Account.vue'
import { useAuthStore } from '../stores/authStore'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
@@ -23,16 +28,31 @@ const router = createRouter({
component: Result,
meta: { requiresPhotos: true },
},
{ path: '/login', name: 'login', component: Login },
{ path: '/signup', name: 'signup', component: Signup },
{ path: '/account', name: 'account', component: Account, meta: { requiresAuth: true } },
],
})
router.beforeEach((to) => {
if (!to.meta?.requiresPhotos) return true
router.beforeEach(async (to) => {
const auth = useAuthStore()
const store = usePhotoboothStore()
if (store.photos.length >= 3) return true
if (to.meta?.requiresAuth && !auth.isAuthenticated) {
// Try restore session via refresh-cookie before redirecting.
await auth.refresh()
if (!auth.isAuthenticated) {
return { name: 'login', query: { returnTo: to.fullPath } }
}
}
return { name: 'home' }
if (to.meta?.requiresPhotos) {
const store = usePhotoboothStore()
if (store.photos.length < 3) {
return { name: 'home' }
}
}
return true
})
export default router
+151
View File
@@ -0,0 +1,151 @@
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { apiRequest, clearAccessToken, setAccessToken, setRefreshHandler } from '../api'
export const useAuthStore = defineStore('auth', () => {
const user = ref(null)
const accessToken = ref('')
const status = ref('idle')
const isAuthenticated = computed(() => Boolean(accessToken.value))
function applyAccessToken(token) {
accessToken.value = token || ''
setAccessToken(accessToken.value)
}
function clearSession() {
user.value = null
accessToken.value = ''
clearAccessToken()
}
async function refresh() {
status.value = 'loading'
try {
const data = await apiRequest('/auth/refresh', {
method: 'POST',
skipAuth: true,
skipRefresh: true,
})
const token = data?.accessToken || ''
if (!token) {
clearSession()
status.value = 'idle'
return false
}
applyAccessToken(token)
status.value = 'idle'
return true
} catch {
clearSession()
status.value = 'idle'
return false
}
}
async function fetchMe() {
try {
const data = await apiRequest('/me', { method: 'GET' })
user.value = data?.user || data || null
return user.value
} catch {
user.value = null
return null
}
}
async function bootstrap() {
// Try to restore session using refresh cookie.
const ok = await refresh()
if (ok) {
await fetchMe()
}
}
async function login({ email, password }) {
status.value = 'loading'
try {
const data = await apiRequest('/auth/login', {
method: 'POST',
skipAuth: true,
body: { email, password },
})
applyAccessToken(data?.accessToken || '')
user.value = data?.user || null
status.value = 'idle'
return true
} catch (err) {
clearSession()
status.value = 'error'
throw err
}
}
async function signup({ email, password, name }) {
status.value = 'loading'
try {
const data = await apiRequest('/auth/signup', {
method: 'POST',
skipAuth: true,
body: { email, password, name },
})
applyAccessToken(data?.accessToken || '')
user.value = data?.user || null
status.value = 'idle'
return true
} catch (err) {
clearSession()
status.value = 'error'
throw err
}
}
async function logout() {
status.value = 'loading'
try {
await apiRequest('/auth/logout', { method: 'POST', skipRefresh: true })
} finally {
clearSession()
status.value = 'idle'
}
}
// Allow apiRequest() to refresh tokens automatically on 401.
setRefreshHandler(async () => {
const data = await apiRequest('/auth/refresh', {
method: 'POST',
skipAuth: true,
skipRefresh: true,
})
const token = data?.accessToken || ''
applyAccessToken(token)
return token
})
// Keep api module token in sync if a token was already set.
setAccessToken(accessToken.value)
return {
user,
accessToken,
status,
isAuthenticated,
bootstrap,
fetchMe,
refresh,
login,
signup,
logout,
clearSession,
}
})