diff --git a/.gitignore b/.gitignore index 827c760..754da20 100644 --- a/.gitignore +++ b/.gitignore @@ -484,3 +484,30 @@ $RECYCLE.BIN/ # Vim temporary swap files *.swp + +# --- Frontend (Vue / Vite / Bun) --- + +# Bun +bun.lockb + +# Vite build output +dist/ + +# Vite cache +.vite/ + +# TypeScript cache +*.tsbuildinfo + +# Local env variants +.env.local +.env.*.local + +# Logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +bun-debug.log* + +# Coverage +coverage/ \ No newline at end of file diff --git a/front/.editorconfig b/front/.editorconfig new file mode 100644 index 0000000..3b510aa --- /dev/null +++ b/front/.editorconfig @@ -0,0 +1,8 @@ +[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}] +charset = utf-8 +indent_size = 2 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true +end_of_line = lf +max_line_length = 100 diff --git a/front/.gitattributes b/front/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/front/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/front/.gitignore b/front/.gitignore new file mode 100644 index 0000000..cd68f14 --- /dev/null +++ b/front/.gitignore @@ -0,0 +1,39 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +.DS_Store +dist +dist-ssr +coverage +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +*.tsbuildinfo + +.eslintcache + +# Cypress +/cypress/videos/ +/cypress/screenshots/ + +# Vitest +__screenshots__/ + +# Vite +*.timestamp-*-*.mjs diff --git a/front/.oxlintrc.json b/front/.oxlintrc.json new file mode 100644 index 0000000..d5648b9 --- /dev/null +++ b/front/.oxlintrc.json @@ -0,0 +1,10 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["eslint", "typescript", "unicorn", "oxc", "vue"], + "env": { + "browser": true + }, + "categories": { + "correctness": "error" + } +} diff --git a/front/.vscode/extensions.json b/front/.vscode/extensions.json new file mode 100644 index 0000000..55ad03c --- /dev/null +++ b/front/.vscode/extensions.json @@ -0,0 +1,8 @@ +{ + "recommendations": [ + "Vue.volar", + "dbaeumer.vscode-eslint", + "EditorConfig.EditorConfig", + "oxc.oxc-vscode" + ] +} diff --git a/front/CLAUDE.md b/front/CLAUDE.md new file mode 100644 index 0000000..4c1ac73 --- /dev/null +++ b/front/CLAUDE.md @@ -0,0 +1,80 @@ +# AIPS Frontend + +Vue 3 SPA with Bootstrap 5 dark theme, authentication UI, and a service layer ready for backend integration. + +## Tech Stack +- **Vue 3** + **TypeScript** + **Vite** — Composition API (` + + diff --git a/front/package.json b/front/package.json new file mode 100644 index 0000000..59b25b8 --- /dev/null +++ b/front/package.json @@ -0,0 +1,44 @@ +{ + "name": "front", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "run-p type-check \"build-only {@}\" --", + "preview": "vite preview", + "build-only": "vite build", + "type-check": "vue-tsc --build", + "lint": "run-s lint:*", + "lint:oxlint": "oxlint . --fix", + "lint:eslint": "eslint . --fix --cache" + }, + "dependencies": { + "@popperjs/core": "^2.11.8", + "bootstrap": "^5.3.8", + "pinia": "^3.0.4", + "vue": "^3.5.27", + "vue-router": "^5.0.1" + }, + "devDependencies": { + "@tsconfig/node24": "^24.0.4", + "@types/node": "^24.10.9", + "@vitejs/plugin-vue": "^6.0.3", + "@vue/eslint-config-typescript": "^14.6.0", + "@vue/tsconfig": "^0.8.1", + "eslint": "^9.39.2", + "eslint-plugin-oxlint": "~1.42.0", + "eslint-plugin-vue": "~10.7.0", + "jiti": "^2.6.1", + "npm-run-all2": "^8.0.4", + "oxlint": "~1.42.0", + "sass-embedded": "^1.97.3", + "typescript": "~5.9.3", + "vite": "^7.3.1", + "vite-plugin-vue-devtools": "^8.0.5", + "vue-tsc": "^3.2.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } +} diff --git a/front/public/favicon.ico b/front/public/favicon.ico new file mode 100644 index 0000000..df36fcf Binary files /dev/null and b/front/public/favicon.ico differ diff --git a/front/src/App.vue b/front/src/App.vue new file mode 100644 index 0000000..9d6fbef --- /dev/null +++ b/front/src/App.vue @@ -0,0 +1,11 @@ + + + diff --git a/front/src/assets/scss/_app.scss b/front/src/assets/scss/_app.scss new file mode 100644 index 0000000..145df45 --- /dev/null +++ b/front/src/assets/scss/_app.scss @@ -0,0 +1,6 @@ +// App-level global styles + +.auth-card { + max-width: 420px; + margin: 4rem auto; +} diff --git a/front/src/assets/scss/_variables.scss b/front/src/assets/scss/_variables.scss new file mode 100644 index 0000000..00f0095 --- /dev/null +++ b/front/src/assets/scss/_variables.scss @@ -0,0 +1,26 @@ +// Bootstrap variable overrides — dark theme +$body-bg: #121212; +$body-color: #e5e7eb; + +$primary: #4f9dff; +$success: #22c55e; +$danger: #ef4444; + +$card-bg: #1e1e1e; +$card-border-color: #2a2a2a; + +$navbar-dark-color: #e5e7eb; + +$input-bg: #1e1e1e; +$input-color: #e5e7eb; +$input-border-color: #333; +$input-focus-border-color: $primary; +$input-focus-box-shadow: 0 0 0 0.2rem rgba($primary, 0.25); +$input-placeholder-color: #6b7280; + +$border-color: #2a2a2a; + +$link-color: $primary; + +$btn-close-color: #e5e7eb; +$btn-close-filter: invert(1); diff --git a/front/src/assets/scss/main.scss b/front/src/assets/scss/main.scss new file mode 100644 index 0000000..aa127f5 --- /dev/null +++ b/front/src/assets/scss/main.scss @@ -0,0 +1,3 @@ +@import 'variables'; +@import 'bootstrap/scss/bootstrap'; +@import 'app'; diff --git a/front/src/components/AppTopBar.vue b/front/src/components/AppTopBar.vue new file mode 100644 index 0000000..11b12e0 --- /dev/null +++ b/front/src/components/AppTopBar.vue @@ -0,0 +1,58 @@ + + + diff --git a/front/src/main.ts b/front/src/main.ts new file mode 100644 index 0000000..e3a679b --- /dev/null +++ b/front/src/main.ts @@ -0,0 +1,20 @@ +import './assets/scss/main.scss' + +import { createApp } from 'vue' +import { createPinia } from 'pinia' + +import App from './App.vue' +import router from './router' +import { useAuthStore } from './stores/auth' + +const app = createApp(App) + +const pinia = createPinia() +app.use(pinia) + +const auth = useAuthStore() +auth.initialize() + +app.use(router) + +app.mount('#app') diff --git a/front/src/router/index.ts b/front/src/router/index.ts new file mode 100644 index 0000000..f64d27c --- /dev/null +++ b/front/src/router/index.ts @@ -0,0 +1,47 @@ +import { createRouter, createWebHistory } from 'vue-router' +import HomeView from '../views/HomeView.vue' +import { useAuthStore } from '@/stores/auth' + +const router = createRouter({ + history: createWebHistory(import.meta.env.BASE_URL), + routes: [ + { + path: '/', + name: 'home', + component: HomeView, + meta: { requiresAuth: false }, + }, + { + path: '/about', + name: 'about', + component: () => import('../views/AboutView.vue'), + meta: { requiresAuth: false }, + }, + { + path: '/login', + name: 'login', + component: () => import('../views/LoginView.vue'), + meta: { guestOnly: true }, + }, + { + path: '/signup', + name: 'signup', + component: () => import('../views/SignupView.vue'), + meta: { guestOnly: true }, + }, + ], +}) + +router.beforeEach((to) => { + const auth = useAuthStore() + + if (to.meta.guestOnly && auth.isAuthenticated) { + return '/' + } + + if (to.meta.requiresAuth && !auth.isAuthenticated) { + return '/login' + } +}) + +export default router diff --git a/front/src/services/api.ts b/front/src/services/api.ts new file mode 100644 index 0000000..b3470e0 --- /dev/null +++ b/front/src/services/api.ts @@ -0,0 +1,37 @@ +import { useAuthStore } from '@/stores/auth' + +const BASE_URL = import.meta.env.VITE_API_URL ?? '/api' + +async function request(method: string, path: string, body?: unknown): Promise { + const auth = useAuthStore() + + const headers: Record = { + 'Content-Type': 'application/json', + } + + if (auth.token) { + headers['Authorization'] = `Bearer ${auth.token}` + } + + const res = await fetch(`${BASE_URL}${path}`, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + }) + + if (!res.ok) { + const text = await res.text() + throw new Error(text || `Request failed: ${res.status}`) + } + + if (res.status === 204) return undefined as T + + return res.json() as Promise +} + +export const api = { + get: (path: string) => request('GET', path), + post: (path: string, body?: unknown) => request('POST', path, body), + put: (path: string, body?: unknown) => request('PUT', path, body), + delete: (path: string) => request('DELETE', path), +} diff --git a/front/src/services/authService.ts b/front/src/services/authService.ts new file mode 100644 index 0000000..3d3e823 --- /dev/null +++ b/front/src/services/authService.ts @@ -0,0 +1,26 @@ +import type { AuthResponse, LoginCredentials, SignupCredentials, User } from '@/types' + +// TODO: Wire up to real API endpoints via `api` helper +// import { api } from './api' + +export const authService = { + async login(_credentials: LoginCredentials): Promise { + // TODO: return api.post('/auth/login', credentials) + throw new Error('Not implemented') + }, + + async signup(_credentials: SignupCredentials): Promise { + // TODO: return api.post('/auth/signup', credentials) + throw new Error('Not implemented') + }, + + async logout(): Promise { + // TODO: return api.post('/auth/logout') + throw new Error('Not implemented') + }, + + async getCurrentUser(): Promise { + // TODO: return api.get('/auth/me') + throw new Error('Not implemented') + }, +} diff --git a/front/src/stores/auth.ts b/front/src/stores/auth.ts new file mode 100644 index 0000000..0b686e9 --- /dev/null +++ b/front/src/stores/auth.ts @@ -0,0 +1,53 @@ +import { ref, computed } from 'vue' +import { defineStore } from 'pinia' +import type { User, LoginCredentials, SignupCredentials } from '@/types' + +const TOKEN_KEY = 'auth_token' + +export const useAuthStore = defineStore('auth', () => { + const user = ref(null) + const token = ref(null) + + const isAuthenticated = computed(() => !!user.value) + + function initialize() { + const saved = localStorage.getItem(TOKEN_KEY) + if (saved) { + token.value = saved + // TODO: call authService.getCurrentUser() to validate token & hydrate user + user.value = { username: 'User', email: 'user@example.com' } + } + } + + async function login(credentials: LoginCredentials) { + // TODO: const res = await authService.login(credentials) + // Mock successful response for now + const res = { + user: { username: credentials.email.split('@')[0] ?? credentials.email, email: credentials.email }, + token: 'mock-jwt-token', + } + user.value = res.user + token.value = res.token + localStorage.setItem(TOKEN_KEY, res.token) + } + + async function signup(credentials: SignupCredentials) { + // TODO: const res = await authService.signup(credentials) + // Mock successful response for now + const res = { + user: { username: credentials.username, email: credentials.email }, + token: 'mock-jwt-token', + } + user.value = res.user + token.value = res.token + localStorage.setItem(TOKEN_KEY, res.token) + } + + function logout() { + user.value = null + token.value = null + localStorage.removeItem(TOKEN_KEY) + } + + return { user, token, isAuthenticated, initialize, login, signup, logout } +}) diff --git a/front/src/types/index.ts b/front/src/types/index.ts new file mode 100644 index 0000000..a032564 --- /dev/null +++ b/front/src/types/index.ts @@ -0,0 +1,20 @@ +export interface User { + username: string + email: string +} + +export interface LoginCredentials { + email: string + password: string +} + +export interface SignupCredentials { + username: string + email: string + password: string +} + +export interface AuthResponse { + user: User + token: string +} diff --git a/front/src/views/AboutView.vue b/front/src/views/AboutView.vue new file mode 100644 index 0000000..484c798 --- /dev/null +++ b/front/src/views/AboutView.vue @@ -0,0 +1,3 @@ + diff --git a/front/src/views/HomeView.vue b/front/src/views/HomeView.vue new file mode 100644 index 0000000..bdd7d3e --- /dev/null +++ b/front/src/views/HomeView.vue @@ -0,0 +1,3 @@ + diff --git a/front/src/views/LoginView.vue b/front/src/views/LoginView.vue new file mode 100644 index 0000000..f332cb1 --- /dev/null +++ b/front/src/views/LoginView.vue @@ -0,0 +1,71 @@ + + + diff --git a/front/src/views/SignupView.vue b/front/src/views/SignupView.vue new file mode 100644 index 0000000..35d0f71 --- /dev/null +++ b/front/src/views/SignupView.vue @@ -0,0 +1,84 @@ + + + diff --git a/front/tsconfig.app.json b/front/tsconfig.app.json new file mode 100644 index 0000000..913b8f2 --- /dev/null +++ b/front/tsconfig.app.json @@ -0,0 +1,12 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], + "exclude": ["src/**/__tests__/*"], + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + + "paths": { + "@/*": ["./src/*"] + } + } +} diff --git a/front/tsconfig.json b/front/tsconfig.json new file mode 100644 index 0000000..66b5e57 --- /dev/null +++ b/front/tsconfig.json @@ -0,0 +1,11 @@ +{ + "files": [], + "references": [ + { + "path": "./tsconfig.node.json" + }, + { + "path": "./tsconfig.app.json" + } + ] +} diff --git a/front/tsconfig.node.json b/front/tsconfig.node.json new file mode 100644 index 0000000..822562d --- /dev/null +++ b/front/tsconfig.node.json @@ -0,0 +1,19 @@ +{ + "extends": "@tsconfig/node24/tsconfig.json", + "include": [ + "vite.config.*", + "vitest.config.*", + "cypress.config.*", + "nightwatch.conf.*", + "playwright.config.*", + "eslint.config.*" + ], + "compilerOptions": { + "noEmit": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + + "module": "ESNext", + "moduleResolution": "Bundler", + "types": ["node"] + } +} diff --git a/front/vite.config.ts b/front/vite.config.ts new file mode 100644 index 0000000..ae9cae0 --- /dev/null +++ b/front/vite.config.ts @@ -0,0 +1,25 @@ +import { fileURLToPath, URL } from 'node:url' + +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import vueDevTools from 'vite-plugin-vue-devtools' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [ + vue(), + vueDevTools(), + ], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)) + }, + }, + css: { + preprocessorOptions: { + scss: { + silenceDeprecations: ['import', 'global-builtin', 'color-functions', 'if-function'], + }, + }, + }, +})