add:planned feats
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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])
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
function SupportApp() {
|
||||
const [activeTab, setActiveTab] = useState<TabValue>('tickets')
|
||||
const [showLogin, setShowLogin] = useState(false)
|
||||
const { user, authState, logout } = useAuth()
|
||||
const storageMode = useStorageMode()
|
||||
|
||||
if (storageMode === 'pending') {
|
||||
const urlError = new URLSearchParams(window.location.search).get('error')
|
||||
|
||||
if (showLogin || urlError) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Layout
|
||||
user={user}>
|
||||
<LoginPage
|
||||
error={urlError}
|
||||
onBack={() => {
|
||||
setShowLogin(false)
|
||||
window.history.replaceState({}, '', window.location.pathname)
|
||||
}}
|
||||
/>
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
if (authState === 'pending' || storageMode === 'pending') {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
64
frontend/src/components/admin/AdminTable.tsx
Normal file
64
frontend/src/components/admin/AdminTable.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
9
frontend/src/components/icons/github.tsx
Normal file
9
frontend/src/components/icons/github.tsx
Normal 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>
|
||||
);
|
||||
10
frontend/src/components/icons/info.tsx
Normal file
10
frontend/src/components/icons/info.tsx
Normal 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>
|
||||
);
|
||||
50
frontend/src/components/ui/AuthBar.tsx
Normal file
50
frontend/src/components/ui/AuthBar.tsx
Normal 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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
27
frontend/src/components/ui/GuestBanner.tsx
Normal file
27
frontend/src/components/ui/GuestBanner.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
32
frontend/src/components/ui/Tabs.tsx
Normal file
32
frontend/src/components/ui/Tabs.tsx
Normal 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
9
frontend/src/env.ts
Normal 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'),
|
||||
}
|
||||
35
frontend/src/hooks/useAuth.ts
Normal file
35
frontend/src/hooks/useAuth.ts
Normal 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 }
|
||||
}
|
||||
@@ -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 ─────────────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
62
frontend/src/pages/AdminPage.tsx
Normal file
62
frontend/src/pages/AdminPage.tsx
Normal 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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
91
frontend/src/pages/LoginPage.tsx
Normal file
91
frontend/src/pages/LoginPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
18
frontend/src/pages/NotFound.tsx
Normal file
18
frontend/src/pages/NotFound.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
65
frontend/src/pages/UserPage.tsx
Normal file
65
frontend/src/pages/UserPage.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user