update: new style for search toolbox
Docker Build and Push / build-and-push (push) Successful in 6m47s

This commit is contained in:
2026-02-25 10:26:15 +07:00
parent 5ce2890023
commit 6b935c9c22
5 changed files with 205 additions and 66 deletions
+1 -10
View File
@@ -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,
-5
View File
@@ -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
View File
@@ -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.
+13 -50
View File
@@ -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,54 +122,17 @@ 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}
</div> onOrderChange={setSortDir}
/>
<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>
<div className="mb-4 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
+190
View File
@@ -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>
)
}