feat: add frontend Vue 3 + Vite + TypeScript project

This commit is contained in:
Stevan Freeborn
2026-07-29 16:29:15 -05:00
parent 73cd6bf1db
commit 94f113e4cc
17 changed files with 3130 additions and 0 deletions
@@ -0,0 +1,6 @@
**/.git/
**/node_modules/
**/dist/
**/.dockerignore
Dockerfile
README.md
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
@@ -0,0 +1,7 @@
FROM node:24-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
EXPOSE 3000
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>paragon-playground</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,32 @@
{
"name": "paragon-playground",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write ."
},
"dependencies": {
"vue": "^3.5.39",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^24.13.2",
"@vitejs/plugin-vue": "^6.0.7",
"@vue/tsconfig": "^0.9.1",
"eslint": "^10.8.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-vue": "^10.10.0",
"prettier": "^3.9.6",
"typescript": "~6.0.2",
"typescript-eslint": "^8.65.0",
"vite": "^8.1.1",
"vue-tsc": "^3.3.5"
}
}
@@ -0,0 +1,6 @@
<script setup lang="ts">
</script>
<template>
<router-view />
</template>
@@ -0,0 +1,7 @@
import { createApp } from 'vue';
import App from './App.vue';
import router from './router';
const app = createApp(App);
app.use(router);
app.mount('#app');
@@ -0,0 +1,29 @@
import { createRouter, createWebHistory } from 'vue-router';
import LoginPage from '../views/LoginPage.vue';
import DashboardPage from '../views/DashboardPage.vue';
import { me } from '../services/auth';
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/login', name: 'login', component: LoginPage },
{
path: '/',
name: 'dashboard',
component: DashboardPage,
meta: { requiresAuth: true },
},
],
});
router.beforeEach(async (to) => {
if (to.meta.requiresAuth) {
try {
await me();
} catch {
return { name: 'login' };
}
}
});
export default router;
@@ -0,0 +1,35 @@
const BASE_URL = import.meta.env.VITE_API_BASE ?? '/api';
function getXsrfToken(): string | null {
const match = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]*)/);
return match ? decodeURIComponent(match[1]) : null;
}
export async function api<T>(path: string, init?: RequestInit): Promise<T> {
const headers: Record<string, string> = {
...(init?.headers as Record<string, string>),
};
const xsrf = getXsrfToken();
if (xsrf) {
headers['X-XSRF-Token'] = xsrf;
}
const res = await fetch(`${BASE_URL}${path}`, {
...init,
headers,
credentials: 'include',
});
if (!res.ok) {
const body = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(body.error ?? `HTTP ${res.status}`);
}
if (res.status === 204) {
return undefined as T;
}
return res.json();
}
@@ -0,0 +1,26 @@
import { api } from './api';
export interface UserResponse {
id: string;
email: string;
displayName: string;
organizationId: string;
organizationName: string;
organizationSlug: string;
}
export async function login(email: string, password: string): Promise<UserResponse> {
return api<UserResponse>('/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
}
export async function logout(): Promise<void> {
await api<void>('/auth/logout', { method: 'POST' });
}
export async function me(): Promise<UserResponse> {
return api<UserResponse>('/auth/me');
}
@@ -0,0 +1,108 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { me, logout, type UserResponse } from '../services/auth';
const router = useRouter();
const user = ref<UserResponse | null>(null);
onMounted(async () => {
user.value = await me();
});
async function handleLogout() {
await logout();
router.push('/login');
}
</script>
<template>
<div class="dashboard">
<header>
<h1>Paragon Playground</h1>
<button class="logout" @click="handleLogout">Sign out</button>
</header>
<main v-if="user">
<section class="card">
<h2>Welcome, {{ user.displayName }}</h2>
<dl>
<dt>Email</dt>
<dd>{{ user.email }}</dd>
<dt>Organization</dt>
<dd>{{ user.organizationName }} ({{ user.organizationSlug }})</dd>
</dl>
</section>
<section class="card">
<h2>Next Steps</h2>
<p>This harness is for Paragon integration exploration.</p>
</section>
</main>
</div>
</template>
<style scoped>
.dashboard {
max-width: 800px;
margin: 0 auto;
padding: 1rem;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
header h1 {
font-size: 1.25rem;
}
.logout {
padding: 0.5rem 1rem;
background: none;
border: 1px solid #ccc;
border-radius: 4px;
cursor: pointer;
}
.card {
background: white;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
margin-bottom: 1rem;
}
.card h2 {
margin: 0 0 1rem;
font-size: 1.1rem;
}
dl dt {
font-weight: 600;
margin-top: 0.5rem;
color: #555;
}
dl dd {
margin: 0 0 0.5rem;
}
code {
background: #f0f0f0;
padding: 0.1rem 0.3rem;
border-radius: 3px;
font-size: 0.9rem;
}
ul {
padding-left: 1.25rem;
}
li {
margin-bottom: 0.5rem;
}
</style>
@@ -0,0 +1,123 @@
<script setup lang="ts">
import { ref } from "vue";
import { useRouter } from "vue-router";
import { login } from "../services/auth";
const router = useRouter();
const email = ref("");
const password = ref("");
const error = ref("");
const loading = ref(false);
async function handleSubmit() {
error.value = "";
loading.value = true;
try {
await login(email.value, password.value);
router.push("/");
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : "Login failed";
} finally {
loading.value = false;
}
}
</script>
<template>
<div class="login-container">
<form class="login-form" @submit.prevent="handleSubmit">
<h1>Paragon Playground</h1>
<p class="subtitle">Sign in to your account</p>
<div v-if="error" class="error">{{ error }}</div>
<label>
Email
<input v-model="email" type="email" required autocomplete="email" />
</label>
<label>
Password
<input
v-model="password"
type="password"
required
autocomplete="current-password"
/>
</label>
<button type="submit" :disabled="loading">
{{ loading ? "Signing in..." : "Sign in" }}
</button>
</form>
</div>
</template>
<style scoped>
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: #f5f5f5;
}
.login-form {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 400px;
}
.login-form h1 {
margin: 0 0 0.25rem;
font-size: 1.5rem;
}
.subtitle {
color: #666;
margin-bottom: 1.5rem;
}
.error {
background: #fee;
color: #c00;
padding: 0.5rem;
border-radius: 4px;
margin-bottom: 1rem;
}
label {
display: block;
margin-bottom: 1rem;
font-weight: 600;
}
input {
display: block;
width: 100%;
padding: 0.5rem;
margin-top: 0.25rem;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 1rem;
}
button {
width: 100%;
padding: 0.75rem;
background: #1a73e8;
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
}
button:disabled {
opacity: 0.6;
}
</style>
@@ -0,0 +1,15 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"types": ["vite/client"],
"allowArbitraryExtensions": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
@@ -0,0 +1,23 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"module": "nodenext",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}
@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 3000,
proxy: {
'/api': {
target: 'http://localhost:5000',
changeOrigin: true,
},
},
},
})