add:infobar

This commit is contained in:
2026-03-10 17:28:15 +09:00
parent 237a5a7862
commit b8195ad257
7 changed files with 206 additions and 107 deletions

View File

@@ -9,6 +9,7 @@ import { useAuth } from './hooks/useAuth.ts'
import { AuthBar } from './components/ui/AuthBar.tsx' import { AuthBar } from './components/ui/AuthBar.tsx'
import { BrowserRouter, Route, Routes } from 'react-router-dom' import { BrowserRouter, Route, Routes } from 'react-router-dom'
import { NotFound } from './pages/NotFound.tsx' import { NotFound } from './pages/NotFound.tsx'
import { InfoBar } from './components/ui/InfoBar.tsx'
type TabValue = 'tickets' | 'admin' | 'stats' type TabValue = 'tickets' | 'admin' | 'stats'
@@ -50,7 +51,10 @@ function SupportApp() {
return ( return (
<Layout <Layout
subHeader={ subHeader={
<>
<AuthBar user={user} onLogin={() => setShowLogin(true)} onLogout={logout} /> <AuthBar user={user} onLogin={() => setShowLogin(true)} onLogout={logout} />
<InfoBar user={user} />
</>
} }
> >
<Tabs tabs={tabs} active={activeTab} onChange={setActiveTab} /> <Tabs tabs={tabs} active={activeTab} onChange={setActiveTab} />

View File

@@ -115,7 +115,8 @@ export function AdminTable({
const hasBilling = tickets.some(t => t.type === 'billing') const hasBilling = tickets.some(t => t.type === 'billing')
return ( return (
<div className="overflow-hidden rounded-lg border border-border-100"> <div className="rounded-lg border border-border-100 overflow-hidden">
<div className="overflow-x-auto overflow-y-hidden">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-border-100 bg-bg-200"> <tr className="border-b border-border-100 bg-bg-200">
@@ -207,6 +208,7 @@ export function AdminTable({
})} })}
</tbody> </tbody>
</table> </table>
</div>
{/* Pagination footer */} {/* Pagination footer */}
<div className="flex items-center justify-between border-t border-border-100 bg-bg-200 px-4 py-3"> <div className="flex items-center justify-between border-t border-border-100 bg-bg-200 px-4 py-3">

View File

@@ -0,0 +1,7 @@
import type { IconProps } from "../../lib/types.ts";
export const ChevronIcon = ({ className }: IconProps) => (
<svg className={className} viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M2 4l4 4 4-4" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);

View File

@@ -23,7 +23,7 @@ export function TicketTable({ tickets, onOpen }: TicketTableProps) {
} }
return ( return (
<div className="overflow-hidden rounded-lg border border-border-100"> <div className="overflow-x-scroll overflow-y-hidden rounded-lg border border-border-100">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-border-100 bg-bg-200"> <tr className="border-b border-border-100 bg-bg-200">

View File

@@ -14,7 +14,7 @@ interface BadgeProps {
export function Badge({ status }: BadgeProps) { export function Badge({ status }: BadgeProps) {
return ( return (
<span className={` <span className={`
inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium capitalize inline-flex whitespace-nowrap items-center rounded-full border px-2 py-0.5 text-xs font-medium capitalize
${variants[status]} ${variants[status]}
`}> `}>
{status} {status}

View File

@@ -0,0 +1,96 @@
import { useState } from "react"
import type { User } from "../../lib/types"
import { InfoIcon } from "../icons/info"
import { CloseIcon } from "../icons/close"
const STORAGE_KEY = "info_bar_dismissed"
interface InfoBarProps {
user: User | null
}
export function InfoBar({ user }: InfoBarProps) {
const [open, setOpen] = useState(
() => localStorage.getItem(STORAGE_KEY) !== "true"
)
function toggle() {
const next = !open
setOpen(next)
if (!next) {
localStorage.setItem(STORAGE_KEY, "true")
} else {
localStorage.removeItem(STORAGE_KEY)
}
}
const isGuest = user === null
return (
<div className="mb-4 rounded-lg border border-border-100 bg-bg-200 overflow-hidden">
{/* Header row — always visible */}
<div className="max-w-4xl mx-auto flex items-center justify-between px-6 py-2.5" onClick={toggle}>
<div className="flex items-center gap-2">
<InfoIcon className="shrink-0 size-4 text-fg-300" />
<span className="text-xs font-medium text-fg-200">
{isGuest ? "You're in guest mode" : "How this app works"}
</span>
</div>
<button
onClick={toggle}
className="flex items-center gap-1.5 text-xs text-fg-300 transition-colors hover:text-fg-100 cursor-pointer"
aria-expanded={open}
>
{open ? (
<>
<CloseIcon className="size-3" />
<span>Hide</span>
</>
) : (
<span>Show info</span>
)}
</button>
</div>
{/* Collapsible body */}
<div
className={`transition-[max-height,opacity] duration-200 ease-in-out overflow-hidden ${open ? "max-h-64 opacity-100" : "max-h-0 opacity-0"
}`}
onClick={toggle}
>
<div className="border-t border-border-100 py-3">
<div className="max-w-4xl px-6 mx-auto">
{isGuest ? (
<div className="flex flex-col sm:flex-row gap-3 sm:gap-5 px-2">
<div>
<p className="text-xs font-medium text-fg-200">Guest mode</p>
<p className="text-xs text-fg-300">
Tickets are stored in your browser (localStorage) only.
No sync across devices or sessions.
Admin panel is not available.
</p>
</div>
<div>
<p className="text-xs font-medium text-fg-200">After signing in</p>
<p className="text-xs text-fg-300">
Tickets are saved to the server persistently.
Admin panel: view all users' tickets.
You can only edit or manage your own tickets.
</p>
</div>
</div>
) : (
<div className="flex flex-col sm:flex-row gap-3 sm:gap-5 px-2">
<div>
<p className="text-xs font-medium text-fg-200">Admin Tab</p>
<p className="text-xs text-fg-300">
You can view all users' tickets in the Admin tab. Useful for monitoring support requests
</p>
</div>
<div>
<p className="text-xs font-medium text-fg-200 pr-1">Editing</p>
<p className="text-xs text-fg-300">
You can only edit or manage your own tickets. Other users' tickets are read-only for you
</p>
</div>
</div>
)}
</div>
</div>
</div>
</div>
)
}

View File

@@ -7,11 +7,12 @@ import type { PaginatedResponse, TicketFilters } from '../lib/storage.ts'
import { useModal } from '../hooks/useModal.ts' import { useModal } from '../hooks/useModal.ts'
import type { Ticket, User } from '../lib/types.ts' import type { Ticket, User } from '../lib/types.ts'
import { Button } from '../components/ui/Button.tsx' import { Button } from '../components/ui/Button.tsx'
import { ChevronIcon } from '../components/icons/chevronIcon.tsx'
function StatCard({ label, value }: { label: string; value: number }) { function StatCard({ label, value }: { label: string; value: number }) {
return ( return (
<div className="rounded-lg border border-border-100 bg-bg-200 px-4 py-3"> <div className="rounded-lg border border-border-100 bg-bg-200 sm:px-4 px-1.5 sm:py-3 py-1.5 overflow-hidden">
<p className="text-xs text-fg-300">{label}</p> <p className="text-xs text-fg-300 truncate">{label}</p>
<p className="mt-1 text-2xl font-semibold text-fg-100">{value}</p> <p className="mt-1 text-2xl font-semibold text-fg-100">{value}</p>
</div> </div>
) )
@@ -61,7 +62,7 @@ function FilterBar({ filters, isAuthenticated, onChange }: FilterBarProps) {
> >
{STATUS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)} {STATUS_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select> </select>
<ChevronIcon /> <ChevronIcon className="size-4 pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-fg-300" />
</div> </div>
{/* Type */} {/* Type */}
@@ -73,7 +74,7 @@ function FilterBar({ filters, isAuthenticated, onChange }: FilterBarProps) {
> >
{TYPE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)} {TYPE_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
</select> </select>
<ChevronIcon /> <ChevronIcon className="size-4 pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-fg-300" />
</div> </div>
{/* Mine toggle — only visible when authenticated */} {/* Mine toggle — only visible when authenticated */}
@@ -112,17 +113,6 @@ function FilterBar({ filters, isAuthenticated, onChange }: FilterBarProps) {
) )
} }
function ChevronIcon() {
return (
<svg
className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-fg-300"
width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true"
>
<path d="M2 4l4 4 4-4" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
interface AdminPageProps { interface AdminPageProps {
isAuthenticated: boolean isAuthenticated: boolean