This commit is contained in:
@@ -0,0 +1,3 @@
|
|||||||
|
# Backend API base URL (no trailing slash)
|
||||||
|
# Example: https://api.example.com
|
||||||
|
VITE_API_BASE_URL=
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
name: Deploy photobooth
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: linux_amd64
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Deploy to Debian Server
|
||||||
|
uses: appleboy/ssh-action@v1.2.0
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.HOST }}
|
||||||
|
username: ${{ secrets.USERNAME }}
|
||||||
|
key: ${{ secrets.SSH_KEY }}
|
||||||
|
port: 22
|
||||||
|
script: |
|
||||||
|
set -eo pipefail
|
||||||
|
|
||||||
|
APP_PATH="/var/www/app/photobooth"
|
||||||
|
TEMP_PATH="/tmp/photobooth"
|
||||||
|
|
||||||
|
mkdir -p $APP_PATH
|
||||||
|
|
||||||
|
rm -rf $TEMP_PATH
|
||||||
|
|
||||||
|
git clonehttps://git.sijaarc.xyz/HayemR/photobooth.git $TEMP_PATH
|
||||||
|
|
||||||
|
rsync -avC --delete \
|
||||||
|
--no-perms \
|
||||||
|
--no-owner \
|
||||||
|
--no-group \
|
||||||
|
--no-times \
|
||||||
|
$TEMP_PATH/ $APP_PATH/
|
||||||
|
|
||||||
|
cd $APP_PATH
|
||||||
|
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
pm2 delete photobooth || true
|
||||||
|
pm2 serve dist 3000 --name photobooth --spa
|
||||||
|
|
||||||
|
pm2 save
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ See [Vite Configuration Reference](https://vite.dev/config/).
|
|||||||
yarn
|
yarn
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Environment
|
||||||
|
|
||||||
|
Create `.env.local` (or copy from `.env.example`) and set:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
VITE_API_BASE_URL=https://your-api.example.com
|
||||||
|
```
|
||||||
|
|
||||||
### Compile and Hot-Reload for Development
|
### Compile and Hot-Reload for Development
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -36,3 +44,22 @@ yarn dev
|
|||||||
```sh
|
```sh
|
||||||
yarn build
|
yarn build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Backend requirements (for auth + saved strips)
|
||||||
|
|
||||||
|
This app expects a backend that implements:
|
||||||
|
|
||||||
|
- `POST /auth/signup`
|
||||||
|
- `POST /auth/login`
|
||||||
|
- `POST /auth/refresh` (uses HttpOnly refresh cookie)
|
||||||
|
- `POST /auth/logout`
|
||||||
|
- `GET /me`
|
||||||
|
- `GET /media?type=strip`
|
||||||
|
- `POST /media` (accepts `dataUrl` for strip PNG)
|
||||||
|
- `DELETE /media/:id`
|
||||||
|
|
||||||
|
If frontend and backend are on different domains, backend must support:
|
||||||
|
|
||||||
|
- CORS with an explicit `Access-Control-Allow-Origin` (not `*`)
|
||||||
|
- `Access-Control-Allow-Credentials: true`
|
||||||
|
- refresh cookie set with `HttpOnly; Secure; SameSite=None`
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
<html lang="">
|
<html lang="">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" href="/favicon.ico" />
|
<link rel="icon" href="public/favicon.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Photobooth</title>
|
<title>Photobooth</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
+27
@@ -1,5 +1,20 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
import { computed, onMounted } from 'vue'
|
||||||
import { RouterLink, RouterView } from 'vue-router'
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -9,6 +24,13 @@ import { RouterLink, RouterView } from 'vue-router'
|
|||||||
<nav class="app-nav">
|
<nav class="app-nav">
|
||||||
<RouterLink to="/" class="nav-link">Home</RouterLink>
|
<RouterLink to="/" class="nav-link">Home</RouterLink>
|
||||||
<RouterLink to="/capture" class="nav-link">Capture</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>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -94,6 +116,11 @@ h1 {
|
|||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-button {
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
.nav-link.router-link-exact-active,
|
.nav-link.router-link-exact-active,
|
||||||
.nav-link:hover {
|
.nav-link:hover {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
|
|||||||
+92
@@ -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
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -104,41 +104,6 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
<RouterLink class="floating" to="/capture" aria-label="Start capture">📸</RouterLink>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
@@ -410,73 +375,6 @@ onMounted(() => {
|
|||||||
background: #d7e7f6;
|
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 {
|
.floating {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: 32px;
|
right: 32px;
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -5,16 +5,23 @@ import { useRouter } from 'vue-router'
|
|||||||
import { useMotion } from '@vueuse/motion'
|
import { useMotion } from '@vueuse/motion'
|
||||||
import html2canvas from 'html2canvas'
|
import html2canvas from 'html2canvas'
|
||||||
|
|
||||||
|
import { apiRequest } from '../api'
|
||||||
|
import { useAuthStore } from '../stores/authStore'
|
||||||
import { usePhotoboothStore } from '../stores/photoboothStore'
|
import { usePhotoboothStore } from '../stores/photoboothStore'
|
||||||
import { DEFAULT_STRIP_THEME_ID, STRIP_THEME_MAP } from '../data/stripThemes'
|
import { DEFAULT_STRIP_THEME_ID, STRIP_THEME_MAP } from '../data/stripThemes'
|
||||||
import PhotoStrip from '../components/PhotoStrip.vue'
|
import PhotoStrip from '../components/PhotoStrip.vue'
|
||||||
|
|
||||||
|
const auth = useAuthStore()
|
||||||
const store = usePhotoboothStore()
|
const store = usePhotoboothStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const stripRef = ref(null)
|
const stripRef = ref(null)
|
||||||
const wrapperRef = ref(null)
|
const wrapperRef = ref(null)
|
||||||
const isExporting = ref(false)
|
const isExporting = ref(false)
|
||||||
const stripDataUrl = ref('')
|
const stripDataUrl = ref('')
|
||||||
|
|
||||||
|
const isSaving = ref(false)
|
||||||
|
const saveError = ref('')
|
||||||
|
const saved = ref(false)
|
||||||
const stripSize = computed(() => {
|
const stripSize = computed(() => {
|
||||||
const theme = STRIP_THEME_MAP[store.stripTheme] || STRIP_THEME_MAP[DEFAULT_STRIP_THEME_ID]
|
const theme = STRIP_THEME_MAP[store.stripTheme] || STRIP_THEME_MAP[DEFAULT_STRIP_THEME_ID]
|
||||||
return theme.size
|
return theme.size
|
||||||
@@ -92,6 +99,39 @@ async function handleDownload() {
|
|||||||
link.remove()
|
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() {
|
function startOver() {
|
||||||
store.resetSession()
|
store.resetSession()
|
||||||
router.push('/')
|
router.push('/')
|
||||||
@@ -115,12 +155,16 @@ function startOver() {
|
|||||||
<button class="primary" :disabled="isExporting" @click="handleDownload">
|
<button class="primary" :disabled="isExporting" @click="handleDownload">
|
||||||
Download strip
|
Download strip
|
||||||
</button>
|
</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" :disabled="!shareSupported" @click="handleShare">Share</button>
|
||||||
<button class="ghost-button" @click="handleCopy">
|
<button class="ghost-button" @click="handleCopy">
|
||||||
{{ copied ? 'Copied!' : 'Copy to clipboard' }}
|
{{ copied ? 'Copied!' : 'Copy to clipboard' }}
|
||||||
</button>
|
</button>
|
||||||
<button class="ghost-button" @click="startOver">Start over</button>
|
<button class="ghost-button" @click="startOver">Start over</button>
|
||||||
</div>
|
</div>
|
||||||
|
<p v-if="saveError" class="save-error">{{ saveError }}</p>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
@@ -169,6 +213,15 @@ function startOver() {
|
|||||||
gap: 12px;
|
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 {
|
.primary {
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
|
|||||||
@@ -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>
|
||||||
+24
-4
@@ -5,6 +5,11 @@ import Home from '../pages/Home.vue'
|
|||||||
import Capture from '../pages/Capture.vue'
|
import Capture from '../pages/Capture.vue'
|
||||||
import Customize from '../pages/Customize.vue'
|
import Customize from '../pages/Customize.vue'
|
||||||
import Result from '../pages/Result.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({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
@@ -23,16 +28,31 @@ const router = createRouter({
|
|||||||
component: Result,
|
component: Result,
|
||||||
meta: { requiresPhotos: true },
|
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) => {
|
router.beforeEach(async (to) => {
|
||||||
if (!to.meta?.requiresPhotos) return true
|
const auth = useAuthStore()
|
||||||
|
|
||||||
|
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 } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to.meta?.requiresPhotos) {
|
||||||
const store = usePhotoboothStore()
|
const store = usePhotoboothStore()
|
||||||
if (store.photos.length >= 3) return true
|
if (store.photos.length < 3) {
|
||||||
|
|
||||||
return { name: 'home' }
|
return { name: 'home' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ "${EUID}" -ne 0 ]; then
|
||||||
|
echo "Run as root: sudo bash tools/db/install-db-setup.sh" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
MYSQL_ROOT_USER="${MYSQL_ROOT_USER:-root}"
|
||||||
|
MYSQL_ROOT_PASSWORD="${MYSQL_ROOT_PASSWORD:-}"
|
||||||
|
MYSQL_HOST="${MYSQL_HOST:-127.0.0.1}"
|
||||||
|
MYSQL_PORT="${MYSQL_PORT:-3306}"
|
||||||
|
MYSQL_DATABASE="${MYSQL_DATABASE:-photobooth}"
|
||||||
|
MYSQL_APP_USER="${MYSQL_APP_USER:-photobooth_app}"
|
||||||
|
MYSQL_APP_PASSWORD="${MYSQL_APP_PASSWORD:-}"
|
||||||
|
|
||||||
|
if [ -z "$MYSQL_ROOT_PASSWORD" ] || [ -z "$MYSQL_APP_PASSWORD" ]; then
|
||||||
|
echo "Set MYSQL_ROOT_PASSWORD and MYSQL_APP_PASSWORD before running." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
install -m 0755 "$SCRIPT_DIR/setup-db.sh" /usr/local/bin/photobooth-db-setup
|
||||||
|
install -m 0644 "$SCRIPT_DIR/photobooth-db.service" /etc/systemd/system/photobooth-db.service
|
||||||
|
install -m 0644 "$SCRIPT_DIR/photobooth-db.timer" /etc/systemd/system/photobooth-db.timer
|
||||||
|
|
||||||
|
cat > /etc/photobooth-db.env <<EOF
|
||||||
|
MYSQL_ROOT_USER=${MYSQL_ROOT_USER}
|
||||||
|
MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD}
|
||||||
|
MYSQL_HOST=${MYSQL_HOST}
|
||||||
|
MYSQL_PORT=${MYSQL_PORT}
|
||||||
|
MYSQL_DATABASE=${MYSQL_DATABASE}
|
||||||
|
MYSQL_APP_USER=${MYSQL_APP_USER}
|
||||||
|
MYSQL_APP_PASSWORD=${MYSQL_APP_PASSWORD}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
chmod 600 /etc/photobooth-db.env
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now photobooth-db.timer
|
||||||
|
systemctl start photobooth-db.service
|
||||||
|
|
||||||
|
echo "DB setup installed. It will run once and then stay idle."
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
MYSQL_ROOT_USER=root
|
||||||
|
MYSQL_ROOT_PASSWORD=replace_me
|
||||||
|
MYSQL_HOST=127.0.0.1
|
||||||
|
MYSQL_PORT=3306
|
||||||
|
MYSQL_DATABASE=photobooth
|
||||||
|
MYSQL_APP_USER=photobooth_app
|
||||||
|
MYSQL_APP_PASSWORD=replace_me
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Photobooth database setup
|
||||||
|
After=network-online.target mysql.service
|
||||||
|
Wants=network-online.target
|
||||||
|
ConditionPathExists=!/var/lib/photobooth/db-setup.done
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
EnvironmentFile=/etc/photobooth-db.env
|
||||||
|
ExecStart=/usr/local/bin/photobooth-db-setup
|
||||||
|
RemainAfterExit=yes
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=Photobooth database setup timer
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=1min
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
MYSQL_ROOT_USER="${MYSQL_ROOT_USER:-root}"
|
||||||
|
MYSQL_ROOT_PASSWORD="${MYSQL_ROOT_PASSWORD:-}"
|
||||||
|
MYSQL_HOST="${MYSQL_HOST:-127.0.0.1}"
|
||||||
|
MYSQL_PORT="${MYSQL_PORT:-3306}"
|
||||||
|
MYSQL_DATABASE="${MYSQL_DATABASE:-photobooth}"
|
||||||
|
MYSQL_APP_USER="${MYSQL_APP_USER:-photobooth_app}"
|
||||||
|
MYSQL_APP_PASSWORD="${MYSQL_APP_PASSWORD:-}"
|
||||||
|
MARKER_FILE="${MARKER_FILE:-/var/lib/photobooth/db-setup.done}"
|
||||||
|
|
||||||
|
if [ -z "$MYSQL_ROOT_PASSWORD" ]; then
|
||||||
|
echo "MYSQL_ROOT_PASSWORD is required." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$MYSQL_APP_PASSWORD" ]; then
|
||||||
|
echo "MYSQL_APP_PASSWORD is required." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$MARKER_FILE" ]; then
|
||||||
|
echo "Database already initialized: $MARKER_FILE"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$MARKER_FILE")"
|
||||||
|
|
||||||
|
MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql \
|
||||||
|
--protocol=TCP \
|
||||||
|
-h "$MYSQL_HOST" \
|
||||||
|
-P "$MYSQL_PORT" \
|
||||||
|
-u "$MYSQL_ROOT_USER" <<SQL
|
||||||
|
CREATE DATABASE IF NOT EXISTS \`$MYSQL_DATABASE\`
|
||||||
|
CHARACTER SET utf8mb4
|
||||||
|
COLLATE utf8mb4_0900_ai_ci;
|
||||||
|
|
||||||
|
CREATE USER IF NOT EXISTS '$MYSQL_APP_USER'@'%' IDENTIFIED BY '$MYSQL_APP_PASSWORD';
|
||||||
|
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX ON \`$MYSQL_DATABASE\`.* TO '$MYSQL_APP_USER'@'%';
|
||||||
|
FLUSH PRIVILEGES;
|
||||||
|
|
||||||
|
USE \`$MYSQL_DATABASE\`;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id CHAR(36) PRIMARY KEY,
|
||||||
|
email VARCHAR(255) NOT NULL,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
name VARCHAR(120) NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uq_users_email (email)
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||||
|
id CHAR(36) PRIMARY KEY,
|
||||||
|
user_id CHAR(36) NOT NULL,
|
||||||
|
token_hash VARCHAR(255) NOT NULL,
|
||||||
|
expires_at DATETIME NOT NULL,
|
||||||
|
revoked_at DATETIME NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uq_refresh_token_hash (token_hash),
|
||||||
|
KEY idx_refresh_user (user_id),
|
||||||
|
CONSTRAINT fk_refresh_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS media (
|
||||||
|
id CHAR(36) PRIMARY KEY,
|
||||||
|
user_id CHAR(36) NOT NULL,
|
||||||
|
type ENUM('shot','strip') NOT NULL,
|
||||||
|
url VARCHAR(2048) NOT NULL,
|
||||||
|
thumb_url VARCHAR(2048) NULL,
|
||||||
|
theme_id VARCHAR(64) NULL,
|
||||||
|
meta JSON NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
KEY idx_media_user_created (user_id, created_at),
|
||||||
|
KEY idx_media_type (type),
|
||||||
|
CONSTRAINT fk_media_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB;
|
||||||
|
SQL
|
||||||
|
|
||||||
|
touch "$MARKER_FILE"
|
||||||
|
echo "Database initialized."
|
||||||
Reference in New Issue
Block a user