chore: remove unused UI components, dummy data, and theme provider

This commit is contained in:
2026-02-22 00:16:58 +07:00
parent 58d74cb8c8
commit 384ac12109
13 changed files with 948 additions and 229 deletions
+55
View File
@@ -0,0 +1,55 @@
import { createContext, useContext, useState, useEffect, type ReactNode } from "react"
import { useNavigate } from "react-router"
import { type User, getUser, getUserAsync, logout as logoutUser } from "@/lib/auth"
interface AuthContextType {
user: User | null
loading: boolean
logout: () => void
refreshUser: () => void
}
const AuthContext = createContext<AuthContextType | null>(null)
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
const navigate = useNavigate()
useEffect(() => {
async function init() {
let currentUser = getUser()
if (!currentUser) {
currentUser = await getUserAsync()
}
setUser(currentUser)
setLoading(false)
}
init()
}, [])
async function logout() {
await logoutUser()
setUser(null)
navigate("/login")
}
function refreshUser() {
const currentUser = getUser()
setUser(currentUser)
}
return (
<AuthContext.Provider value={{ user, loading, logout, refreshUser }}>
{children}
</AuthContext.Provider>
)
}
export function useAuth() {
const context = useContext(AuthContext)
if (!context) {
throw new Error("useAuth must be used within AuthProvider")
}
return context
}
+130 -8
View File
@@ -1,23 +1,92 @@
import { Link, useParams } from "react-router" import { useState, useEffect } from "react"
import { Link, useParams, useNavigate } from "react-router"
import { import {
ArrowLeft, ArrowLeft,
Calendar, Calendar,
FileText, FileText,
Lock, Lock,
MessageSquare, MessageSquare,
Pencil,
Trash2,
} from "lucide-react" } from "lucide-react"
import { dummyForms } from "@/lib/dummy-data"
import { Navbar } from "@/components/shared/navbar" import { Navbar } from "@/components/shared/navbar"
import { Footer } from "@/components/shared/footer" import { Footer } from "@/components/shared/footer"
import { FormButton } from "@/components/shared/form-button" import { FormButton } from "@/components/shared/form-button"
import { QuestionPreview } from "@/components/forms/question-preview" import { QuestionPreview } from "@/components/forms/question-preview"
import { getFormById, deleteForm } from "@/lib/api"
import { useAuth } from "@/app/context/auth-context"
import type { FormDetail } from "@/lib/types"
export default function FormPreviewPage() { export default function FormPreviewPage() {
const { id } = useParams() const { id } = useParams()
const form = dummyForms.find((f) => f.id === id) const navigate = useNavigate()
const { user } = useAuth()
const [form, setForm] = useState<FormDetail | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [deleting, setDeleting] = useState(false)
if (!form) { useEffect(() => {
throw new Response("Form not found", { status: 404 }) if (!id) return
async function fetchForm() {
try {
const data = await getFormById(id!)
setForm(data)
} catch (err) {
if (err instanceof Response && err.status === 404) {
throw err
}
setError("Failed to load form. Please try again.")
} finally {
setLoading(false)
}
}
fetchForm()
}, [id])
async function handleDelete() {
if (!id) return
setDeleting(true)
try {
await deleteForm(id)
navigate("/forms")
} catch {
setError("Failed to delete form.")
setShowDeleteConfirm(false)
} finally {
setDeleting(false)
}
}
if (loading) {
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<p className="text-muted-foreground">Loading...</p>
</div>
)
}
if (error || !form) {
return (
<div className="flex min-h-screen flex-col bg-background">
<Navbar />
<main className="flex flex-1 items-center justify-center">
<div className="text-center">
<p className="text-sm text-destructive">{error ?? "Form not found"}</p>
<Link
to="/forms"
className="mt-4 inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
>
<ArrowLeft className="h-4 w-4" />
Back to forms
</Link>
</div>
</main>
<Footer />
</div>
)
} }
return ( return (
@@ -59,17 +128,38 @@ export default function FormPreviewPage() {
</span> </span>
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<MessageSquare className="h-3.5 w-3.5" /> <MessageSquare className="h-3.5 w-3.5" />
{form.responseCount} responses {form.response_count} responses
</span> </span>
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<Calendar className="h-3.5 w-3.5" /> <Calendar className="h-3.5 w-3.5" />
Created {form.createdAt} Created {new Date(form.created_at).toLocaleDateString()}
</span> </span>
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<Calendar className="h-3.5 w-3.5" /> <Calendar className="h-3.5 w-3.5" />
Updated {form.updatedAt} Updated {new Date(form.updated_at).toLocaleDateString()}
</span> </span>
</div> </div>
{user && user.id === form.user_id && (
<div className="mt-5 flex items-center gap-3 border-t border-border pt-5">
<Link to={`/form/${id}/edit`}>
<FormButton type="button" variant="ghost" size="sm">
<Pencil className="h-4 w-4" />
Edit
</FormButton>
</Link>
<FormButton
type="button"
variant="ghost"
size="sm"
onClick={() => setShowDeleteConfirm(true)}
className="text-destructive hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
Delete
</FormButton>
</div>
)}
</div> </div>
</div> </div>
@@ -103,6 +193,38 @@ export default function FormPreviewPage() {
</div> </div>
</main> </main>
{showDeleteConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="mx-4 w-full max-w-sm rounded-xl border border-border bg-card p-6 shadow-lg">
<h2 className="text-lg font-semibold text-foreground">Delete Form</h2>
<p className="mt-2 text-sm text-muted-foreground">
Are you sure you want to delete this form? This action cannot be
undone.
</p>
<div className="mt-6 flex items-center justify-end gap-3">
<FormButton
type="button"
variant="ghost"
size="sm"
onClick={() => setShowDeleteConfirm(false)}
disabled={deleting}
>
Cancel
</FormButton>
<FormButton
type="button"
size="sm"
onClick={handleDelete}
disabled={deleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{deleting ? "Deleting..." : "Delete"}
</FormButton>
</div>
</div>
</div>
)}
<Footer /> <Footer />
</div> </div>
) )
+59 -7
View File
@@ -1,18 +1,56 @@
"use client" import { useState, useEffect } from "react"
import { useNavigate, Link } from "react-router"
import { useState } from "react"
import { Plus, Search } from "lucide-react" import { Plus, Search } from "lucide-react"
import { dummyForms } from "@/lib/dummy-data"
import { Navbar } from "@/components/shared/navbar" import { Navbar } from "@/components/shared/navbar"
import { Footer } from "@/components/shared/footer" import { Footer } from "@/components/shared/footer"
import { FormInput } from "@/components/shared/form-input" import { FormInput } from "@/components/shared/form-input"
import { FormButton } from "@/components/shared/form-button" import { FormButton } from "@/components/shared/form-button"
import { FormCard } from "@/components/forms/form-card" import { FormCard } from "@/components/forms/form-card"
import { useAuth } from "@/app/context/auth-context"
import { getForms } from "@/lib/api"
import type { FormSummary } from "@/lib/types"
export default function FormsPage() { export default function FormsPage() {
const { user, loading } = useAuth()
const navigate = useNavigate()
const [search, setSearch] = useState("") const [search, setSearch] = useState("")
const [forms, setForms] = useState<FormSummary[]>([])
const [loadingForms, setLoadingForms] = useState(true)
const [error, setError] = useState<string | null>(null)
const filtered = dummyForms.filter((form) => { useEffect(() => {
if (!loading && !user) {
navigate("/login")
}
}, [user, loading, navigate])
useEffect(() => {
if (!user) return
async function fetchForms() {
try {
const data = await getForms()
setForms(data)
} catch {
setError("Failed to load forms. Please try again.")
} finally {
setLoadingForms(false)
}
}
fetchForms()
}, [user])
if (loading || loadingForms) {
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<p className="text-muted-foreground">Loading...</p>
</div>
)
}
if (!user) return null
const filtered = forms.filter((form) => {
return ( return (
form.title.toLowerCase().includes(search.toLowerCase()) || form.title.toLowerCase().includes(search.toLowerCase()) ||
form.description.toLowerCase().includes(search.toLowerCase()) form.description.toLowerCase().includes(search.toLowerCase())
@@ -32,10 +70,12 @@ export default function FormsPage() {
Manage and preview all your forms in one place Manage and preview all your forms in one place
</p> </p>
</div> </div>
<Link to="/forms/new">
<FormButton size="md"> <FormButton size="md">
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
New Form New Form
</FormButton> </FormButton>
</Link>
</div> </div>
<div className="mb-6"> <div className="mb-6">
@@ -51,10 +91,22 @@ export default function FormsPage() {
</div> </div>
<p className="mb-4 text-sm text-muted-foreground"> <p className="mb-4 text-sm text-muted-foreground">
Showing {filtered.length} of {dummyForms.length} forms Showing {filtered.length} of {forms.length} forms
</p> </p>
{filtered.length > 0 ? ( {error ? (
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border py-16">
<p className="text-sm text-destructive">{error}</p>
<FormButton
variant="ghost"
size="sm"
className="mt-3"
onClick={() => window.location.reload()}
>
Retry
</FormButton>
</div>
) : filtered.length > 0 ? (
<div className="grid gap-5 sm:grid-cols-2"> <div className="grid gap-5 sm:grid-cols-2">
{filtered.map((form) => ( {filtered.map((form) => (
<FormCard key={form.id} form={form} /> <FormCard key={form.id} form={form} />
+13
View File
@@ -0,0 +1,13 @@
export function meta() {
return [{ title: "404 - Page Not Found" }];
}
export default function NotFound() {
return (
<div style={{ textAlign: "center", padding: "4rem" }}>
<h1>404</h1>
<p>Page not found.</p>
<a href="/">Go home</a>
</div>
);
}
+5 -9
View File
@@ -4,12 +4,12 @@ import {
FileText, FileText,
MessageSquare, MessageSquare,
} from "lucide-react" } from "lucide-react"
import type { Form } from "@/lib/dummy-data" import type { FormSummary } from "@/lib/types"
import { FormButton } from "@/components/shared/form-button" import { FormButton } from "@/components/shared/form-button"
import { Link } from "react-router"; import { Link } from "react-router";
interface FormCardProps { interface FormCardProps {
form: Form form: FormSummary
} }
export function FormCard({ form }: FormCardProps) { export function FormCard({ form }: FormCardProps) {
@@ -29,22 +29,18 @@ export function FormCard({ form }: FormCardProps) {
</p> </p>
<div className="mt-auto flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground"> <div className="mt-auto flex flex-wrap items-center gap-x-4 gap-y-1.5 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<FileText className="h-3.5 w-3.5" />
{form.questions.length} questions
</span>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<MessageSquare className="h-3.5 w-3.5" /> <MessageSquare className="h-3.5 w-3.5" />
{form.responseCount} responses {form.response_count} responses
</span> </span>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Calendar className="h-3.5 w-3.5" /> <Calendar className="h-3.5 w-3.5" />
{form.updatedAt} {new Date(form.updated_at).toLocaleDateString()}
</span> </span>
</div> </div>
<div className="mt-4 border-t border-border pt-4"> <div className="mt-4 border-t border-border pt-4">
<Link to={`/forms/${form.id}`}> <Link to={`/form/${form.id}`}>
<FormButton variant="outline" size="sm" className="w-full"> <FormButton variant="outline" size="sm" className="w-full">
<Eye className="h-3.5 w-3.5" /> <Eye className="h-3.5 w-3.5" />
Preview Form Preview Form
+285
View File
@@ -0,0 +1,285 @@
import { useState, useRef } from "react"
import {
GripVertical,
Plus,
Trash2,
ChevronDown,
} from "lucide-react"
import { FormInput } from "@/components/shared/form-input"
import type { QuestionType, CreateQuestion } from "@/lib/types"
const QUESTION_TYPES: { value: QuestionType; label: string }[] = [
{ value: "short_text", label: "Short Text" },
{ value: "long_text", label: "Long Text" },
{ value: "multiple_choice", label: "Multiple Choice" },
{ value: "checkbox", label: "Checkbox" },
{ value: "dropdown", label: "Dropdown" },
{ value: "date", label: "Date" },
{ value: "rating", label: "Rating" },
]
const TYPES_WITH_OPTIONS: QuestionType[] = ["multiple_choice", "checkbox", "dropdown"]
interface QuestionEditorProps {
questions: CreateQuestion[]
onChange: (questions: CreateQuestion[]) => void
}
function reposition(questions: CreateQuestion[]): CreateQuestion[] {
return questions.map((q, i) => ({ ...q, position: i + 1 }))
}
export function QuestionEditor({ questions, onChange }: QuestionEditorProps) {
const [dragIndex, setDragIndex] = useState<number | null>(null)
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null)
const dragNode = useRef<HTMLDivElement | null>(null)
function updateQuestion(index: number, updates: Partial<CreateQuestion>) {
const updated = questions.map((q, i) => {
if (i !== index) return q
const merged = { ...q, ...updates }
if (updates.type && !TYPES_WITH_OPTIONS.includes(merged.type)) {
merged.options = []
}
if (
updates.type &&
TYPES_WITH_OPTIONS.includes(merged.type) &&
merged.options.length === 0
) {
merged.options = [{ label: "", position: 1 }]
}
return merged
})
onChange(updated)
}
function addQuestion() {
onChange([
...questions,
{
type: "short_text",
title: "",
required: false,
position: questions.length + 1,
options: [],
},
])
}
function removeQuestion(index: number) {
if (questions.length <= 1) return
onChange(reposition(questions.filter((_, i) => i !== index)))
}
function updateOption(qIndex: number, oIndex: number, label: string) {
const updated = questions.map((q, i) => {
if (i !== qIndex) return q
const options = q.options.map((o, j) =>
j === oIndex ? { ...o, label } : o
)
return { ...q, options }
})
onChange(updated)
}
function addOption(qIndex: number) {
const updated = questions.map((q, i) => {
if (i !== qIndex) return q
return {
...q,
options: [...q.options, { label: "", position: q.options.length + 1 }],
}
})
onChange(updated)
}
function removeOption(qIndex: number, oIndex: number) {
const updated = questions.map((q, i) => {
if (i !== qIndex) return q
const options = q.options
.filter((_, j) => j !== oIndex)
.map((o, j) => ({ ...o, position: j + 1 }))
return { ...q, options }
})
onChange(updated)
}
function handleDragStart(index: number, e: React.DragEvent<HTMLDivElement>) {
setDragIndex(index)
dragNode.current = e.currentTarget
e.dataTransfer.effectAllowed = "move"
requestAnimationFrame(() => {
if (dragNode.current) {
dragNode.current.style.opacity = "0.4"
}
})
}
function handleDragOver(index: number, e: React.DragEvent<HTMLDivElement>) {
e.preventDefault()
e.dataTransfer.dropEffect = "move"
if (dragIndex === null || dragIndex === index) return
setDragOverIndex(index)
}
function handleDragEnd() {
if (dragNode.current) {
dragNode.current.style.opacity = "1"
}
if (dragIndex !== null && dragOverIndex !== null && dragIndex !== dragOverIndex) {
const reordered = [...questions]
const [moved] = reordered.splice(dragIndex, 1)
reordered.splice(dragOverIndex, 0, moved)
onChange(reposition(reordered))
}
setDragIndex(null)
setDragOverIndex(null)
dragNode.current = null
}
return (
<>
<div className="flex flex-col gap-4">
{questions.map((question, qIndex) => (
<div
key={qIndex}
draggable
onDragStart={(e) => handleDragStart(qIndex, e)}
onDragOver={(e) => handleDragOver(qIndex, e)}
onDragEnd={handleDragEnd}
onDragLeave={() => setDragOverIndex(null)}
className={`rounded-xl border bg-card p-5 shadow-sm transition-all ${
dragOverIndex === qIndex && dragIndex !== qIndex
? "border-primary ring-2 ring-primary/20"
: "border-border"
}`}
>
<div className="mb-4 flex items-start justify-between gap-3">
<div className="flex items-center gap-2">
<button
type="button"
className="cursor-grab active:cursor-grabbing rounded p-0.5 text-muted-foreground hover:text-foreground"
onMouseDown={(e) => e.stopPropagation()}
>
<GripVertical className="h-4 w-4" />
</button>
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-xs font-semibold text-primary">
{qIndex + 1}
</span>
</div>
{questions.length > 1 && (
<button
type="button"
onClick={() => removeQuestion(qIndex)}
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
<div className="flex flex-col gap-4">
<FormInput
label="Question Title"
placeholder="Enter question"
value={question.title}
onChange={(e) =>
updateQuestion(qIndex, { title: e.target.value })
}
required
/>
<div className="flex flex-wrap items-end gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-foreground">
Type
</label>
<div className="relative">
<select
value={question.type}
onChange={(e) =>
updateQuestion(qIndex, {
type: e.target.value as QuestionType,
})
}
className="appearance-none rounded-lg border border-input bg-card py-2.5 pl-3.5 pr-9 text-sm text-foreground outline-none transition-colors focus:border-primary focus:ring-2 focus:ring-primary/20"
>
{QUESTION_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
</div>
</div>
<label className="flex items-center gap-2 pb-2.5 text-sm text-muted-foreground">
<input
type="checkbox"
checked={question.required}
onChange={(e) =>
updateQuestion(qIndex, { required: e.target.checked })
}
className="h-4 w-4 rounded border-input accent-primary"
/>
Required
</label>
</div>
{TYPES_WITH_OPTIONS.includes(question.type) && (
<div className="flex flex-col gap-2">
<label className="text-sm font-medium text-foreground">
Options
</label>
{question.options.map((option, oIndex) => (
<div key={oIndex} className="flex items-center gap-2">
<span className="text-xs text-muted-foreground w-5 text-center">
{oIndex + 1}.
</span>
<input
type="text"
placeholder={`Option ${oIndex + 1}`}
value={option.label}
onChange={(e) =>
updateOption(qIndex, oIndex, e.target.value)
}
className="flex-1 rounded-lg border border-input bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground outline-none transition-colors focus:border-primary focus:ring-2 focus:ring-primary/20"
/>
{question.options.length > 1 && (
<button
type="button"
onClick={() => removeOption(qIndex, oIndex)}
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
))}
<button
type="button"
onClick={() => addOption(qIndex)}
className="mt-1 inline-flex items-center gap-1.5 self-start rounded-md px-3 py-1.5 text-sm font-medium text-primary transition-colors hover:bg-primary/10"
>
<Plus className="h-3.5 w-3.5" />
Add option
</button>
</div>
)}
</div>
</div>
))}
</div>
<button
type="button"
onClick={addQuestion}
className="mt-4 flex w-full items-center justify-center gap-2 rounded-xl border-2 border-dashed border-border py-4 text-sm font-medium text-muted-foreground transition-colors hover:border-primary hover:text-primary"
>
<Plus className="h-4 w-4" />
Add Question
</button>
</>
)
}
+7 -9
View File
@@ -3,7 +3,7 @@ import {
ChevronDown, ChevronDown,
Star, Star,
} from "lucide-react" } from "lucide-react"
import type { Question } from "@/lib/dummy-data" import type { Question } from "@/lib/types"
interface QuestionPreviewProps { interface QuestionPreviewProps {
question: Question question: Question
@@ -13,7 +13,6 @@ interface QuestionPreviewProps {
export function QuestionPreview({ question, index }: QuestionPreviewProps) { export function QuestionPreview({ question, index }: QuestionPreviewProps) {
return ( return (
<div className="rounded-xl border border-border bg-card p-5 shadow-sm"> <div className="rounded-xl border border-border bg-card p-5 shadow-sm">
{/* Question header */}
<div className="mb-4 flex items-start gap-3"> <div className="mb-4 flex items-start gap-3">
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-xs font-semibold text-primary"> <span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-xs font-semibold text-primary">
{index + 1} {index + 1}
@@ -31,7 +30,6 @@ export function QuestionPreview({ question, index }: QuestionPreviewProps) {
</div> </div>
</div> </div>
{/* Read-only field preview */}
<div className="pl-10"> <div className="pl-10">
{question.type === "short_text" && ( {question.type === "short_text" && (
<div className="rounded-lg border border-input bg-muted/50 px-3.5 py-2.5 text-sm text-muted-foreground"> <div className="rounded-lg border border-input bg-muted/50 px-3.5 py-2.5 text-sm text-muted-foreground">
@@ -45,29 +43,29 @@ export function QuestionPreview({ question, index }: QuestionPreviewProps) {
</div> </div>
)} )}
{question.type === "multiple_choice" && question.options && ( {question.type === "multiple_choice" && question.options.length > 0 && (
<div className="flex flex-col gap-2.5"> <div className="flex flex-col gap-2.5">
{question.options.map((option) => ( {question.options.map((option) => (
<label <label
key={option} key={option.id}
className="flex items-center gap-2.5 text-sm text-foreground" className="flex items-center gap-2.5 text-sm text-foreground"
> >
<span className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full border-2 border-input" /> <span className="flex h-4 w-4 shrink-0 items-center justify-center rounded-full border-2 border-input" />
{option} {option.label}
</label> </label>
))} ))}
</div> </div>
)} )}
{question.type === "checkbox" && question.options && ( {question.type === "checkbox" && question.options.length > 0 && (
<div className="flex flex-col gap-2.5"> <div className="flex flex-col gap-2.5">
{question.options.map((option) => ( {question.options.map((option) => (
<label <label
key={option} key={option.id}
className="flex items-center gap-2.5 text-sm text-foreground" className="flex items-center gap-2.5 text-sm text-foreground"
> >
<span className="flex h-4 w-4 shrink-0 items-center justify-center rounded border-2 border-input" /> <span className="flex h-4 w-4 shrink-0 items-center justify-center rounded border-2 border-input" />
{option} {option.label}
</label> </label>
))} ))}
</div> </div>
+37 -20
View File
@@ -1,19 +1,19 @@
"use client" import { Link, useLocation } from "react-router"
import { LogIn, LogOut, Menu, X } from "lucide-react"
import { Link } from "react-router";
import { useLocation } from "react-router";
import { LogOut, Menu, X } from "lucide-react"
import { useState } from "react" import { useState } from "react"
import { useAuth } from "@/app/context/auth-context"
const navLinks = [ const navLinks = [
{ href: "/forms", label: "My Forms" }, { href: "/forms", label: "My Forms" },
] ]
export function Navbar() { export function Navbar() {
const { pathname } = useLocation(); const { pathname } = useLocation()
const [mobileOpen, setMobileOpen] = useState(false); const [mobileOpen, setMobileOpen] = useState(false)
const { user, logout } = useAuth()
const isAuthPage = pathname === "/login" || pathname === "/register"; const isAuthPage = pathname === "/login" || pathname === "/register"
const isLoggedIn = !!user
return ( return (
<header className="sticky top-0 z-50 border-b border-border bg-card/80 backdrop-blur-md"> <header className="sticky top-0 z-50 border-b border-border bg-card/80 backdrop-blur-md">
@@ -24,7 +24,7 @@ const isAuthPage = pathname === "/login" || pathname === "/register";
</span> </span>
</Link> </Link>
{!isAuthPage && ( {!isAuthPage && isLoggedIn && (
<> <>
<div className="hidden items-center gap-6 md:flex"> <div className="hidden items-center gap-6 md:flex">
{navLinks.map((link) => ( {navLinks.map((link) => (
@@ -44,15 +44,15 @@ const isAuthPage = pathname === "/login" || pathname === "/register";
<div className="hidden items-center gap-4 md:flex"> <div className="hidden items-center gap-4 md:flex">
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
bagas@example.com {user?.email}
</span> </span>
<Link <button
to="/login" onClick={logout}
className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground" className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
> >
<LogOut className="h-4 w-4" /> <LogOut className="h-4 w-4" />
Logout Logout
</Link> </button>
</div> </div>
<button <button
@@ -69,6 +69,24 @@ const isAuthPage = pathname === "/login" || pathname === "/register";
</> </>
)} )}
{!isAuthPage && !isLoggedIn && (
<div className="flex items-center gap-3">
<Link
to="/login"
className="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
>
<LogIn className="h-4 w-4" />
Log in
</Link>
<Link
to="/register"
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90"
>
Sign up
</Link>
</div>
)}
{isAuthPage && ( {isAuthPage && (
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Link <Link
@@ -91,7 +109,7 @@ const isAuthPage = pathname === "/login" || pathname === "/register";
)} )}
</nav> </nav>
{!isAuthPage && mobileOpen && ( {!isAuthPage && isLoggedIn && mobileOpen && (
<div className="border-t border-border bg-card px-4 pb-4 pt-2 md:hidden"> <div className="border-t border-border bg-card px-4 pb-4 pt-2 md:hidden">
{navLinks.map((link) => ( {navLinks.map((link) => (
<Link <Link
@@ -109,16 +127,15 @@ const isAuthPage = pathname === "/login" || pathname === "/register";
))} ))}
<div className="mt-2 border-t border-border pt-2"> <div className="mt-2 border-t border-border pt-2">
<span className="block px-3 py-1 text-sm text-muted-foreground"> <span className="block px-3 py-1 text-sm text-muted-foreground">
bagas@example.com {user?.email}
</span> </span>
<Link <button
to="/login" onClick={() => { setMobileOpen(false); logout() }}
onClick={() => setMobileOpen(false)} className="flex items-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-secondary w-full text-left"
className="flex items-center gap-1.5 rounded-md px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-secondary"
> >
<LogOut className="h-4 w-4" /> <LogOut className="h-4 w-4" />
Logout Logout
</Link> </button>
</div> </div>
</div> </div>
)} )}
+11
View File
@@ -0,0 +1,11 @@
'use client'
import * as React from 'react'
import {
ThemeProvider as NextThemesProvider,
type ThemeProviderProps,
} from 'next-themes'
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}
+68
View File
@@ -0,0 +1,68 @@
import { fetchWithAuth } from "@/lib/auth"
import type { FormSummary, FormDetail, CreateFormPayload, UpdateFormPayload } from "@/lib/types"
const API_BASE = "http://localhost:8080"
export async function getForms(): Promise<FormSummary[]> {
const res = await fetchWithAuth(`${API_BASE}/api/forms`)
if (!res.ok) {
throw new Error("Failed to fetch forms")
}
return res.json()
}
export async function getFormById(id: string): Promise<FormDetail> {
const res = await fetchWithAuth(`${API_BASE}/api/form/${id}`)
if (!res.ok) {
if (res.status === 404) {
throw new Response("Form not found", { status: 404 })
}
throw new Error("Failed to fetch form")
}
return res.json()
}
export async function createForm(payload: CreateFormPayload): Promise<FormDetail> {
const res = await fetchWithAuth(`${API_BASE}/api/form`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
if (!res.ok) {
const data = await res.json().catch(() => null)
throw new Error(data?.message ?? "Failed to create form")
}
return res.json()
}
export async function updateForm(id: string, payload: UpdateFormPayload): Promise<FormDetail> {
const res = await fetchWithAuth(`${API_BASE}/api/form/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
if (!res.ok) {
const data = await res.json().catch(() => null)
throw new Error(data?.message ?? "Failed to update form")
}
return res.json()
}
export async function deleteForm(id: string): Promise<void> {
const res = await fetchWithAuth(`${API_BASE}/api/form/${id}`, {
method: "DELETE",
})
if (!res.ok) {
const data = await res.json().catch(() => null)
throw new Error(data?.message ?? "Failed to delete form")
}
}
+206
View File
@@ -0,0 +1,206 @@
export interface User {
id: string
email: string
name?: string
}
export interface JWTPayload {
sub: string
email: string
name?: string
exp: number
iat: number
}
export function decodeJWT(token: string): JWTPayload | null {
try {
const base64Url = token.split(".")[1]
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/")
const jsonPayload = decodeURIComponent(
atob(base64)
.split("")
.map((c) => "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2))
.join("")
)
return JSON.parse(jsonPayload)
} catch {
return null
}
}
export function isTokenExpired(token: string): boolean {
const payload = decodeJWT(token)
if (!payload) return true
return Date.now() >= payload.exp * 1000
}
function getStorage(): Storage {
if (typeof window === "undefined") return localStorage
return localStorage.getItem("remember_me") === "true"
? localStorage
: sessionStorage
}
function getToken(key: string): string | null {
return localStorage.getItem(key) ?? sessionStorage.getItem(key)
}
export function setTokens(accessToken: string, refreshToken: string, remember?: boolean) {
if (typeof window === "undefined") return
if (remember !== undefined) {
localStorage.setItem("remember_me", String(remember))
}
const storage = remember !== undefined
? (remember ? localStorage : sessionStorage)
: getStorage()
localStorage.removeItem("access_token")
localStorage.removeItem("refresh_token")
sessionStorage.removeItem("access_token")
sessionStorage.removeItem("refresh_token")
storage.setItem("access_token", accessToken)
storage.setItem("refresh_token", refreshToken)
}
function clearTokens() {
localStorage.removeItem("access_token")
localStorage.removeItem("refresh_token")
localStorage.removeItem("remember_me")
sessionStorage.removeItem("access_token")
sessionStorage.removeItem("refresh_token")
}
export async function refreshAccessToken(): Promise<string | null> {
if (typeof window === "undefined") return null
const refreshToken = getToken("refresh_token")
if (!refreshToken) {
clearTokens()
return null
}
try {
const res = await fetch("http://localhost:8080/api/auth/refresh", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refresh_token: refreshToken }),
})
if (!res.ok) {
clearTokens()
return null
}
const data = await res.json()
setTokens(data.access_token, data.refresh_token)
return data.access_token
} catch {
clearTokens()
return null
}
}
export function getUser(): User | null {
if (typeof window === "undefined") return null
const token = getToken("access_token")
if (!token || isTokenExpired(token)) return null
const payload = decodeJWT(token)
if (!payload) return null
return {
id: payload.sub,
email: payload.email,
name: payload.name,
}
}
export async function getUserAsync(): Promise<User | null> {
if (typeof window === "undefined") return null
let token = getToken("access_token")
if (!token || isTokenExpired(token)) {
token = await refreshAccessToken()
if (!token) return null
}
const payload = decodeJWT(token)
if (!payload) return null
return {
id: payload.sub,
email: payload.email,
name: payload.name,
}
}
export function getAccessToken(): string | null {
if (typeof window === "undefined") return null
const token = getToken("access_token")
if (!token || isTokenExpired(token)) return null
return token
}
export async function getAccessTokenAsync(): Promise<string | null> {
if (typeof window === "undefined") return null
let token = getToken("access_token")
if (token && !isTokenExpired(token)) return token
token = await refreshAccessToken()
return token
}
export async function logout() {
if (typeof window === "undefined") return
const token = getToken("access_token")
const refreshToken = getToken("refresh_token")
try {
await fetch("http://localhost:8080/api/auth/logout", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: token ? `Bearer ${token}` : "",
},
body: JSON.stringify({ refresh_token: refreshToken }),
})
} catch {
}
clearTokens()
}
export async function fetchWithAuth(url: string, options: RequestInit = {}) {
let token = await getAccessTokenAsync()
const res = await fetch(url, {
...options,
headers: {
...options.headers,
Authorization: token ? `Bearer ${token}` : "",
},
})
if (res.status === 401) {
token = await refreshAccessToken()
if (token) {
return fetch(url, {
...options,
headers: {
...options.headers,
Authorization: `Bearer ${token}`,
},
})
}
}
return res
}
-172
View File
@@ -1,172 +0,0 @@
export type QuestionType =
| "short_text"
| "long_text"
| "multiple_choice"
| "checkbox"
| "dropdown"
| "date"
| "rating"
export interface Question {
id: string
type: QuestionType
title: string
required: boolean
options?: string[]
}
export interface Form {
id: string
title: string
description: string
createdAt: string
updatedAt: string
responseCount: number
questions: Question[]
}
export const dummyForms: Form[] = [
{
id: "1",
title: "RISTEK Datathon 2025 Registration",
description:
"Register for RISTEK's flagship data science competition — the biggest student-led Datathon in Indonesia. Open for undergraduate students from all universities.",
createdAt: "2026-01-10",
updatedAt: "2026-02-05",
responseCount: 420,
questions: [
{
id: "q1",
type: "short_text",
title: "Full Name",
required: true,
},
{
id: "q2",
type: "short_text",
title: "University / Institution",
required: true,
},
{
id: "q3",
type: "dropdown",
title: "Which track are you registering for?",
required: true,
options: [
"Data Analytics",
"Machine Learning",
"Data Engineering",
"MLOps",
],
},
{
id: "q4",
type: "multiple_choice",
title: "How did you hear about RISTEK Datathon 2025?",
required: false,
options: [
"Instagram / Social Media",
"Friend / Colleague",
"Fasilkom UI Website",
"Email Newsletter",
],
},
{
id: "q5",
type: "checkbox",
title:
"Which tools/technologies are you comfortable with? (Select all that apply)",
required: false,
options: [
"Python",
"SQL",
"TensorFlow / PyTorch",
"Tableau / Power BI",
"Spark / Hadoop",
],
},
{
id: "q6",
type: "long_text",
title:
"Briefly describe your motivation for joining RISTEK Datathon 2025.",
required: true,
},
],
},
{
id: "2",
title: "RISTEK Sisters in Tech 2025 — Mentee Application",
description:
"Apply to be a mentee in SISTECH 2025, the first student-powered women-only tech mentorship program in Indonesia. Open for female Indonesian citizens aged 1725.",
createdAt: "2026-01-20",
updatedAt: "2026-02-15",
responseCount: 67,
questions: [
{
id: "q1",
type: "short_text",
title: "Full Name",
required: true,
},
{
id: "q2",
type: "short_text",
title: "Email Address",
required: true,
},
{
id: "q3",
type: "dropdown",
title: "Select your preferred career path",
required: true,
options: [
"Product Management",
"UI/UX Design",
"Software Engineering",
"Data Analytics",
"Digital Marketing",
],
},
{
id: "q4",
type: "rating",
title:
"How would you rate your current knowledge in your chosen career path?",
required: true,
},
{
id: "q5",
type: "checkbox",
title: "What do you hope to gain from SISTECH? (Select all that apply)",
required: false,
options: [
"Mentorship from industry professionals",
"Networking opportunities",
"Portfolio & project guidance",
"Career direction & clarity",
"Community support",
],
},
{
id: "q6",
type: "long_text",
title:
"Tell us about yourself and why you want to join RISTEK Sisters in Tech 2025.",
required: true,
},
{
id: "q7",
type: "multiple_choice",
title: "What is your current education level?",
required: true,
options: [
"High School / Vocational",
"Undergraduate (Year 12)",
"Undergraduate (Year 34)",
"Fresh Graduate",
],
},
],
},
]
+68
View File
@@ -0,0 +1,68 @@
export type QuestionType =
| "short_text"
| "long_text"
| "multiple_choice"
| "checkbox"
| "dropdown"
| "date"
| "rating"
export interface QuestionOption {
id: number
label: string
position: number
}
export interface Question {
id: string
type: QuestionType
title: string
required: boolean
position: number
options: QuestionOption[]
}
export interface FormSummary {
id: string
title: string
description: string
response_count: number
created_at: string
updated_at: string
}
export interface FormDetail {
id: string
user_id: string
title: string
description: string
response_count: number
questions: Question[]
created_at: string
updated_at: string
}
export interface CreateQuestionOption {
label: string
position: number
}
export interface CreateQuestion {
type: QuestionType
title: string
required: boolean
position: number
options: CreateQuestionOption[]
}
export interface CreateFormPayload {
title: string
description: string
questions: CreateQuestion[]
}
export interface UpdateFormPayload {
title: string
description: string
questions: CreateQuestion[]
}