add:planned feats

This commit is contained in:
kokopi
2026-03-09 00:51:07 +09:00
parent 16bc00632d
commit fc611806a3
30 changed files with 950 additions and 129 deletions

View File

@@ -1,3 +1,6 @@
# Personal Support Ticket System
For demo purposes.
# Dev
`bun run dev`

View File

@@ -10,10 +10,14 @@
"db:studio": "drizzle-kit studio"
},
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "latest",
"@fastify/session": "^11.1.1",
"drizzle-orm": "^0.45.1",
"fastify": "latest",
"fastify-plugin": "latest"
"fastify-plugin": "latest",
"google-auth-library": "^10.6.1",
"unique-names-generator": "^4.7.1"
},
"devDependencies": {
"@libsql/client": "^0.17.0",

View File

@@ -1,38 +1,54 @@
import { eq } from 'drizzle-orm'
import { db } from '../db/index.ts'
import { tickets } from '../db/schema.ts'
import type { Ticket, StorageAdapter } from '../types.ts'
import type { StorageAdapter, Ticket, TicketType } from '../types.ts'
export class SQLiteAdapter implements StorageAdapter {
getTickets(): Ticket[] {
return db.select().from(tickets).all()
async getTickets(): Promise<Ticket[]> {
const rows = await db.select().from(tickets).orderBy(tickets.createdAt)
return rows.map(toTicket).reverse()
}
getTicket(id: string): Ticket | null {
return db.select().from(tickets).where(eq(tickets.id, id)).get() ?? null
async getTicket(id: string): Promise<Ticket | null> {
const rows = await db.select().from(tickets).where(eq(tickets.id, id))
return rows[0] ? toTicket(rows[0]) : null
}
createTicket({ subject, description, type }: Pick<Ticket, 'subject' | 'description' | 'type'>): Ticket {
const ticket: Ticket = {
id: crypto.randomUUID(),
subject,
description,
async createTicket(
data: Pick<Ticket, 'subject' | 'description' | 'type'> & { userId?: string }
): Promise<Ticket> {
const id = crypto.randomUUID()
const now = new Date().toISOString()
await db.insert(tickets).values({
id,
userId: data.userId ?? null,
subject: data.subject,
description: data.description,
type: data.type,
status: 'open',
type,
createdAt: new Date().toISOString(),
}
db.insert(tickets).values(ticket).run()
return ticket
createdAt: now,
})
return (await this.getTicket(id))!
}
updateTicket(id: string, patch: Partial<Ticket>): Ticket | null {
const current = this.getTicket(id)
if (!current) return null
db.update(tickets).set(patch).where(eq(tickets.id, id)).run()
return { ...current, ...patch }
async updateTicket(id: string, patch: Partial<Ticket>): Promise<Ticket | null> {
await db.update(tickets).set(patch).where(eq(tickets.id, id))
return this.getTicket(id)
}
deleteTicket(id: string): void {
db.delete(tickets).where(eq(tickets.id, id)).run()
async deleteTicket(id: string): Promise<void> {
await db.delete(tickets).where(eq(tickets.id, id))
}
}
function toTicket(row: typeof tickets.$inferSelect): Ticket {
return {
id: row.id,
userId: row.userId,
subject: row.subject,
description: row.description,
type: row.type as TicketType,
status: row.status as Ticket['status'],
createdAt: row.createdAt,
}
}

View File

@@ -23,3 +23,11 @@ export const tickets = sqliteTable("tickets", {
.default("open"),
createdAt: text("createdAt").notNull(),
});
export const users = sqliteTable('users', {
id: text('id').primaryKey(), // internal UUID
googleId: text('googleId').notNull().unique(), // Google's `sub` claim
username: text('username').notNull().unique(), // generated: "silent-crimson-falcon"
avatarUrl: text('avatarUrl'), // Google profile picture
createdAt: text('createdAt').notNull(),
})

View File

@@ -1,19 +1,38 @@
import Fastify from 'fastify'
import cors from '@fastify/cors'
import cookie from '@fastify/cookie'
import session from '@fastify/session'
import { authMiddleware } from './middleware/auth.ts'
import { storageMiddleware } from './middleware/storage.ts'
import { storageModeRouter } from './routes/storageMode.ts'
import { ticketsRouter } from './routes/tickets.ts'
import { authRouter } from './routes/auth.ts'
const app = Fastify({ logger: true })
const PORT = Number(process.env.PORT) || 3000
const PORT = Number(process.env.PORT) || 4500
await app.register(cors, {
origin: process.env.FRONTEND_URL ?? 'http://localhost:5173',
credentials: true,
})
await app.register(cookie)
await app.register(session, {
secret: process.env.SESSION_SECRET ?? 'dev-secret-change-in-production-min-32-chars!!',
cookie: {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
maxAge: 1000 * 60 * 60 * 24 * 7, // 7 days
},
})
await app.register(cors)
await app.register(authMiddleware)
await app.register(storageMiddleware)
await app.register(storageModeRouter, { prefix: '/api/storage-mode' })
await app.register(ticketsRouter, { prefix: '/api/tickets' })
await app.register(authRouter, { prefix: '/api/auth' })
app.listen({ port: PORT }, () => {
await app.listen({ port: PORT })
console.log(`Backend running on http://localhost:${PORT}`)
})

View File

@@ -1,16 +1,30 @@
import fp from 'fastify-plugin'
import type { FastifyPluginAsync } from 'fastify'
import type { User } from '../types.ts'
declare module 'fastify' {
interface FastifyRequest {
isAuthenticated: boolean
user: User | null
}
interface Session {
userId?: string
user?: User
}
}
export const authMiddleware: FastifyPluginAsync = fp(async (app) => {
app.decorateRequest('isAuthenticated', false)
app.decorateRequest('user', null)
app.addHook('onRequest', async (req) => {
// hardcoded false — replace with real session/token check when auth is implemented
const sessionUser = req.session?.user
if (sessionUser) {
req.isAuthenticated = true
req.user = sessionUser
} else {
req.isAuthenticated = false
req.user = null
}
})
})

View File

@@ -9,13 +9,11 @@ declare module 'fastify' {
}
}
const adapter: StorageAdapter = new SQLiteAdapter()
const adapter = new SQLiteAdapter()
const plugin: FastifyPluginAsync = async (app) => {
app.decorateRequest('storage', null)
app.addHook('onRequest', async (req) => {
req.storage = adapter
export const storageMiddleware: FastifyPluginAsync = fp(async (app) => {
app.decorateRequest('storage', { getter: () => adapter })
// app.addHook('onRequest', async (req) => {
// req.storage = adapter
// })
})
}
export const storageMiddleware = fp(plugin)

133
backend/src/routes/auth.ts Normal file
View File

@@ -0,0 +1,133 @@
import type { FastifyPluginAsync } from "fastify";
import { OAuth2Client } from "google-auth-library";
import { eq } from "drizzle-orm";
import {
uniqueNamesGenerator,
adjectives,
colors,
animals,
} from "unique-names-generator";
import { db } from "../db/index.ts";
import { users } from "../db/schema.ts";
import type { User } from "../types.ts";
const CLIENT_ID = process.env.GOOGLE_CLIENT_ID ?? "";
const CLIENT_SECRET = process.env.GOOGLE_CLIENT_SECRET ?? "";
const REDIRECT_URI =
process.env.GOOGLE_REDIRECT_URI ?? "http://localhost:4500/api/auth/callback";
const FRONTEND_URL = process.env.FRONTEND_URL ?? "http://localhost:5173";
const oauthClient = new OAuth2Client(CLIENT_ID, CLIENT_SECRET, REDIRECT_URI);
function generateUsername(): string {
return uniqueNamesGenerator({
dictionaries: [adjectives, animals],
separator: " ",
length: 3,
});
}
export const authRouter: FastifyPluginAsync = async (app) => {
// ── Step 1: Redirect to Google ──────────────────────────────
app.get("/google", async (_req, reply) => {
const url = oauthClient.generateAuthUrl({
access_type: "online",
// Only request openid + profile — no email scope
scope: ["openid", "profile"],
prompt: "select_account",
});
return reply.redirect(url);
});
// ── Step 2: Google redirects back here ──────────────────────
app.get<{ Querystring: { code?: string; error?: string } }>(
"/callback",
async (req, reply) => {
const { code, error } = req.query;
if (error || !code) {
return reply.redirect(`${FRONTEND_URL}?error=oauth_denied`);
}
try {
const { tokens } = await oauthClient.getToken(code);
oauthClient.setCredentials(tokens);
const ticket = await oauthClient.verifyIdToken({
idToken: tokens.id_token!,
audience: CLIENT_ID,
});
const payload = ticket.getPayload();
if (!payload?.sub) {
return reply.redirect(`${FRONTEND_URL}?error=invalid_token`);
}
const googleId = payload.sub;
const avatarUrl = payload.picture ?? null;
// Find or create user
const existing = await db
.select()
.from(users)
.where(eq(users.googleId, googleId))
.limit(1);
let user: User;
if (existing[0]) {
// Returning user — refresh avatar
await db
.update(users)
.set({ avatarUrl })
.where(eq(users.googleId, googleId));
user = {
id: existing[0].id,
googleId: existing[0].googleId,
username: existing[0].username,
avatarUrl: avatarUrl ?? existing[0].avatarUrl,
createdAt: existing[0].createdAt,
};
} else {
// New user — generate readable username
let username = generateUsername();
// Retry on collision (very rare)
const collision = await db
.select({ id: users.id })
.from(users)
.where(eq(users.username, username))
.limit(1);
if (collision[0]) {
username = `${generateUsername()}-${Math.floor(Math.random() * 999)}`;
}
const id = crypto.randomUUID();
const now = new Date().toISOString();
await db
.insert(users)
.values({ id, googleId, username, avatarUrl, createdAt: now });
user = { id, googleId, username, avatarUrl, createdAt: now };
}
req.session.user = user;
return reply.redirect(FRONTEND_URL);
} catch (err) {
app.log.error(err, "OAuth callback error");
return reply.redirect(`${FRONTEND_URL}?error=server_error`);
}
},
);
// /api/auth/me
app.get("/me", async (req, reply) => {
if (!req.isAuthenticated || !req.user) {
return reply.status(401).send({ user: null });
}
return reply.send({ user: req.user });
});
// /api/auth/logout
app.post("/logout", async (req, reply) => {
await req.session.destroy();
return reply.send({ ok: true });
});
};

View File

@@ -1,3 +1,11 @@
export interface User {
id: string
googleId: string
username: string
avatarUrl: string | null
createdAt: string
}
export type TicketType =
| "bug"
| "billing"
@@ -7,18 +15,19 @@ export type TicketType =
| "other";
export interface Ticket {
id: string;
subject: string;
description: string;
status: "open" | "in-progress" | "resolved" | "closed";
type: TicketType;
createdAt: string;
id: string
userId: string | null
subject: string
description: string
type: TicketType
status: 'open' | 'in-progress' | 'resolved' | 'closed'
createdAt: string
}
export interface StorageAdapter {
getTickets(): Ticket[];
getTicket(id: string): Ticket | null;
createTicket(data: Pick<Ticket, "subject" | "description" | "type">): Ticket;
updateTicket(id: string, patch: Partial<Ticket>): Ticket | null;
deleteTicket(id: string): void;
getTickets(): Promise<Ticket[]>
getTicket(id: string): Promise<Ticket | null>
createTicket(data: Pick<Ticket, 'subject' | 'description' | 'type'> & { userId?: string }): Promise<Ticket>
updateTicket(id: string, patch: Partial<Ticket>): Promise<Ticket | null>
deleteTicket(id: string): Promise<void>
}

View File

@@ -14,10 +14,14 @@
"backend": {
"name": "personal-support-ticket-system-backend",
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "latest",
"@fastify/session": "^11.1.1",
"drizzle-orm": "^0.45.1",
"fastify": "latest",
"fastify-plugin": "latest",
"google-auth-library": "^10.6.1",
"unique-names-generator": "^4.7.1",
},
"devDependencies": {
"@libsql/client": "^0.17.0",
@@ -33,6 +37,7 @@
"@tailwindcss/vite": "^4.2.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.1",
"tailwindcss": "^4.2.1",
},
"devDependencies": {
@@ -40,6 +45,7 @@
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
@@ -168,6 +174,8 @@
"@fastify/ajv-compiler": ["@fastify/ajv-compiler@4.0.5", "", { "dependencies": { "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0" } }, "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A=="],
"@fastify/cookie": ["@fastify/cookie@11.0.2", "", { "dependencies": { "cookie": "^1.0.0", "fastify-plugin": "^5.0.0" } }, "sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA=="],
"@fastify/cors": ["@fastify/cors@11.2.0", "", { "dependencies": { "fastify-plugin": "^5.0.0", "toad-cache": "^3.7.0" } }, "sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw=="],
"@fastify/error": ["@fastify/error@4.2.0", "", {}, "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ=="],
@@ -180,6 +188,8 @@
"@fastify/proxy-addr": ["@fastify/proxy-addr@5.1.0", "", { "dependencies": { "@fastify/forwarded": "^3.0.0", "ipaddr.js": "^2.1.0" } }, "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw=="],
"@fastify/session": ["@fastify/session@11.1.1", "", { "dependencies": { "fastify-plugin": "^5.0.1", "safe-stable-stringify": "^2.4.3" } }, "sha512-nuKwTHxh3eJsI4NJeXoYVGzXUsg+kH1WfHgS7IofVyVhmjc+A6qGr+29WQy8hYZiNtmCjfG415COpf5xTBkW4Q=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
@@ -188,6 +198,8 @@
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
@@ -228,6 +240,8 @@
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.3", "", {}, "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="],
@@ -322,6 +336,8 @@
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/history": ["@types/history@4.7.11", "", {}, "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/node": ["@types/node@24.12.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ=="],
@@ -330,6 +346,10 @@
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@types/react-router": ["@types/react-router@5.1.20", "", { "dependencies": { "@types/history": "^4.7.11", "@types/react": "*" } }, "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q=="],
"@types/react-router-dom": ["@types/react-router-dom@5.3.3", "", { "dependencies": { "@types/history": "^4.7.11", "@types/react": "*", "@types/react-router": "*" } }, "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.56.1", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/type-utils": "8.56.1", "@typescript-eslint/utils": "8.56.1", "@typescript-eslint/visitor-keys": "8.56.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.56.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A=="],
@@ -360,6 +380,8 @@
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
"ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
@@ -376,12 +398,18 @@
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="],
"bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
@@ -426,6 +454,10 @@
"drizzle-orm": ["drizzle-orm@0.45.1", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-Te0FOdKIistGNPMq2jscdqngBRfBpC8uMFVwqjf6gtTVJHIQ/dosgV/CLBU2N4ZJBsXL5savCba9b0YJskKdcA=="],
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
"electron-to-chromium": ["electron-to-chromium@1.5.307", "", {}, "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
@@ -460,6 +492,8 @@
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
@@ -494,22 +528,34 @@
"flatted": ["flatted@3.3.4", "", {}, "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA=="],
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
"formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
"frontend": ["frontend@workspace:frontend"],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"gaxios": ["gaxios@7.1.3", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "rimraf": "^5.0.1" } }, "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ=="],
"gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"get-tsconfig": ["get-tsconfig@4.13.6", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw=="],
"glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"globals": ["globals@16.5.0", "", {}, "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ=="],
"google-auth-library": ["google-auth-library@10.6.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "7.1.3", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-5awwuLrzNol+pFDmKJd0dKtZ0fPLAtoA5p7YO4ODsDu6ONJUVqbYwvv8y2ZBO5MBNp9TJXigB19710kYpBPdtA=="],
"google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
@@ -518,6 +564,8 @@
"hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
@@ -534,6 +582,8 @@
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"js-base64": ["js-base64@3.7.8", "", {}, "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow=="],
@@ -544,6 +594,8 @@
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
"json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="],
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
"json-schema-ref-resolver": ["json-schema-ref-resolver@3.0.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A=="],
@@ -554,6 +606,10 @@
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
"jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
@@ -596,6 +652,8 @@
"minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
"minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
@@ -616,12 +674,16 @@
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
"personal-support-ticket-system-backend": ["personal-support-ticket-system-backend@workspace:backend"],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
@@ -652,6 +714,10 @@
"react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
"react-router": ["react-router@7.13.1", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA=="],
"react-router-dom": ["react-router-dom@7.13.1", "", { "dependencies": { "react-router": "7.13.1" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw=="],
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
@@ -668,10 +734,14 @@
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
"rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="],
"rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="],
"rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safe-regex2": ["safe-regex2@5.0.0", "", { "dependencies": { "ret": "~0.5.0" } }, "sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw=="],
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
@@ -690,6 +760,8 @@
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="],
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
@@ -702,8 +774,12 @@
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
"supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
@@ -734,6 +810,8 @@
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"unique-names-generator": ["unique-names-generator@4.7.1", "", {}, "sha512-lMx9dX+KRmG8sq6gulYYpKWZc9RlGsgBR6aoO8Qsm3qvkSJ+3rAymr+TnV8EDMrIrwuFJ4kruzMWM/OpYzPoow=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
@@ -752,6 +830,8 @@
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
@@ -780,6 +860,12 @@
"@fastify/ajv-compiler/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
"@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
"@isaacs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
"@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
@@ -812,10 +898,14 @@
"fast-json-stringify/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
"glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
"libsql/detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="],
"light-my-request/process-warning": ["process-warning@4.0.1", "", {}, "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q=="],
"path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"personal-support-ticket-system-backend/@types/node": ["@types/node@25.3.5", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
@@ -864,6 +954,12 @@
"@fastify/ajv-compiler/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
"@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"@types/ws/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@5.0.4", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg=="],
@@ -926,6 +1022,8 @@
"fast-json-stringify/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"personal-support-ticket-system-backend/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],

View File

@@ -13,6 +13,7 @@
"@tailwindcss/vite": "^4.2.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.1",
"tailwindcss": "^4.2.1"
},
"devDependencies": {
@@ -20,6 +21,7 @@
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@types/react-router-dom": "^5.3.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",

View File

@@ -1,76 +1,79 @@
import { useState, useEffect } from 'react'
import { Modal } from './components/ui/Modal.tsx'
import { Button } from './components/ui/Button.tsx'
import { TicketTable } from './components/tickets/TicketTable.tsx'
import { NewTicketForm } from './components/tickets/NewTicketForm.tsx'
import { useModal } from './hooks/useModal.ts'
import { useStorageMode } from './hooks/useStorageMode.ts'
import { getStorage } from './lib/storage.ts'
import type { Ticket } from './lib/types.ts'
import { useState } from 'react'
import { Layout } from './components/ui/Layout.tsx'
import { PlusIcon } from './components/icons/plus.tsx'
import { Tabs } from './components/ui/Tabs.tsx'
import { UserPage } from './pages/UserPage.tsx'
import { AdminPage } from './pages/AdminPage.tsx'
import { LoginPage } from './pages/LoginPage.tsx'
import { useStorageMode } from './hooks/useStorageMode.ts'
import { useAuth } from './hooks/useAuth.ts'
import { AuthBar } from './components/ui/AuthBar.tsx'
import { BrowserRouter, Route, Routes } from 'react-router-dom'
import { NotFound } from './pages/NotFound.tsx'
function TicketApp({ storageMode }: { storageMode: 'local' | 'remote' }) {
const storage = getStorage(storageMode)
const [tickets, setTickets] = useState<Ticket[]>([])
const newTicketModal = useModal()
type TabValue = 'tickets' | 'admin'
// load tickets — handles both sync (local) and async (remote)
useEffect(() => {
const result = storage.getTickets()
if (result instanceof Promise) {
result.then(setTickets)
} else {
setTickets(result)
}
}, [storageMode])
function SupportApp() {
const [activeTab, setActiveTab] = useState<TabValue>('tickets')
const [showLogin, setShowLogin] = useState(false)
const { user, authState, logout } = useAuth()
const storageMode = useStorageMode()
const handleCreateTicket = async (form: Pick<Ticket, 'subject' | 'description' | 'type'>) => {
const result = storage.createTicket(form)
const ticket = result instanceof Promise ? await result : result
setTickets(prev => [ticket, ...prev])
newTicketModal.close()
}
const handleDeleteTicket = async (id: string) => {
const result = storage.deleteTicket(id)
if (result instanceof Promise) await result
setTickets(prev => prev.filter(t => t.id !== id))
}
const urlError = new URLSearchParams(window.location.search).get('error')
if (showLogin || urlError) {
return (
<Layout>
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-lg font-semibold text-fg-100">Support Tickets</h1>
<p className="mt-0.5 text-sm text-fg-300">
{tickets.length} {tickets.length === 1 ? 'ticket' : 'tickets'} total
</p>
</div>
<Button onClick={newTicketModal.open}>
<PlusIcon className="size-3" />
New Ticket
</Button>
</div>
<TicketTable tickets={tickets} onDelete={handleDeleteTicket} />
<Modal isOpen={newTicketModal.isOpen} onClose={newTicketModal.close} title="New Ticket">
<NewTicketForm onSubmit={handleCreateTicket} />
</Modal>
<Layout
user={user}>
<LoginPage
error={urlError}
onBack={() => {
setShowLogin(false)
window.history.replaceState({}, '', window.location.pathname)
}}
/>
</Layout>
)
}
export default function App() {
const storageMode = useStorageMode()
if (storageMode === 'pending') {
if (authState === 'pending' || storageMode === 'pending') {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="flex min-h-screen items-center justify-center bg-bg-100">
<p className="text-sm text-fg-300">Loading...</p>
</div>
)
}
return <TicketApp storageMode={storageMode} />
const isGuest = authState === 'unauthenticated'
const tabs: { value: TabValue; label: string }[] = [
{
value: 'tickets',
label: user ? `${user.username}'s Tickets` : 'My Tickets',
},
{ value: 'admin', label: 'Admin' },
]
return (
<Layout
user={user}
subHeader={
<AuthBar isGuest={isGuest} onLogin={() => setShowLogin(true)} onLogout={logout} user={user} />
}
>
<Tabs tabs={tabs} active={activeTab} onChange={setActiveTab} />
{activeTab === 'tickets' && <UserPage storageMode={storageMode} />}
{activeTab === 'admin' && <AdminPage storageMode={storageMode} />}
</Layout>
)
}
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<SupportApp />} />
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
)
}

View File

@@ -0,0 +1,64 @@
import { Badge } from '../ui/Badge.tsx'
import type { Ticket } from '../../lib/types.ts'
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString('en-US', {
month: 'short', day: 'numeric', year: 'numeric'
})
}
interface AdminTableProps {
tickets: Ticket[]
}
export function AdminTable({ tickets }: AdminTableProps) {
if (tickets.length === 0) {
return (
<div className="flex flex-col items-center justify-center rounded-lg border border-border-100 bg-bg-200 py-16 text-center">
<p className="text-sm text-fg-300">No tickets in the system.</p>
</div>
)
}
return (
<div className="overflow-hidden rounded-lg border border-border-100">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-100 bg-bg-200">
{(['Subject', 'Type', 'Status', 'Description', 'Created'] as const).map(col => (
<th
key={col}
className="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-fg-300"
>
{col}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-border-100 bg-bg-100">
{tickets.map(ticket => (
<tr key={ticket.id} className="transition-colors hover:bg-bg-200">
<td className="px-4 py-3 font-medium text-fg-100">
{ticket.subject}
</td>
<td className="px-4 py-3 text-xs capitalize text-fg-200">
{ticket.type.replace('-', ' ')}
</td>
<td className="px-4 py-3">
<Badge status={ticket.status} />
</td>
<td className="max-w-xs px-4 py-3 text-xs text-fg-300">
<span className="line-clamp-2">
{ticket.description || <span className="italic">No description</span>}
</span>
</td>
<td className="whitespace-nowrap px-4 py-3 text-xs text-fg-300">
{formatDate(ticket.createdAt)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,9 @@
import type { IconProps } from "../../lib/types.ts";
export const GithubIcon = ({ className }: IconProps) => (
<svg className={className} viewBox="0 0 24 24" fill="currentColor">
<path
d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z"
></path>
</svg>
);

View File

@@ -0,0 +1,10 @@
import type { IconProps } from "../../lib/types.ts";
export const InfoIcon = ({ className }: IconProps) => (
<svg
className={className} viewBox="0 0 14 14" fill="none"
>
<circle cx="7" cy="7" r="6" stroke="currentColor" strokeWidth="1.25" />
<path d="M7 6.5v4M7 4.5v.5" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" />
</svg>
);

View File

@@ -0,0 +1,50 @@
import type { User } from "../../lib/types"
import { GuestBanner } from "./GuestBanner"
interface AuthBarProps {
isGuest: boolean
onLogin: () => void
onLogout: () => void
user?: User | null
}
export function AuthBar({ isGuest, onLogin, onLogout, user }: AuthBarProps) {
return (
<>
{/* Auth bar — guest strip or user status strip */}
{isGuest ? (
<GuestBanner onLogin={onLogin} />
) : user ? (
<div className="w-full border-b border-border-100 bg-bg-200">
<div className="mx-auto flex max-w-4xl items-center justify-between px-6 py-2.5">
{/* Left: avatar + username */}
<div className="flex items-center gap-2">
{user.avatarUrl ? (
<img
src={user.avatarUrl}
alt={user.username}
className="h-5 w-5 rounded-full object-cover ring-1 ring-border-100"
/>
) : (
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-bg-400 ring-1 ring-border-100">
<span className="text-[10px] font-medium text-fg-200">
{user.username[0].toUpperCase()}
</span>
</div>
)}
<span className="text-xs text-fg-200">{user.username}</span>
</div>
{/* Right: sign out */}
<button
onClick={onLogout}
className="text-xs text-fg-300 transition-colors hover:text-fg-100 cursor-pointer"
>
Sign out
</button>
</div>
</div>
) : null}
</>
)
}

View File

@@ -0,0 +1,27 @@
import { InfoIcon } from "../icons/info"
interface GuestBannerProps {
onLogin: () => void
}
export function GuestBanner({ onLogin }: GuestBannerProps) {
return (
<div className="w-full border-b border-border-100 bg-bg-200">
<div className="mx-auto flex max-w-4xl items-center justify-between px-6 py-2.5">
<div className="flex items-center gap-2.5">
{/* Info icon */}
<InfoIcon className="shrink-0 text-fg-300 size-4" />
<p className="text-xs text-fg-300">
You're in guest mode tickets are stored locally in your browser.
</p>
</div>
<button
onClick={onLogin}
className="ml-4 shrink-0 rounded-md bg-bg-300 px-3 py-1.5 text-xs font-medium text-fg-100 transition-colors hover:bg-bg-400 cursor-pointer"
>
Sign in with Google
</button>
</div>
</div>
)
}

View File

@@ -1,14 +1,27 @@
import type { User } from '../../lib/types.ts'
import { Navbar } from './Navbar.tsx'
interface LayoutProps {
children: React.ReactNode
subHeader?: React.ReactNode
user?: User | null
}
export function Layout({ children }: LayoutProps) {
export function Layout({ children, subHeader }: LayoutProps) {
return (
<div className="min-h-screen bg-bg-100">
<Navbar />
<main className="mx-auto max-w-4xl px-6 py-10">
{/* Tab sub-header */}
{subHeader && (
<div className="w-full bg-bg-100">
<div className="mx-auto">
{subHeader}
</div>
</div>
)}
<main className="mx-auto max-w-4xl px-6 py-4">
{children}
</main>
</div>

View File

@@ -1,4 +1,5 @@
import { GiteaIcon } from "../icons/gitea";
import { GithubIcon } from "../icons/github";
export function Navbar() {
return (
@@ -11,7 +12,11 @@ export function Navbar() {
derrickgee.dev
</a>
<nav className="flex items-center gap-5">
<a href="https://git.kokopi.dev/kokopi/personal-support-ticket-system" className="text-xs text-fg-300 transition-colors duration-150 hover:text-fg-100">
<a href="https://github.com/kokopi-dev/personal-support-ticket-system" className="flex gap-2 items-center text-xs text-fg-300 transition-colors duration-150 hover:text-fg-100">
<GithubIcon className="size-4" />
github
</a>
<a href="https://git.kokopi.dev/kokopi/personal-support-ticket-system" className="flex gap-2 items-center text-xs text-fg-300 transition-colors duration-150 hover:text-fg-100">
<GiteaIcon className="size-4" />
gitea
</a>

View File

@@ -0,0 +1,32 @@
interface Tab<T extends string> {
value: T
label: string
}
interface TabsProps<T extends string> {
tabs: Tab<T>[]
active: T
onChange: (value: T) => void
}
export function Tabs<T extends string>({ tabs, active, onChange }: TabsProps<T>) {
return (
<div className="flex gap-0 border-b border-border-100 mb-4">
{tabs.map(tab => (
<button
key={tab.value}
onClick={() => onChange(tab.value)}
className={`
relative px-4 py-2.5 text-xs font-medium transition-colors duration-150 cursor-pointer
${active === tab.value
? 'text-fg-100 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-px after:bg-fg-100'
: 'text-fg-300 hover:text-fg-200'
}
`}
>
{tab.label}
</button>
))}
</div>
)
}

9
frontend/src/env.ts Normal file
View File

@@ -0,0 +1,9 @@
function required(key: string): string {
const value = import.meta.env[key]
if (!value) throw new Error(`Missing env variable: ${key}`)
return value
}
export const env = {
apiUrl: required('VITE_API_URL'),
}

View File

@@ -0,0 +1,35 @@
import { useState, useEffect, useCallback } from 'react'
import type { User } from '../lib/types.ts'
export type AuthState = 'pending' | 'authenticated' | 'unauthenticated'
export function useAuth() {
const [user, setUser] = useState<User | null>(null)
const [authState, setAuthState] = useState<AuthState>('pending')
useEffect(() => {
fetch('/api/auth/me', { credentials: 'include' })
.then(res => res.json())
.then(data => {
if (data.user) {
setUser(data.user)
setAuthState('authenticated')
} else {
setUser(null)
setAuthState('unauthenticated')
}
})
.catch(() => {
setUser(null)
setAuthState('unauthenticated')
})
}, [])
const logout = useCallback(async () => {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' })
setUser(null)
setAuthState('unauthenticated')
}, [])
return { user, authState, logout }
}

View File

@@ -1,4 +1,4 @@
import type { Ticket } from './types.ts'
import type { Ticket, TicketType } from './types.ts'
export type StorageMode = 'local' | 'remote'
@@ -24,10 +24,11 @@ const local = {
createTicket: (data: Pick<Ticket, 'subject' | 'description' | 'type'>): Ticket => {
const ticket: Ticket = {
id: crypto.randomUUID(),
userId: null,
subject: data.subject,
description: data.description,
type: data.type,
status: 'open',
type: 'other',
createdAt: new Date().toISOString(),
}
save([ticket, ...load()])
@@ -49,12 +50,13 @@ const local = {
const remote = {
getTickets: (): Promise<Ticket[]> =>
fetch('/api/tickets').then(r => r.json()),
fetch('/api/tickets', { credentials: 'include' }).then(r => r.json()),
createTicket: (data: Pick<Ticket, 'subject' | 'description' | 'type'>): Promise<Ticket> =>
fetch('/api/tickets', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(data),
}).then(r => r.json()),
@@ -62,11 +64,12 @@ const remote = {
fetch(`/api/tickets/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(patch),
}).then(r => r.json()),
deleteTicket: (id: string): Promise<void> =>
fetch(`/api/tickets/${id}`, { method: 'DELETE' }).then(() => undefined),
fetch(`/api/tickets/${id}`, { method: 'DELETE', credentials: 'include' }).then(() => undefined),
}
// ─── Resolver ─────────────────────────────────────────────────

View File

@@ -7,14 +7,23 @@ export type TicketType =
| "other";
export interface Ticket {
id: string;
subject: string;
description: string;
type: TicketType;
status: "open" | "in-progress" | "resolved" | "closed";
createdAt: string;
id: string
userId: string | null
subject: string
description: string
type: TicketType
status: 'open' | 'in-progress' | 'resolved' | 'closed'
createdAt: string
}
export interface IconProps {
className?: string;
}
export interface User {
id: string
googleId: string
username: string
avatarUrl: string | null
createdAt: string
}

View File

@@ -0,0 +1,62 @@
import { useState, useEffect } from 'react'
import { AdminTable } from '../components/admin/AdminTable.tsx'
import { getStorage } from '../lib/storage.ts'
import type { Ticket } from '../lib/types.ts'
interface AdminPageProps {
storageMode: 'local' | 'remote'
}
interface StatCardProps {
label: string
value: number
}
function StatCard({ label, value }: StatCardProps) {
return (
<div className="rounded-lg border border-border-100 bg-bg-200 px-4 py-3">
<p className="text-xs text-fg-300">{label}</p>
<p className="mt-1 text-2xl font-semibold text-fg-100">{value}</p>
</div>
)
}
export function AdminPage({ storageMode }: AdminPageProps) {
const storage = getStorage(storageMode)
const [tickets, setTickets] = useState<Ticket[]>([])
useEffect(() => {
const result = storage.getTickets()
if (result instanceof Promise) {
result.then(setTickets)
} else {
setTickets(result)
}
}, [storageMode])
const stats = {
total: tickets.length,
open: tickets.filter(t => t.status === 'open').length,
inProgress: tickets.filter(t => t.status === 'in-progress').length,
resolved: tickets.filter(t => t.status === 'resolved').length,
}
return (
<>
<div className="mb-6">
<h1 className="text-lg font-semibold text-fg-100">Admin</h1>
<p className="mt-0.5 text-sm text-fg-300">All tickets across the system</p>
</div>
{/* Stats row */}
<div className="mb-6 grid grid-cols-4 gap-3">
<StatCard label="Total" value={stats.total} />
<StatCard label="Open" value={stats.open} />
<StatCard label="In Progress" value={stats.inProgress} />
<StatCard label="Resolved" value={stats.resolved} />
</div>
<AdminTable tickets={tickets} />
</>
)
}

View File

@@ -0,0 +1,91 @@
import { env } from "@/env"
interface LoginPageProps {
onBack?: () => void
error?: string | null
}
export function LoginPage({ onBack, error }: LoginPageProps) {
const handleGoogleLogin = () => {
window.location.href = env.apiUrl + '/api/auth/google'
}
const errorMessage = (() => {
switch (error) {
case 'oauth_denied': return 'Sign-in was cancelled.'
case 'invalid_token': return 'Authentication failed — please try again.'
case 'server_error': return 'Something went wrong — please try again.'
default: return error ?? null
}
})()
return (
<div className="w-full max-w-sm mx-auto">
{/* Logo / wordmark */}
<div className="mb-8 text-center">
<span className="font-mono text-xl font-semibold tracking-tight text-fg-100">
Support Ticket Login
</span>
<p className="mt-1.5 text-sm text-fg-300">
The full version uses a database, and admin view shows all tickets. Create a profile with OAuth to experience the full version.
</p>
</div>
{/* Error message */}
{errorMessage && (
<div className="mb-4 rounded-lg border border-red-500/20 bg-red-500/10 px-4 py-3">
<p className="text-xs text-red-400">{errorMessage}</p>
</div>
)}
{/* Card */}
<div className="overflow-hidden rounded-xl border border-border-100 bg-bg-200">
<div className="p-6">
<button
onClick={handleGoogleLogin}
className="flex w-full items-center justify-center gap-3 rounded-lg border border-border-200 bg-bg-100 px-4 py-3 text-sm font-medium text-fg-100 transition-colors hover:bg-bg-300 cursor-pointer"
>
{/* Google "G" logo */}
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<path
d="M17.64 9.2c0-.637-.057-1.251-.164-1.84H9v3.481h4.844a4.14 4.14 0 0 1-1.796 2.716v2.259h2.908c1.702-1.567 2.684-3.875 2.684-6.615z"
fill="#4285F4"
/>
<path
d="M9 18c2.43 0 4.467-.806 5.956-2.18l-2.908-2.259c-.806.54-1.837.86-3.048.86-2.344 0-4.328-1.584-5.036-3.711H.957v2.332A8.997 8.997 0 0 0 9 18z"
fill="#34A853"
/>
<path
d="M3.964 10.71A5.41 5.41 0 0 1 3.682 9c0-.593.102-1.17.282-1.71V4.958H.957A8.996 8.996 0 0 0 0 9c0 1.452.348 2.827.957 4.042l3.007-2.332z"
fill="#FBBC05"
/>
<path
d="M9 3.58c1.321 0 2.508.454 3.44 1.345l2.582-2.58C13.463.891 11.426 0 9 0A8.997 8.997 0 0 0 .957 4.958L3.964 7.29C4.672 5.163 6.656 3.58 9 3.58z"
fill="#EA4335"
/>
</svg>
Continue with Google
</button>
</div>
<div className="border-t border-border-100 px-6 py-4">
<p className="text-center text-xs text-fg-300">
We only request your public profile no email address is stored.
</p>
</div>
</div>
{/* Back link */}
{onBack && (
<div className="mt-5 text-center">
<button
onClick={onBack}
className="text-xs text-fg-300 hover:text-fg-200 transition-colors cursor-pointer"
>
Back to guest mode
</button>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,18 @@
import { Link } from "react-router-dom"
import { Layout } from "../components/ui/Layout"
import { Button } from "../components/ui/Button"
export function NotFound() {
return (
<Layout>
<div className="mx-auto flex flex-col gap-5 w-full text-center py-8">
<h2>page not found</h2>
<Link to="/">
<Button>
Go Back
</Button>
</Link>
</div>
</Layout>
)
}

View File

@@ -0,0 +1,65 @@
import { useState, useEffect } from 'react'
import { Modal } from '../components/ui/Modal.tsx'
import { Button } from '../components/ui/Button.tsx'
import { TicketTable } from '../components/tickets/TicketTable.tsx'
import { NewTicketForm } from '../components/tickets/NewTicketForm.tsx'
import { useModal } from '../hooks/useModal.ts'
import { getStorage } from '../lib/storage.ts'
import type { Ticket } from '../lib/types.ts'
interface UserPageProps {
storageMode: 'local' | 'remote'
}
export function UserPage({ storageMode }: UserPageProps) {
const storage = getStorage(storageMode)
const [tickets, setTickets] = useState<Ticket[]>([])
const newTicketModal = useModal()
useEffect(() => {
const result = storage.getTickets()
if (result instanceof Promise) {
result.then(setTickets)
} else {
setTickets(result)
}
}, [storageMode])
const handleCreate = async (form: Pick<Ticket, 'subject' | 'description' | 'type'>) => {
const result = storage.createTicket(form)
const ticket = result instanceof Promise ? await result : result
setTickets(prev => [ticket, ...prev])
newTicketModal.close()
}
const handleDelete = async (id: string) => {
const result = storage.deleteTicket(id)
if (result instanceof Promise) await result
setTickets(prev => prev.filter(t => t.id !== id))
}
return (
<>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-lg font-semibold text-fg-100">My Tickets</h1>
<p className="mt-0.5 text-sm text-fg-300">
{tickets.length} {tickets.length === 1 ? 'ticket' : 'tickets'}
</p>
</div>
<Button onClick={newTicketModal.open}>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M7 1v12M1 7h12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
</svg>
New Ticket
</Button>
</div>
<TicketTable tickets={tickets} onDelete={handleDelete} />
<Modal isOpen={newTicketModal.isOpen} onClose={newTicketModal.close} title="New Ticket">
<NewTicketForm onSubmit={handleCreate} />
</Modal>
</>
)
}

View File

@@ -1,4 +1,10 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
},
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },

View File

@@ -1,8 +1,14 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import path from "path";
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});