From 3e3d64464993bc017b5b24f6f75718979c54dd48 Mon Sep 17 00:00:00 2001 From: kokopi Date: Tue, 10 Mar 2026 00:17:00 +0900 Subject: [PATCH] add:deploy stuff --- backend/Dockerfile | 18 +++++ backend/entrypoint.sh | 8 +++ deploy.sh | 14 ++++ docker-compose.yml | 35 ++++++++++ frontend/Dockerfile | 31 +++++++++ frontend/src/components/admin/AdminTable.tsx | 13 +--- .../src/components/tickets/TicketDetail.tsx | 65 +++++++++++++++++-- .../src/components/tickets/TicketTable.tsx | 12 +--- frontend/src/components/ui/Modal.tsx | 16 +++-- frontend/src/pages/AdminPage.tsx | 51 +++++++++------ 10 files changed, 211 insertions(+), 52 deletions(-) create mode 100644 backend/Dockerfile create mode 100644 backend/entrypoint.sh create mode 100644 deploy.sh create mode 100644 docker-compose.yml create mode 100644 frontend/Dockerfile diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..22cddce --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,18 @@ +FROM oven/bun:1-alpine + +WORKDIR /app + +COPY package.json ./ +COPY drizzle.config.ts ./ +COPY drizzle/ ./drizzle/ +COPY src/ ./src/ + +RUN bun install --frozen-lockfile + +COPY entrypoint.sh ./ +RUN chmod +x entrypoint.sh + +EXPOSE 4500 + +ENTRYPOINT ["./entrypoint.sh"] + diff --git a/backend/entrypoint.sh b/backend/entrypoint.sh new file mode 100644 index 0000000..41f5901 --- /dev/null +++ b/backend/entrypoint.sh @@ -0,0 +1,8 @@ +#!/bin/bash +set -e + +echo "Running database migrations..." +bun run db:migrate + +echo "Starting server..." +exec bun run src/index.ts diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 0000000..bd4071e --- /dev/null +++ b/deploy.sh @@ -0,0 +1,14 @@ +#!/bin/bash +set -e + +echo "Pulling latest code..." +git pull + +echo "Building and restarting containers..." +docker compose up -d --build + +echo "Cleaning up old images..." +docker image prune -f + +echo "Done. Status:" +docker compose ps diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..addaf65 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,35 @@ +services: + backend: + build: + context: ./backend + ports: + - "4500:4500" + env_file: + - ./backend/.env + environment: + # These override anything in .env so they are always correct for Docker + NODE_ENV: production + HOST: "0.0.0.0" + DB_PATH: /app/data/app.db + volumes: + - db_data:/app/data + restart: unless-stopped + + frontend: + build: + context: ./frontend + args: + # VITE_API_URL is read from frontend/.env and baked into the bundle + # at build time by Vite. It must be the URL the browser uses to reach + # the backend — not an internal Docker hostname. + VITE_API_URL: ${VITE_API_URL} + env_file: + - ./frontend/.env + ports: + - "4501:4501" + depends_on: + - backend + restart: unless-stopped + +volumes: + db_data: diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..28a367a --- /dev/null +++ b/frontend/Dockerfile @@ -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"] diff --git a/frontend/src/components/admin/AdminTable.tsx b/frontend/src/components/admin/AdminTable.tsx index b30b81c..8f58025 100644 --- a/frontend/src/components/admin/AdminTable.tsx +++ b/frontend/src/components/admin/AdminTable.tsx @@ -137,7 +137,6 @@ export function AdminTable({ {col} ))} - @@ -203,14 +202,6 @@ export function AdminTable({ {formatDate(ticket.createdAt)} - - - ) })} @@ -225,7 +216,7 @@ export function AdminTable({
@@ -235,7 +226,7 @@ export function AdminTable({
diff --git a/frontend/src/components/tickets/TicketDetail.tsx b/frontend/src/components/tickets/TicketDetail.tsx index 1dc18c8..06dfc2a 100644 --- a/frontend/src/components/tickets/TicketDetail.tsx +++ b/frontend/src/components/tickets/TicketDetail.tsx @@ -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 onDeleteTicket?: (id: string) => Promise onReopenTicket?: (id: string) => Promise + onStatusChange?: (id: string, status: Ticket['status']) => Promise } 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([]) const [repliesLoading, setRepliesLoading] = useState(true) const [replyLimitHit, setReplyLimitHit] = useState(false) + const [statusChanging, setStatusChanging] = useState(false) const threadEndRef = useRef(null) const atReplyLimit = replies.length >= REPLY_LIMIT || replyLimitHit @@ -248,12 +252,60 @@ export function TicketDetail({ } } + const handleStatusChange = async (e: React.ChangeEvent) => { + 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 (
{/* Status + meta row */}
- + {onStatusChange ? ( +
+ {(() => { + const statusStyles: Record = { + '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 ( + + ) + })()} + +
+ ) : ( + + )} · {TYPE_LABELS[ticket.type]} · @@ -330,9 +382,9 @@ export function TicketDetail({ )} {/* Footer: ticket ID + actions */} -
-

ID: {ticket.id}

-
+
+

ID: {ticket.id}

+
{!hasAnyAction && ( Read only )} @@ -369,6 +421,7 @@ export function TicketDetail({ /> )}
+

ID: {ticket.id}

diff --git a/frontend/src/components/tickets/TicketTable.tsx b/frontend/src/components/tickets/TicketTable.tsx index 9b466e1..30dfa7e 100644 --- a/frontend/src/components/tickets/TicketTable.tsx +++ b/frontend/src/components/tickets/TicketTable.tsx @@ -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) { Subject + Type Status Created - @@ -42,16 +41,9 @@ export function TicketTable({ tickets, onOpen }: TicketTableProps) { onClick={() => onOpen(ticket)} > {ticket.subject} + {ticket.type.replace('-', ' ')} {formatDate(ticket.createdAt)} - - - ))} diff --git a/frontend/src/components/ui/Modal.tsx b/frontend/src/components/ui/Modal.tsx index 1fde16e..2a07537 100644 --- a/frontend/src/components/ui/Modal.tsx +++ b/frontend/src/components/ui/Modal.tsx @@ -26,24 +26,28 @@ export function Modal({ isOpen, onClose, title, children }: ModalProps) { return createPortal(
-
-
- +
+ {/* Drag handle — mobile only */} +
+
+
+
+
-
{children}
+
{children}
, document.body diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index a87ded7..e3a4641 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -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) { `} > My tickets @@ -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" > - + ) } @@ -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) {
- - + + - +
)}