chore: remove unused UI components, dummy data, and theme provider
This commit is contained in:
@@ -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
@@ -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
@@ -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} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
}
|
||||||
@@ -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 17–25.",
|
|
||||||
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 1–2)",
|
|
||||||
"Undergraduate (Year 3–4)",
|
|
||||||
"Fresh Graduate",
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
@@ -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[]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user