diff --git a/.gitignore b/.gitignore
index 2fabefc..c929a72 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,5 @@ __pycache__/
.venv/
.env
.DS_Store
+node_modules/
+dist/
diff --git a/frontend/.dockerignore b/frontend/.dockerignore
new file mode 100644
index 0000000..f06235c
--- /dev/null
+++ b/frontend/.dockerignore
@@ -0,0 +1,2 @@
+node_modules
+dist
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
new file mode 100644
index 0000000..7b4784d
--- /dev/null
+++ b/frontend/Dockerfile
@@ -0,0 +1,17 @@
+FROM node:22-alpine AS build
+WORKDIR /app
+COPY package.json package-lock.json* ./
+RUN npm install
+COPY . .
+ARG VITE_FIREBASE_API_KEY
+ARG VITE_FIREBASE_AUTH_DOMAIN
+ARG VITE_FIREBASE_PROJECT_ID
+ARG VITE_FIREBASE_STORAGE_BUCKET
+ARG VITE_FIREBASE_MESSAGING_SENDER_ID
+ARG VITE_FIREBASE_APP_ID
+ARG VITE_FIREBASE_MEASUREMENT_ID
+RUN npm run build
+
+FROM nginx:alpine
+COPY nginx.conf /etc/nginx/conf.d/default.conf
+COPY --from=build /app/dist /usr/share/nginx/html
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..e73a091
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Favs
+
+
+
+
+
+
diff --git a/frontend/nginx.conf b/frontend/nginx.conf
new file mode 100644
index 0000000..4a45e03
--- /dev/null
+++ b/frontend/nginx.conf
@@ -0,0 +1,13 @@
+server {
+ listen 80;
+ root /usr/share/nginx/html;
+ index index.html;
+
+ location /api/ {
+ proxy_pass http://favs-api:8000;
+ }
+
+ location / {
+ try_files $uri $uri/ /index.html;
+ }
+}
diff --git a/frontend/package.json b/frontend/package.json
new file mode 100644
index 0000000..802ab2b
--- /dev/null
+++ b/frontend/package.json
@@ -0,0 +1,17 @@
+{
+ "name": "favs-ui",
+ "private": true,
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "vue": "^3.5",
+ "firebase": "^11.0"
+ },
+ "devDependencies": {
+ "@vitejs/plugin-vue": "^5.0",
+ "vite": "^6.0"
+ }
+}
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
new file mode 100644
index 0000000..6555617
--- /dev/null
+++ b/frontend/src/App.vue
@@ -0,0 +1,116 @@
+
+
+
+
+
+
Loading...
+
+
+
+
favs
+
Your personal bookmarks
+
+
+
+
+
+
+
+
+
+
+
+ {{ status }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/api.js b/frontend/src/api.js
new file mode 100644
index 0000000..3994ce7
--- /dev/null
+++ b/frontend/src/api.js
@@ -0,0 +1,32 @@
+import { getIdToken } from './firebase.js'
+
+const BASE = '/api'
+
+async function request(path, options = {}) {
+ const token = await getIdToken()
+ const headers = {
+ 'Content-Type': 'application/json',
+ ...options.headers,
+ }
+ if (token) {
+ headers['Authorization'] = `Bearer ${token}`
+ }
+ const res = await fetch(`${BASE}${path}`, { ...options, headers })
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
+ if (res.status === 204) return null
+ return res.json()
+}
+
+export const getBookmarks = (category) => {
+ const qs = category ? `?category=${encodeURIComponent(category)}` : ''
+ return request(`/bookmarks/${qs}`)
+}
+
+export const createBookmark = (data) =>
+ request('/bookmarks/', { method: 'POST', body: JSON.stringify(data) })
+
+export const deleteBookmark = (id) =>
+ request(`/bookmarks/${id}`, { method: 'DELETE' })
+
+export const triggerCategorize = () =>
+ request('/categorize', { method: 'POST' })
diff --git a/frontend/src/components/BookmarkForm.vue b/frontend/src/components/BookmarkForm.vue
new file mode 100644
index 0000000..e7dbdc4
--- /dev/null
+++ b/frontend/src/components/BookmarkForm.vue
@@ -0,0 +1,27 @@
+
+
+
+
+
diff --git a/frontend/src/components/BookmarkList.vue b/frontend/src/components/BookmarkList.vue
new file mode 100644
index 0000000..6994b63
--- /dev/null
+++ b/frontend/src/components/BookmarkList.vue
@@ -0,0 +1,25 @@
+
+
+
+ No bookmarks yet
+
+
+
{{ b.title }}
+
+ {{ b.category }}
+ {{ new Date(b.created_at).toLocaleDateString() }}
+
+
+
+
+
diff --git a/frontend/src/composables/useAuth.js b/frontend/src/composables/useAuth.js
new file mode 100644
index 0000000..4d7375f
--- /dev/null
+++ b/frontend/src/composables/useAuth.js
@@ -0,0 +1,20 @@
+import { ref, onMounted } from 'vue'
+import { onAuthStateChanged } from 'firebase/auth'
+import { auth, loginWithGoogle, logout } from '../firebase.js'
+
+const user = ref(null)
+const loading = ref(true)
+
+let initialized = false
+
+export function useAuth() {
+ if (!initialized) {
+ initialized = true
+ onAuthStateChanged(auth, (u) => {
+ user.value = u
+ loading.value = false
+ })
+ }
+
+ return { user, loading, loginWithGoogle, logout }
+}
diff --git a/frontend/src/firebase.js b/frontend/src/firebase.js
new file mode 100644
index 0000000..9086ecc
--- /dev/null
+++ b/frontend/src/firebase.js
@@ -0,0 +1,24 @@
+import { initializeApp } from 'firebase/app'
+import { getAuth, GoogleAuthProvider, signInWithPopup, signOut } from 'firebase/auth'
+
+const app = initializeApp({
+ apiKey: import.meta.env.VITE_FIREBASE_API_KEY,
+ authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN,
+ projectId: import.meta.env.VITE_FIREBASE_PROJECT_ID,
+ storageBucket: import.meta.env.VITE_FIREBASE_STORAGE_BUCKET,
+ messagingSenderId: import.meta.env.VITE_FIREBASE_MESSAGING_SENDER_ID,
+ appId: import.meta.env.VITE_FIREBASE_APP_ID,
+})
+
+export const auth = getAuth(app)
+
+const provider = new GoogleAuthProvider()
+
+export const loginWithGoogle = () => signInWithPopup(auth, provider)
+export const logout = () => signOut(auth)
+
+export async function getIdToken() {
+ const user = auth.currentUser
+ if (!user) return null
+ return user.getIdToken()
+}
diff --git a/frontend/src/main.js b/frontend/src/main.js
new file mode 100644
index 0000000..fe5bae3
--- /dev/null
+++ b/frontend/src/main.js
@@ -0,0 +1,5 @@
+import { createApp } from 'vue'
+import App from './App.vue'
+import './style.css'
+
+createApp(App).mount('#app')
diff --git a/frontend/src/style.css b/frontend/src/style.css
new file mode 100644
index 0000000..6eee6d1
--- /dev/null
+++ b/frontend/src/style.css
@@ -0,0 +1,239 @@
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+ background: #0f0f0f;
+ color: #e0e0e0;
+ line-height: 1.5;
+}
+
+.container {
+ max-width: 700px;
+ margin: 2rem auto;
+ padding: 0 1rem;
+}
+
+h1 {
+ font-size: 1.5rem;
+ margin-bottom: 1.5rem;
+ color: #fff;
+}
+
+/* Form */
+.bookmark-form {
+ display: flex;
+ gap: 0.5rem;
+ margin-bottom: 1.5rem;
+}
+
+.bookmark-form input {
+ flex: 1;
+ padding: 0.5rem 0.75rem;
+ border: 1px solid #333;
+ border-radius: 6px;
+ background: #1a1a1a;
+ color: #e0e0e0;
+ font-size: 0.875rem;
+}
+
+.bookmark-form input:focus {
+ outline: none;
+ border-color: #555;
+}
+
+.bookmark-form input::placeholder {
+ color: #666;
+}
+
+/* Buttons */
+button {
+ padding: 0.5rem 1rem;
+ border: none;
+ border-radius: 6px;
+ cursor: pointer;
+ font-size: 0.875rem;
+ transition: background 0.15s;
+}
+
+.btn-add {
+ background: #2563eb;
+ color: #fff;
+}
+
+.btn-add:hover {
+ background: #1d4ed8;
+}
+
+.btn-categorize {
+ background: #1a1a1a;
+ color: #a0a0a0;
+ border: 1px solid #333;
+ margin-bottom: 1rem;
+}
+
+.btn-categorize:hover {
+ background: #252525;
+ color: #fff;
+}
+
+.btn-delete {
+ background: none;
+ color: #555;
+ padding: 0.25rem 0.5rem;
+ font-size: 0.75rem;
+}
+
+.btn-delete:hover {
+ color: #ef4444;
+}
+
+/* Category bar */
+.category-bar {
+ display: flex;
+ gap: 0.375rem;
+ margin-bottom: 1rem;
+ flex-wrap: wrap;
+}
+
+.category-pill {
+ padding: 0.25rem 0.75rem;
+ border-radius: 999px;
+ background: #1a1a1a;
+ color: #888;
+ border: 1px solid #282828;
+ font-size: 0.75rem;
+ cursor: pointer;
+}
+
+.category-pill:hover {
+ color: #ccc;
+ border-color: #444;
+}
+
+.category-pill.active {
+ background: #2563eb;
+ color: #fff;
+ border-color: #2563eb;
+}
+
+/* Bookmark list */
+.bookmark-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.625rem 0;
+ border-bottom: 1px solid #1a1a1a;
+}
+
+.bookmark-info {
+ min-width: 0;
+ flex: 1;
+}
+
+.bookmark-title {
+ color: #60a5fa;
+ text-decoration: none;
+ font-size: 0.875rem;
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.bookmark-title:hover {
+ text-decoration: underline;
+}
+
+.bookmark-meta {
+ font-size: 0.7rem;
+ color: #555;
+ margin-top: 0.125rem;
+}
+
+.bookmark-category {
+ color: #888;
+ background: #1a1a1a;
+ padding: 0.1rem 0.4rem;
+ border-radius: 4px;
+ font-size: 0.65rem;
+}
+
+.empty {
+ color: #555;
+ font-size: 0.875rem;
+ padding: 2rem 0;
+ text-align: center;
+}
+
+.status {
+ font-size: 0.75rem;
+ color: #888;
+ margin-bottom: 0.5rem;
+}
+
+/* Login */
+.login-screen {
+ text-align: center;
+ padding-top: 20vh;
+}
+
+.login-subtitle {
+ color: #666;
+ margin-bottom: 2rem;
+ font-size: 0.875rem;
+}
+
+.btn-google {
+ background: #fff;
+ color: #333;
+ padding: 0.625rem 1.5rem;
+ font-size: 0.875rem;
+ font-weight: 500;
+ border-radius: 6px;
+}
+
+.btn-google:hover {
+ background: #f0f0f0;
+}
+
+/* Header */
+.header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 1.5rem;
+}
+
+.header h1 {
+ margin-bottom: 0;
+}
+
+.user-info {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.user-email {
+ font-size: 0.75rem;
+ color: #666;
+}
+
+.btn-logout {
+ background: none;
+ color: #666;
+ font-size: 0.75rem;
+ padding: 0.25rem 0.5rem;
+ border: 1px solid #333;
+}
+
+.btn-logout:hover {
+ color: #fff;
+ border-color: #555;
+}
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
new file mode 100644
index 0000000..e86fbc4
--- /dev/null
+++ b/frontend/vite.config.js
@@ -0,0 +1,13 @@
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+
+export default defineConfig({
+ plugins: [vue()],
+ server: {
+ host: '0.0.0.0',
+ port: 5173,
+ proxy: {
+ '/api': 'http://favs-api:8000'
+ }
+ }
+})