add:deploy stuff

This commit is contained in:
2026-03-10 00:17:00 +09:00
parent 5264b81466
commit 3e3d644649
10 changed files with 211 additions and 52 deletions

31
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,31 @@
FROM oven/bun:1-alpine AS builder
WORKDIR /app
COPY package.json ./
COPY index.html ./
COPY tsconfig*.json ./
COPY vite.config.ts ./
COPY src/ ./src/
COPY public/ ./public/
# VITE_API_URL must be set at build time — Vite bakes it into the bundle
ARG VITE_API_URL
ENV VITE_API_URL=$VITE_API_URL
RUN bun install --frozen-lockfile
RUN bun run build
# ---- serve ----
FROM oven/bun:1-alpine
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./
COPY --from=builder /app/vite.config.ts ./
EXPOSE 4501
CMD ["bun", "run", "node_modules/.bin/vite", "preview", "--host", "0.0.0.0", "--port", "4501"]

View File

@@ -137,7 +137,6 @@ export function AdminTable({
{col}
</th>
))}
<th className="px-4 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-border-100 bg-bg-100">
@@ -203,14 +202,6 @@ export function AdminTable({
<td className="whitespace-nowrap px-4 py-3 text-xs text-fg-300">
{formatDate(ticket.createdAt)}
</td>
<td className="px-4 py-3 text-right">
<Button
variant="ghost"
onClick={e => { e.stopPropagation(); onOpen(ticket) }}
>
Open
</Button>
</td>
</tr>
)
})}
@@ -225,7 +216,7 @@ export function AdminTable({
<div className="flex items-center gap-2">
<Button variant="ghost" onClick={onPrev} disabled={page <= 1}>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
<path d="M9 3L5 7l4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M9 3L5 7l4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
Prev
</Button>
@@ -235,7 +226,7 @@ export function AdminTable({
<Button variant="ghost" onClick={onNext} disabled={page >= totalPages}>
Next
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true">
<path d="M5 3l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M5 3l4 4-4 4" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</Button>
</div>

View File

@@ -91,7 +91,8 @@ function HoldButton({ onComplete, label, completingLabel, icon, ariaLabel }: Hol
onTouchEnd={cancel}
disabled={completing}
className={`
relative inline-flex items-center gap-2 rounded-md px-3 py-1.5 text-sm
relative inline-flex w-full items-center justify-center gap-2 rounded-md px-3 py-2.5 text-sm
sm:w-auto sm:py-1.5
select-none transition-colors duration-150 cursor-pointer
disabled:opacity-40 disabled:cursor-not-allowed
${isHolding
@@ -198,6 +199,7 @@ interface TicketDetailProps {
onCloseTicket?: (id: string) => Promise<void>
onDeleteTicket?: (id: string) => Promise<void>
onReopenTicket?: (id: string) => Promise<void>
onStatusChange?: (id: string, status: Ticket['status']) => Promise<void>
}
export function TicketDetail({
@@ -208,17 +210,19 @@ export function TicketDetail({
onCloseTicket,
onDeleteTicket,
onReopenTicket,
onStatusChange,
}: TicketDetailProps) {
const { txnId, body } = parseDescription(ticket.description)
const txn = txnId ? FAKE_TRANSACTIONS.find(t => t.id === txnId) ?? null : null
const isClosed = ticket.status === 'closed'
const hasAnyAction = onCloseTicket || onReopenTicket || onDeleteTicket
const hasAnyAction = onCloseTicket || onReopenTicket || onDeleteTicket || onStatusChange
const REPLY_LIMIT = 20
const [replies, setReplies] = useState<Reply[]>([])
const [repliesLoading, setRepliesLoading] = useState(true)
const [replyLimitHit, setReplyLimitHit] = useState(false)
const [statusChanging, setStatusChanging] = useState(false)
const threadEndRef = useRef<HTMLDivElement>(null)
const atReplyLimit = replies.length >= REPLY_LIMIT || replyLimitHit
@@ -248,12 +252,60 @@ export function TicketDetail({
}
}
const handleStatusChange = async (e: React.ChangeEvent<HTMLSelectElement>) => {
if (!onStatusChange) return
setStatusChanging(true)
try {
await onStatusChange(ticket.id, e.target.value as Ticket['status'])
} finally {
setStatusChanging(false)
}
}
const STATUS_OPTIONS: { value: Ticket['status']; label: string }[] = [
{ value: 'open', label: 'Open' },
{ value: 'in-progress', label: 'In Progress' },
{ value: 'resolved', label: 'Resolved' },
{ value: 'closed', label: 'Closed' },
]
return (
<div className="flex flex-col gap-4 max-h-[75vh]">
{/* Status + meta row */}
<div className="flex items-center gap-2 flex-wrap shrink-0">
<Badge status={ticket.status} />
{onStatusChange ? (
<div className="relative">
{(() => {
const statusStyles: Record<Ticket['status'], string> = {
'open': 'bg-blue-950/60 text-blue-400 border-blue-900/60',
'in-progress': 'bg-amber-950/60 text-amber-400 border-amber-900/60',
'resolved': 'bg-green-950/60 text-green-400 border-green-900/60',
'closed': 'bg-bg-300 text-fg-300 border-border-100',
}
return (
<select
value={ticket.status}
onChange={handleStatusChange}
disabled={statusChanging}
className={`appearance-none rounded-full border pl-2.5 pr-6 py-0.5 text-xs font-medium cursor-pointer transition-colors outline-none disabled:opacity-50 focus:ring-1 focus:ring-ring-100 ${statusStyles[ticket.status]}`}
>
{STATUS_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
)
})()}
<svg
className="pointer-events-none absolute right-1.5 top-1/2 -translate-y-1/2 text-fg-300"
width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true"
>
<path d="M2 3.5l3 3 3-3" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
) : (
<Badge status={ticket.status} />
)}
<span className="text-xs text-fg-300">·</span>
<span className="text-xs text-fg-200 capitalize">{TYPE_LABELS[ticket.type]}</span>
<span className="text-xs text-fg-300">·</span>
@@ -330,9 +382,9 @@ export function TicketDetail({
)}
{/* Footer: ticket ID + actions */}
<div className="flex items-center justify-between border-t border-border-100 pt-3 shrink-0">
<p className="text-xs text-fg-300 font-mono">ID: {ticket.id}</p>
<div className="flex items-center gap-1">
<div className="flex flex-col gap-2 border-t border-border-100 pt-3 shrink-0 sm:flex-row sm:items-center sm:justify-between">
<p className="text-[11px] text-fg-300/60 font-mono hidden sm:block">ID: {ticket.id}</p>
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-end">
{!hasAnyAction && (
<span className="text-xs text-fg-300 italic">Read only</span>
)}
@@ -369,6 +421,7 @@ export function TicketDetail({
/>
)}
</div>
<p className="text-[11px] text-fg-300/60 font-mono sm:hidden">ID: {ticket.id}</p>
</div>
</div>

View File

@@ -1,5 +1,4 @@
import { Badge } from '../ui/Badge.tsx'
import { Button } from '../ui/Button.tsx'
import type { Ticket } from '../../lib/types.ts'
function formatDate(iso: string): string {
@@ -29,9 +28,9 @@ export function TicketTable({ tickets, onOpen }: TicketTableProps) {
<thead>
<tr className="border-b border-border-100 bg-bg-200">
<th className="px-4 py-3 text-left text-xs font-medium text-fg-300 uppercase tracking-wider">Subject</th>
<th className="px-4 py-3 text-left text-xs font-medium text-fg-300 uppercase tracking-wider">Type</th>
<th className="px-4 py-3 text-left text-xs font-medium text-fg-300 uppercase tracking-wider">Status</th>
<th className="px-4 py-3 text-left text-xs font-medium text-fg-300 uppercase tracking-wider">Created</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-border-100 bg-bg-100">
@@ -42,16 +41,9 @@ export function TicketTable({ tickets, onOpen }: TicketTableProps) {
onClick={() => onOpen(ticket)}
>
<td className="px-4 py-3 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="px-4 py-3 text-xs text-fg-300">{formatDate(ticket.createdAt)}</td>
<td className="px-4 py-3 text-right">
<Button
variant="ghost"
onClick={e => { e.stopPropagation(); onOpen(ticket) }}
>
Open
</Button>
</td>
</tr>
))}
</tbody>

View File

@@ -26,24 +26,28 @@ export function Modal({ isOpen, onClose, title, children }: ModalProps) {
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
className="fixed inset-0 z-50 flex items-end justify-center sm:items-center sm:p-4"
aria-modal="true"
role="dialog"
aria-labelledby="modal-title"
>
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
<div className="relative z-10 w-full max-w-2xl rounded-lg border border-border-100 bg-bg-200 shadow-2xl">
<div className="flex items-center justify-between border-b border-border-100 px-5 py-4">
<h2 id="modal-title" className="text-sm font-semibold text-fg-100">{title}</h2>
<div className="relative z-10 w-full max-w-2xl sm:rounded-lg rounded-t-xl border border-border-100 bg-bg-200 shadow-2xl max-h-[92dvh] sm:max-h-[85dvh] flex flex-col">
{/* Drag handle — mobile only */}
<div className="flex justify-center pt-3 pb-1 sm:hidden shrink-0">
<div className="h-1 w-10 rounded-full bg-border-200" />
</div>
<div className="flex items-center justify-between border-b border-border-100 px-5 py-3.5 shrink-0">
<h2 id="modal-title" className="text-sm font-semibold text-fg-100 truncate pr-4">{title}</h2>
<button
onClick={onClose}
className="rounded-md p-1 text-fg-300 transition-colors hover:bg-bg-300 hover:text-fg-100 cursor-pointer"
className="shrink-0 rounded-md p-1 text-fg-300 transition-colors hover:bg-bg-300 hover:text-fg-100 cursor-pointer"
aria-label="Close"
>
<CloseIcon className="size-5" />
</button>
</div>
<div className="px-5 py-4">{children}</div>
<div className="px-5 py-4 overflow-y-auto">{children}</div>
</div>
</div>,
document.body

View File

@@ -18,21 +18,21 @@ function StatCard({ label, value }: { label: string; value: number }) {
}
const STATUS_OPTIONS: { value: Ticket['status'] | ''; label: string }[] = [
{ value: '', label: 'All statuses' },
{ value: 'open', label: 'Open' },
{ value: '', label: 'All statuses' },
{ value: 'open', label: 'Open' },
{ value: 'in-progress', label: 'In progress' },
{ value: 'resolved', label: 'Resolved' },
{ value: 'closed', label: 'Closed' },
{ value: 'resolved', label: 'Resolved' },
{ value: 'closed', label: 'Closed' },
]
const TYPE_OPTIONS: { value: Ticket['type'] | ''; label: string }[] = [
{ value: '', label: 'All types' },
{ value: 'bug', label: 'Bug' },
{ value: 'billing', label: 'Billing' },
{ value: 'account', label: 'Account' },
{ value: '', label: 'All types' },
{ value: 'bug', label: 'Bug' },
{ value: 'billing', label: 'Billing' },
{ value: 'account', label: 'Account' },
{ value: 'feature-request', label: 'Feature request' },
{ value: 'feedback', label: 'Feedback' },
{ value: 'other', label: 'Other' },
{ value: 'feedback', label: 'Feedback' },
{ value: 'other', label: 'Other' },
]
const selectClass = `
@@ -91,8 +91,8 @@ function FilterBar({ filters, isAuthenticated, onChange }: FilterBarProps) {
`}
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<circle cx="6" cy="4" r="2.5" stroke="currentColor" strokeWidth="1.3"/>
<path d="M1.5 10.5c0-2.21 2.015-4 4.5-4s4.5 1.79 4.5 4" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round"/>
<circle cx="6" cy="4" r="2.5" stroke="currentColor" strokeWidth="1.3" />
<path d="M1.5 10.5c0-2.21 2.015-4 4.5-4s4.5 1.79 4.5 4" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
</svg>
My tickets
</button>
@@ -118,7 +118,7 @@ function ChevronIcon() {
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"/>
<path d="M2 4l4 4 4-4" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
@@ -154,10 +154,10 @@ export function AdminPage({ isAuthenticated, user }: AdminPageProps) {
}
const stats = {
total: result.total,
open: result.data.filter(t => t.status === 'open').length,
total: result.total,
open: result.data.filter(t => t.status === 'open').length,
inProgress: result.data.filter(t => t.status === 'in-progress').length,
resolved: result.data.filter(t => t.status === 'resolved').length,
resolved: result.data.filter(t => t.status === 'resolved').length,
}
const handleOpen = (ticket: Ticket) => {
@@ -204,6 +204,18 @@ export function AdminPage({ isAuthenticated, user }: AdminPageProps) {
}
}
const handleStatusChange = async (id: string, status: Ticket['status']) => {
try {
const updated = await storage.updateTicket(isAuthenticated, id, { status })
if (updated) {
setResult(prev => ({ ...prev, data: prev.data.map(t => t.id === id ? updated : t) }))
setSelectedTicket(updated)
}
} catch {
setActionError('Failed to update ticket status. Please try again.')
}
}
const handleDeleteTicket = async (id: string) => {
try {
await storage.deleteTicket(isAuthenticated, id)
@@ -241,10 +253,10 @@ export function AdminPage({ isAuthenticated, user }: AdminPageProps) {
</div>
<div className="mb-6 grid grid-cols-4 gap-3">
<StatCard label="Total" value={stats.total} />
<StatCard label="Open" value={stats.open} />
<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} />
<StatCard label="Resolved" value={stats.resolved} />
</div>
<FilterBar
@@ -317,6 +329,7 @@ export function AdminPage({ isAuthenticated, user }: AdminPageProps) {
onCloseTicket={canModify(selectedTicket) ? handleCloseTicket : undefined}
onReopenTicket={canModify(selectedTicket) ? handleReopenTicket : undefined}
onDeleteTicket={canModify(selectedTicket) ? handleDeleteTicket : undefined}
onStatusChange={canModify(selectedTicket) ? handleStatusChange : undefined}
/>
</>
)}