diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..807cd8a
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,3 @@
+# Backend API base URL (no trailing slash)
+# Example: https://api.example.com
+VITE_API_BASE_URL=
diff --git a/.gitea/workflows/config.yaml b/.gitea/workflows/config.yaml
index e69de29..4cb633c 100644
--- a/.gitea/workflows/config.yaml
+++ b/.gitea/workflows/config.yaml
@@ -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
diff --git a/README.md b/README.md
index 4ddca76..dbbf515 100644
--- a/README.md
+++ b/README.md
@@ -25,6 +25,14 @@ See [Vite Configuration Reference](https://vite.dev/config/).
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
```sh
@@ -36,3 +44,22 @@ yarn dev
```sh
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`
diff --git a/index.html b/index.html
index 57e4395..b25584e 100644
--- a/index.html
+++ b/index.html
@@ -2,7 +2,7 @@
-
+
Photobooth
diff --git a/src/App.vue b/src/App.vue
index bce4007..5906359 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -1,5 +1,20 @@
@@ -9,6 +24,13 @@ import { RouterLink, RouterView } from 'vue-router'
@@ -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;
diff --git a/src/api.js b/src/api.js
new file mode 100644
index 0000000..3180aa5
--- /dev/null
+++ b/src/api.js
@@ -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
+}
diff --git a/src/pages/Account.vue b/src/pages/Account.vue
new file mode 100644
index 0000000..9409062
--- /dev/null
+++ b/src/pages/Account.vue
@@ -0,0 +1,183 @@
+
+
+
+
+
+
+
+ {{ error }}
+ Loading…
+ No saved strips yet.
+
+
+
+
+
+ {{ item.createdAt ? new Date(item.createdAt).toLocaleString() : '' }}
+ Theme: {{ item.themeId }}
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/Home.vue b/src/pages/Home.vue
index 60a35ee..ea66af9 100644
--- a/src/pages/Home.vue
+++ b/src/pages/Home.vue
@@ -104,41 +104,6 @@ onMounted(() => {
-
-
-
-
Choose Your Aesthetic
-
From Y2K nostalgia to clean minimal vibes, find the theme that fits your mood.
-
-
-
-
-
-
-
-
-
- Cotton Candy
- Minimalist
-
-
-
- Midnight Film
- Dramatic
-
-
-
- Sugar Pop
- Y2K Retro
-
-
-
- Golden Hour
- Elegant
-
-
-
-
📸
@@ -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;
diff --git a/src/pages/Login.vue b/src/pages/Login.vue
new file mode 100644
index 0000000..a302ea2
--- /dev/null
+++ b/src/pages/Login.vue
@@ -0,0 +1,146 @@
+
+
+
+
+
+ Welcome back
+ Log in to save your strips and access your gallery.
+
+
+
+
+
+
+
diff --git a/src/pages/Result.vue b/src/pages/Result.vue
index 931a7d6..aa1ff81 100644
--- a/src/pages/Result.vue
+++ b/src/pages/Result.vue
@@ -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() {
+
+ {{ saveError }}
@@ -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;
diff --git a/src/pages/Signup.vue b/src/pages/Signup.vue
new file mode 100644
index 0000000..41270b8
--- /dev/null
+++ b/src/pages/Signup.vue
@@ -0,0 +1,156 @@
+
+
+
+
+
+ Create your account
+ Save your photobooth strips and access them anytime.
+
+
+
+
+
+
+
diff --git a/src/plugins/router.js b/src/plugins/router.js
index 69587ea..4885ce5 100644
--- a/src/plugins/router.js
+++ b/src/plugins/router.js
@@ -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
diff --git a/src/stores/authStore.js b/src/stores/authStore.js
new file mode 100644
index 0000000..ba12782
--- /dev/null
+++ b/src/stores/authStore.js
@@ -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,
+ }
+})
diff --git a/tools/db/install-db-setup.sh b/tools/db/install-db-setup.sh
new file mode 100644
index 0000000..85bff2e
--- /dev/null
+++ b/tools/db/install-db-setup.sh
@@ -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 <&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" <