feat(search-everywhere): Global Search API + Search-Bar component [tsc:fail]
This commit is contained in:
parent
26805dca90
commit
39bdd9d62c
@ -1,5 +1,8 @@
|
|||||||
{
|
{
|
||||||
"completed_features": [],
|
"completed_features": [],
|
||||||
"current_feature": "documents-upload",
|
"current_feature": "search-everywhere",
|
||||||
"started_at": "2026-05-23T05:40:09.997191"
|
"started_at": "2026-05-23T05:40:09.997191",
|
||||||
|
"attempted_features": [
|
||||||
|
"documents-upload"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
@ -782,3 +782,20 @@ src/routes/documents.ts(36,25): error TS2339: Property 'size' does not exist on
|
|||||||
src/routes/documents.ts(46,32): error TS2339: Property 'file' does not exist on type 'FastifyRequest<RouteGenericInterface, RawServerDefault, IncomingMessage, FastifySchema, FastifyTypeProviderDefault, unknown, FastifyBaseLogger, ResolveFastifyRequestType<...>>'.
|
src/routes/documents.ts(46,32): error TS2339: Property 'file' does not exist on type 'FastifyRequest<RouteGenericInterface, RawServerDefault, IncomingMessage, FastifySchema, FastifyTypeProviderDefault, unknown, FastifyBaseLogger, ResolveFastifyRequestType<...>>'.
|
||||||
src/routes/documents.ts(56,9): error TS2769: No overload matches this call.
|
src/routes/documents.ts(56,9): error TS2769: No overload matches this call.
|
||||||
Overload 1 of 2, '(value: { filename: string | SQL<unknown> | Placeholder<string, any>; contentType: string | SQL<unknown> | Placeholder<string, any>; sizeBytes: number | SQL<...> | Placeholder<...>; id?: string | ... 2 more ... | undefined; createdAt?: SQL<...> | ... 2 more ... | undefined; userId?: string | ... 3 more ... | undefined; c
|
Overload 1 of 2, '(value: { filename: string | SQL<unknown> | Placeholder<string, any>; contentType: string | SQL<unknown> | Placeholder<string, any>; sizeBytes: number | SQL<...> | Placeholder<...>; id?: string | ... 2 more ... | undefined; createdAt?: SQL<...> | ... 2 more ... | undefined; userId?: string | ... 3 more ... | undefined; c
|
||||||
|
- `05:41:42` **INFO** Committed feature documents-upload
|
||||||
|
- `05:41:43` **INFO** Pushed: rc=0
|
||||||
|
|
||||||
|
## Phase-3 Feature: search-everywhere (2026-05-23 05:41:43)
|
||||||
|
|
||||||
|
- `05:41:43` **INFO** Description: Global Search API + Search-Bar component
|
||||||
|
- `05:41:43` **INFO** Generating apps/api/src/routes/search.ts (Fastify-Plugin /api/search?q=... Auth required. Sucht in time-entries …)
|
||||||
|
- `05:41:57` **INFO** wrote 1584 chars in 14.0s (attempt 1)
|
||||||
|
- `05:41:57` **INFO** Generating apps/web/src/components/SearchBar.tsx (Global Search-Component. Input rechts in Nav-Bar. Debounced (300ms). B…)
|
||||||
|
- `05:42:38` **INFO** wrote 4743 chars in 40.8s (attempt 1)
|
||||||
|
- `05:42:38` **INFO** Running tsc --noEmit on api…
|
||||||
|
- `05:42:39` **WARN** tsc errors:
|
||||||
|
src/routes/documents.ts(34,25): error TS2339: Property 'name' does not exist on type 'PgTableWithColumns<{ name: "documents"; schema: undefined; columns: { id: PgColumn<{ name: "id"; tableName: "documents"; dataType: "string"; columnType: "PgUUID"; data: string; driverParam: string; notNull: true; hasDefault: true; ... 6 more ...; generated: undefined; }, {}, {}>; ... 5 more ...; createdAt: PgColumn<...'.
|
||||||
|
src/routes/documents.ts(36,25): error TS2339: Property 'size' does not exist on type 'PgTableWithColumns<{ name: "documents"; schema: undefined; columns: { id: PgColumn<{ name: "id"; tableName: "documents"; dataType: "string"; columnType: "PgUUID"; data: string; driverParam: string; notNull: true; hasDefault: true; ... 6 more ...; generated: undefined; }, {}, {}>; ... 5 more ...; createdAt: PgColumn<...'.
|
||||||
|
src/routes/documents.ts(46,32): error TS2339: Property 'file' does not exist on type 'FastifyRequest<RouteGenericInterface, RawServerDefault, IncomingMessage, FastifySchema, FastifyTypeProviderDefault, unknown, FastifyBaseLogger, ResolveFastifyRequestType<...>>'.
|
||||||
|
src/routes/documents.ts(56,9): error TS2769: No overload matches this call.
|
||||||
|
Overload 1 of 2, '(value: { filename: string | SQL<unknown> | Placeholder<string, any>; contentType: string | SQL<unknown> | Placeholder<string, any>; sizeBytes: number | SQL<...> | Placeholder<...>; id?: string | ... 2 more ... | undefined; createdAt?: SQL<...> | ... 2 more ... | undefined; userId?: string | ... 3 more ... | undefined; c
|
||||||
|
|||||||
57
apps/api/src/routes/search.ts
Normal file
57
apps/api/src/routes/search.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { FastifyInstance } from "fastify"
|
||||||
|
import { db } from "../db"
|
||||||
|
import { customers, projects, timeEntries, users } from "../db/schema"
|
||||||
|
import { ilike, and, eq } from "drizzle-orm"
|
||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
export default async function searchRoutes(fastify: FastifyInstance) {
|
||||||
|
fastify.addHook("preHandler", async (request, reply) => {
|
||||||
|
try {
|
||||||
|
await request.jwtVerify()
|
||||||
|
} catch (err) {
|
||||||
|
return reply.code(401).send({ message: "Unauthorized" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
fastify.get("/", async (request, reply) => {
|
||||||
|
const { q } = request.query as { q?: string }
|
||||||
|
|
||||||
|
if (!q || q.length < 2) {
|
||||||
|
return { timeEntries: [], customers: [], projects: [], users: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchTerm = `%${q}%`
|
||||||
|
|
||||||
|
const [customerResults, projectResults, timeEntryResults, userResults] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(customers)
|
||||||
|
.where(and(eq(customers.active, true), ilike(customers.name, searchTerm)))
|
||||||
|
.limit(10),
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(projects)
|
||||||
|
.where(and(eq(projects.active, true), ilike(projects.name, searchTerm)))
|
||||||
|
.limit(10),
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(timeEntries)
|
||||||
|
.where(ilike(timeEntries.description, searchTerm))
|
||||||
|
.limit(10),
|
||||||
|
db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(
|
||||||
|
ilike(users.name, searchTerm) || ilike(users.email, searchTerm)
|
||||||
|
)
|
||||||
|
.limit(10)
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
customers: customerResults,
|
||||||
|
projects: projectResults,
|
||||||
|
timeEntries: timeEntryResults,
|
||||||
|
users: userResults
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
118
apps/web/src/components/SearchBar.tsx
Normal file
118
apps/web/src/components/SearchBar.tsx
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
|
import { Search, User, Folder, Clock, X } from 'lucide-react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { api } from '../lib/api';
|
||||||
|
|
||||||
|
interface SearchResult {
|
||||||
|
id: string;
|
||||||
|
type: 'customer' | 'project' | 'time-entry';
|
||||||
|
label: string;
|
||||||
|
subtitle?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SearchBar() {
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const { data: results, isLoading } = useQuery({
|
||||||
|
queryKey: ['search', query],
|
||||||
|
queryFn: () => api.search(query),
|
||||||
|
enabled: query.length >= 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') setIsOpen(false);
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSelect = (result: SearchResult) => {
|
||||||
|
const paths: Record<SearchResult['type'], string> = {
|
||||||
|
customer: `/customers/${result.id}`,
|
||||||
|
project: `/projects/${result.id}`,
|
||||||
|
'time-entry': `/time-entries/${result.id}`,
|
||||||
|
};
|
||||||
|
navigate({ to: paths[result.type] });
|
||||||
|
setQuery('');
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getIcon = (type: SearchResult['type']) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'customer': return <User className="w-4 h-4 text-purple-500" />;
|
||||||
|
case 'project': return <Folder className="w-4 h-4 text-orange-500" />;
|
||||||
|
case 'time-entry': return <Clock className="w-4 h-4 text-green-500" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative w-64">
|
||||||
|
<div className="relative group">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-400 group-focus-within:text-zinc-600 dark:group-focus-within:text-zinc-300" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="w-full pl-9 pr-4 py-1.5 bg-zinc-100 dark:bg-zinc-800 border border-transparent focus:border-zinc-300 dark:focus:border-zinc-700 rounded-md text-sm outline-none transition-all placeholder-zinc-500 dark:placeholder-zinc-400"
|
||||||
|
placeholder="Search... (⌘K)"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => {
|
||||||
|
setQuery(e.target.value);
|
||||||
|
setIsOpen(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => setIsOpen(true)}
|
||||||
|
/>
|
||||||
|
{query && (
|
||||||
|
<button
|
||||||
|
onClick={() => setQuery('')}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-400 hover:text-zinc-600 dark:hover:text-zinc-300"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isOpen && (query.length >= 2 || (results && results.length > 0)) && (
|
||||||
|
<div className="absolute top-full left-0 right-0 mt-2 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-lg shadow-xl z-50 overflow-hidden">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="p-4 text-center text-xs text-zinc-500">Searching...</div>
|
||||||
|
) : results && results.length > 0 ? (
|
||||||
|
<div className="max-h-96 overflow-y-auto p-1">
|
||||||
|
{['customer', 'project', 'time-entry'].map((type) => {
|
||||||
|
const filtered = results.filter((r: SearchResult) => r.type === type);
|
||||||
|
if (filtered.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={type} className="mb-2">
|
||||||
|
<div className="px-2 py-1 text-[10px] font-bold uppercase tracking-wider text-zinc-400 dark:text-zinc-500">
|
||||||
|
{type}s
|
||||||
|
</div>
|
||||||
|
{filtered.map((result: SearchResult) => (
|
||||||
|
<button
|
||||||
|
key={result.id}
|
||||||
|
onClick={() => handleSelect(result)}
|
||||||
|
className="w-full flex items-center gap-3 px-2 py-2 text-left text-sm rounded-md hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors group"
|
||||||
|
>
|
||||||
|
{getIcon(result.type)}
|
||||||
|
<div className="flex flex-col overflow-hidden">
|
||||||
|
<span className="text-zinc-900 dark:text-zinc-100 truncate">{result.label}</span>
|
||||||
|
{result.subtitle && (
|
||||||
|
<span className="text-xs text-zinc-500 truncate">{result.subtitle}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-4 text-center text-sm text-zinc-500">No results found</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user