add:deploy stuff
This commit is contained in:
18
backend/Dockerfile
Normal file
18
backend/Dockerfile
Normal file
@@ -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"]
|
||||
|
||||
8
backend/entrypoint.sh
Normal file
8
backend/entrypoint.sh
Normal file
@@ -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
|
||||
14
deploy.sh
Normal file
14
deploy.sh
Normal file
@@ -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
|
||||
35
docker-compose.yml
Normal file
35
docker-compose.yml
Normal file
@@ -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:
|
||||
31
frontend/Dockerfile
Normal file
31
frontend/Dockerfile
Normal 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"]
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user