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,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>
)
}

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>
</>
)
}