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 @@ + + + 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 @@ + + + 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' + } + } +})