update: new style for search toolbox
Docker Build and Push / build-and-push (push) Successful in 6m47s
Docker Build and Push / build-and-push (push) Successful in 6m47s
This commit is contained in:
+1
-10
@@ -19,7 +19,7 @@
|
|||||||
--accent: oklch(0.96 0.005 285);
|
--accent: oklch(0.96 0.005 285);
|
||||||
--accent-foreground: oklch(0.30 0.05 300);
|
--accent-foreground: oklch(0.30 0.05 300);
|
||||||
--destructive: oklch(0.577 0.245 27.325);
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
--destructive-foreground: oklch(0.577 0.245 27.325);
|
--destructive-foreground: oklch(0.99 0 0);
|
||||||
--border: oklch(0.91 0.005 285);
|
--border: oklch(0.91 0.005 285);
|
||||||
--input: oklch(0.91 0.005 285);
|
--input: oklch(0.91 0.005 285);
|
||||||
--ring: oklch(0.30 0.05 300);
|
--ring: oklch(0.30 0.05 300);
|
||||||
@@ -124,8 +124,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Animation keyframes ── */
|
|
||||||
|
|
||||||
@keyframes fade-in {
|
@keyframes fade-in {
|
||||||
from { opacity: 0; }
|
from { opacity: 0; }
|
||||||
to { opacity: 1; }
|
to { opacity: 1; }
|
||||||
@@ -172,8 +170,6 @@
|
|||||||
50% { opacity: 0.7; }
|
50% { opacity: 0.7; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Utility classes ── */
|
|
||||||
|
|
||||||
.animate-fade-in {
|
.animate-fade-in {
|
||||||
animation: fade-in 0.5s ease-out both;
|
animation: fade-in 0.5s ease-out both;
|
||||||
}
|
}
|
||||||
@@ -212,7 +208,6 @@
|
|||||||
animation: pulse-soft 2s ease-in-out infinite;
|
animation: pulse-soft 2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Stagger delays for children */
|
|
||||||
.stagger-1 { animation-delay: 0.05s; }
|
.stagger-1 { animation-delay: 0.05s; }
|
||||||
.stagger-2 { animation-delay: 0.1s; }
|
.stagger-2 { animation-delay: 0.1s; }
|
||||||
.stagger-3 { animation-delay: 0.15s; }
|
.stagger-3 { animation-delay: 0.15s; }
|
||||||
@@ -222,7 +217,6 @@
|
|||||||
.stagger-7 { animation-delay: 0.35s; }
|
.stagger-7 { animation-delay: 0.35s; }
|
||||||
.stagger-8 { animation-delay: 0.4s; }
|
.stagger-8 { animation-delay: 0.4s; }
|
||||||
|
|
||||||
/* Button press effect */
|
|
||||||
.btn-press {
|
.btn-press {
|
||||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||||
}
|
}
|
||||||
@@ -235,7 +229,6 @@
|
|||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Card hover lift */
|
|
||||||
.card-hover {
|
.card-hover {
|
||||||
transition: transform 0.25s ease, box-shadow 0.25s ease;
|
transition: transform 0.25s ease, box-shadow 0.25s ease;
|
||||||
}
|
}
|
||||||
@@ -244,7 +237,6 @@
|
|||||||
box-shadow: 0 12px 24px -8px rgba(0, 0, 0, 0.12);
|
box-shadow: 0 12px 24px -8px rgba(0, 0, 0, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Smooth loading skeleton */
|
|
||||||
.skeleton {
|
.skeleton {
|
||||||
background: linear-gradient(90deg, var(--muted) 25%, var(--accent) 50%, var(--muted) 75%);
|
background: linear-gradient(90deg, var(--muted) 25%, var(--accent) 50%, var(--muted) 75%);
|
||||||
background-size: 200% 100%;
|
background-size: 200% 100%;
|
||||||
@@ -252,7 +244,6 @@
|
|||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Reduced motion preference */
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.animate-fade-in,
|
.animate-fade-in,
|
||||||
.animate-fade-in-up,
|
.animate-fade-in-up,
|
||||||
|
|||||||
@@ -129,7 +129,6 @@ export default function FormResponsesPage() {
|
|||||||
Back to form
|
Back to form
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Header */}
|
|
||||||
<div className="mb-8 rounded-xl border border-border bg-card shadow-sm animate-float-in">
|
<div className="mb-8 rounded-xl border border-border bg-card shadow-sm animate-float-in">
|
||||||
<div className="h-2 rounded-t-xl bg-primary" />
|
<div className="h-2 rounded-t-xl bg-primary" />
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
@@ -152,7 +151,6 @@ export default function FormResponsesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
|
||||||
<div className="mt-5 grid grid-cols-2 gap-4 border-t border-border pt-5 sm:grid-cols-4">
|
<div className="mt-5 grid grid-cols-2 gap-4 border-t border-border pt-5 sm:grid-cols-4">
|
||||||
<StatCard
|
<StatCard
|
||||||
icon={<Users className="h-4 w-4 text-primary" />}
|
icon={<Users className="h-4 w-4 text-primary" />}
|
||||||
@@ -182,7 +180,6 @@ export default function FormResponsesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tabs */}
|
|
||||||
<div className="mb-6 flex gap-1 rounded-lg border border-border bg-card p-1 animate-fade-in-up" style={{ animationDelay: '0.15s' }}>
|
<div className="mb-6 flex gap-1 rounded-lg border border-border bg-card p-1 animate-fade-in-up" style={{ animationDelay: '0.15s' }}>
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab("summary")}
|
onClick={() => setActiveTab("summary")}
|
||||||
@@ -578,7 +575,6 @@ function IndividualTab({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
{/* Navigation */}
|
|
||||||
<div className="flex items-center justify-between rounded-xl border border-border bg-card p-4 shadow-sm">
|
<div className="flex items-center justify-between rounded-xl border border-border bg-card p-4 shadow-sm">
|
||||||
<button
|
<button
|
||||||
onClick={() => setSelectedIndex((i) => Math.max(0, i - 1))}
|
onClick={() => setSelectedIndex((i) => Math.max(0, i - 1))}
|
||||||
@@ -606,7 +602,6 @@ function IndividualTab({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Answers */}
|
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{form.questions.map((question, index) => {
|
{form.questions.map((question, index) => {
|
||||||
const answer = current.answers.find(
|
const answer = current.answers.find(
|
||||||
|
|||||||
+1
-1
@@ -206,7 +206,7 @@ export default function FormPreviewPage() {
|
|||||||
{showDeleteConfirm && (
|
{showDeleteConfirm && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
<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">
|
<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>
|
<h2 className="text-lg font-semibold text-card-foreground">Delete Form</h2>
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
Are you sure you want to delete this form? This action cannot be
|
Are you sure you want to delete this form? This action cannot be
|
||||||
undone.
|
undone.
|
||||||
|
|||||||
+12
-49
@@ -1,11 +1,11 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from "react"
|
import { useState, useEffect, useRef, useCallback } from "react"
|
||||||
import { useNavigate, Link } from "react-router"
|
import { useNavigate, Link } from "react-router"
|
||||||
import { Plus, Search, Filter, ArrowUpDown, Loader2 } from "lucide-react"
|
import { Plus, Loader2 } from "lucide-react"
|
||||||
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 { 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 { FormsToolbar } from "@/components/forms/forms-toolbar"
|
||||||
import { useAuth } from "@/app/context/auth-context"
|
import { useAuth } from "@/app/context/auth-context"
|
||||||
import { getForms } from "@/lib/api"
|
import { getForms } from "@/lib/api"
|
||||||
import type { FormSummary } from "@/lib/types"
|
import type { FormSummary } from "@/lib/types"
|
||||||
@@ -122,56 +122,19 @@ export default function FormsPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-6 flex flex-col gap-3">
|
<div className="mb-6">
|
||||||
<div className="relative w-full sm:max-w-md">
|
<FormsToolbar
|
||||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
search={search}
|
||||||
<FormInput
|
status={statusFilter}
|
||||||
placeholder="Search forms by title..."
|
sortBy={sortBy}
|
||||||
value={search}
|
sortDir={sortDir}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onSearchChange={setSearch}
|
||||||
className="pl-9"
|
onStatusChange={setStatusFilter}
|
||||||
|
onSortChange={setSortBy}
|
||||||
|
onOrderChange={setSortDir}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2 sm:flex sm:items-center sm:gap-2">
|
|
||||||
<div className="relative col-span-2 sm:col-span-1">
|
|
||||||
<Filter className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
|
||||||
<select
|
|
||||||
value={statusFilter}
|
|
||||||
onChange={(e) => setStatusFilter(e.target.value as StatusFilter)}
|
|
||||||
className="h-10 w-full appearance-none rounded-lg border border-border bg-card pl-8 pr-8 text-sm text-foreground shadow-sm focus:border-ring focus:outline-none focus:ring-2 focus:ring-ring/30"
|
|
||||||
>
|
|
||||||
<option value="all">All Status</option>
|
|
||||||
<option value="has_responses">Has Responses</option>
|
|
||||||
<option value="no_responses">No Responses</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<ArrowUpDown className="pointer-events-none absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
|
||||||
<select
|
|
||||||
value={sortBy}
|
|
||||||
onChange={(e) => setSortBy(e.target.value as SortBy)}
|
|
||||||
className="h-10 w-full appearance-none rounded-lg border border-border bg-card pl-8 pr-8 text-sm text-foreground shadow-sm focus:border-ring focus:outline-none focus:ring-2 focus:ring-ring/30"
|
|
||||||
>
|
|
||||||
<option value="created_at">Sort: Created</option>
|
|
||||||
<option value="updated_at">Sort: Updated</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative">
|
|
||||||
<select
|
|
||||||
value={sortDir}
|
|
||||||
onChange={(e) => setSortDir(e.target.value as SortDir)}
|
|
||||||
className="h-10 w-full appearance-none rounded-lg border border-border bg-card pl-8 pr-8 text-sm text-foreground shadow-sm focus:border-ring focus:outline-none focus:ring-2 focus:ring-ring/30"
|
|
||||||
>
|
|
||||||
<option value="newest">Newest first</option>
|
|
||||||
<option value="oldest">Oldest first</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
{loadingForms ? (
|
{loadingForms ? (
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { useState, useEffect, useRef } from "react"
|
||||||
|
import { Search, Filter, ArrowUpDown, Clock, X } from "lucide-react"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
type StatusFilter = "all" | "has_responses" | "no_responses"
|
||||||
|
type SortBy = "created_at" | "updated_at"
|
||||||
|
type SortDir = "newest" | "oldest"
|
||||||
|
|
||||||
|
const STATUS_OPTIONS: { label: string; value: StatusFilter }[] = [
|
||||||
|
{ label: "All Status", value: "all" },
|
||||||
|
{ label: "Has Responses", value: "has_responses" },
|
||||||
|
{ label: "No Responses", value: "no_responses" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const SORT_OPTIONS: { label: string; value: SortBy }[] = [
|
||||||
|
{ label: "Created", value: "created_at" },
|
||||||
|
{ label: "Updated", value: "updated_at" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const ORDER_OPTIONS: { label: string; value: SortDir }[] = [
|
||||||
|
{ label: "Newest first", value: "newest" },
|
||||||
|
{ label: "Oldest first", value: "oldest" },
|
||||||
|
]
|
||||||
|
|
||||||
|
interface FormsToolbarProps {
|
||||||
|
search: string
|
||||||
|
status: StatusFilter
|
||||||
|
sortBy: SortBy
|
||||||
|
sortDir: SortDir
|
||||||
|
onSearchChange: (value: string) => void
|
||||||
|
onStatusChange: (status: StatusFilter) => void
|
||||||
|
onSortChange: (sort: SortBy) => void
|
||||||
|
onOrderChange: (order: SortDir) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormsToolbar({
|
||||||
|
search,
|
||||||
|
status,
|
||||||
|
sortBy,
|
||||||
|
sortDir,
|
||||||
|
onSearchChange,
|
||||||
|
onStatusChange,
|
||||||
|
onSortChange,
|
||||||
|
onOrderChange,
|
||||||
|
}: FormsToolbarProps) {
|
||||||
|
const [openDropdown, setOpenDropdown] = useState<string | null>(null)
|
||||||
|
const toolbarRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(e: MouseEvent) {
|
||||||
|
if (toolbarRef.current && !toolbarRef.current.contains(e.target as Node)) {
|
||||||
|
setOpenDropdown(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("mousedown", handleClickOutside)
|
||||||
|
return () => document.removeEventListener("mousedown", handleClickOutside)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
function toggleDropdown(name: string) {
|
||||||
|
setOpenDropdown((prev) => (prev === name ? null : name))
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusLabel = STATUS_OPTIONS.find((o) => o.value === status)?.label ?? "All Status"
|
||||||
|
const sortLabel = SORT_OPTIONS.find((o) => o.value === sortBy)?.label ?? "Created"
|
||||||
|
const orderLabel = ORDER_OPTIONS.find((o) => o.value === sortDir)?.label ?? "Newest first"
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={toolbarRef} className="rounded-xl border border-border bg-card shadow-sm">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center">
|
||||||
|
<div className="flex flex-1 items-center gap-2 px-4 py-2.5">
|
||||||
|
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search forms by title..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
className="w-full min-w-0 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
|
||||||
|
/>
|
||||||
|
{search && (
|
||||||
|
<button
|
||||||
|
onClick={() => onSearchChange("")}
|
||||||
|
className="shrink-0 rounded-md p-0.5 text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
aria-label="Clear search"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-px w-full bg-border md:hidden" />
|
||||||
|
<div className="hidden md:block w-px self-stretch bg-border" />
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-1 px-3 py-2 md:shrink-0 md:flex-nowrap md:px-2 md:py-1.5">
|
||||||
|
<ToolbarDropdown
|
||||||
|
icon={<Filter className="h-3.5 w-3.5" />}
|
||||||
|
label={statusLabel}
|
||||||
|
open={openDropdown === "status"}
|
||||||
|
onToggle={() => toggleDropdown("status")}
|
||||||
|
options={STATUS_OPTIONS}
|
||||||
|
value={status}
|
||||||
|
onSelect={(v) => {
|
||||||
|
onStatusChange(v)
|
||||||
|
setOpenDropdown(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ToolbarDropdown
|
||||||
|
icon={<ArrowUpDown className="h-3.5 w-3.5" />}
|
||||||
|
label={`Sort: ${sortLabel}`}
|
||||||
|
open={openDropdown === "sort"}
|
||||||
|
onToggle={() => toggleDropdown("sort")}
|
||||||
|
options={SORT_OPTIONS}
|
||||||
|
value={sortBy}
|
||||||
|
onSelect={(v) => {
|
||||||
|
onSortChange(v)
|
||||||
|
setOpenDropdown(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ToolbarDropdown
|
||||||
|
icon={<Clock className="h-3.5 w-3.5" />}
|
||||||
|
label={orderLabel}
|
||||||
|
open={openDropdown === "order"}
|
||||||
|
onToggle={() => toggleDropdown("order")}
|
||||||
|
options={ORDER_OPTIONS}
|
||||||
|
value={sortDir}
|
||||||
|
onSelect={(v) => {
|
||||||
|
onOrderChange(v)
|
||||||
|
setOpenDropdown(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ToolbarDropdown<T extends string>({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
open,
|
||||||
|
onToggle,
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode
|
||||||
|
label: string
|
||||||
|
open: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
options: { label: string; value: T }[]
|
||||||
|
value: T
|
||||||
|
onSelect: (value: T) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium transition-colors",
|
||||||
|
open
|
||||||
|
? "bg-primary/10 text-primary"
|
||||||
|
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
<span className="whitespace-nowrap">{label}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="absolute right-0 top-full z-50 mt-1.5 min-w-[140px] rounded-lg border border-border bg-popover p-1 shadow-lg">
|
||||||
|
{options.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
onClick={() => onSelect(option.value)}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center rounded-md px-2.5 py-1.5 text-xs transition-colors",
|
||||||
|
option.value === value
|
||||||
|
? "bg-primary/10 font-medium text-primary"
|
||||||
|
: "text-popover-foreground hover:bg-muted"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user