diff --git a/migrations/atlas.hcl b/migrations/atlas.hcl new file mode 100644 index 0000000..6deef15 --- /dev/null +++ b/migrations/atlas.hcl @@ -0,0 +1,30 @@ +data "external_schema" "drizzle" { + program = [ + "tail", + "-q", + "-n", + "+3", + "migrations/ddl.sql", + "migrations/overrides.sql", + ] +} + +env "local" { + url = "mysql://root:${getenv("MYSQL_PASSWORD")}@mysql:3306/${getenv("MYSQL_DATABASE")}" + dev = "mysql://root:password@mysql-dev:3306/dev" + schema { + src = data.external_schema.drizzle.url + } + migration { + dir = "file://atlas/migrations" + } +} + +env "remote" { + schema { + src = data.external_schema.drizzle.url + } + migration { + dir = "file://atlas/migrations" + } +} \ No newline at end of file diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts new file mode 100644 index 0000000..ad127b7 --- /dev/null +++ b/src/app/api/search/route.ts @@ -0,0 +1,77 @@ +import { NextResponse } from "next/server"; +import { db } from "../../../server/db"; +import { posts } from "../../../server/db/schema"; +import { sql } from "drizzle-orm"; + +export async function GET(request: Request) { + const url = new URL(request.url); + const params = url.searchParams; + + const search = params.get("search") ?? params.get("searchterms") ?? ""; + const startDateRaw = params.get("startDate"); + const startDate = startDateRaw ? new Date(startDateRaw) : undefined; + const endDateRaw = params.get("endDate"); + const endDate = endDateRaw ? new Date(endDateRaw) : undefined; + const flaggedRaw = params.get("flagged"); + const flagged = flaggedRaw !== null ? parseInt(flaggedRaw, 10) : undefined; + const archivedRaw = params.get("archived"); + const archived = archivedRaw; + const minCommentsRaw = params.get("minComments"); + const minComments = + minCommentsRaw !== null ? parseInt(minCommentsRaw, 10) : undefined; + + const parsed = { search, startDate, endDate, flagged, archived, minComments }; + + try { + const results = await db + .select() + .from(posts) + .where(sql`MATCH(${posts.content}) AGAINST(${search})`); + + // Apply in-memory filters based on parsed search params (no DB-side changes) + const filtered = (results ?? []).filter((row: any) => { + // archived: expect integer (e.g., 0 or 1) + if (typeof archived === "number" && !Number.isNaN(archived)) { + if (row.archived !== archived) return false; + } + + // minComments: filter by commentCount >= minComments + if (typeof minComments === "number" && !Number.isNaN(minComments)) { + if ((row.commentCount ?? 0) < minComments) return false; + } + + // flagged: treat as minimum flagCount threshold + if (typeof flagged === "number" && !Number.isNaN(flagged)) { + if (!((row.flagCount ?? 0) && flagged)) return false; + } + + // date range: compare against createdAt + const updated = row.createdAt ? new Date(row.createdAt) : null; + if (startDate && updated) { + if (updated.getTime() < startDate.getTime()) return false; + } + if (endDate && updated) { + if (updated.getTime() > endDate.getTime()) return false; + } + return true; + }); + + return NextResponse.json({ + results: filtered, + parsed: { + search, + startDate: startDateRaw ?? null, + endDate: endDateRaw ?? null, + flagged, + archived, + minComments, + }, + }); + } catch (error) { + console.error("Database query failed:", error); + return NextResponse.json( + { error: "Internal Server Error" }, + { status: 500 }, + ); + } +} diff --git a/src/components/FilterWindow.tsx b/src/components/FilterWindow.tsx new file mode 100644 index 0000000..5d63d29 --- /dev/null +++ b/src/components/FilterWindow.tsx @@ -0,0 +1,177 @@ +"use client"; + +import { useEffect, useState } from "react"; + +export type FilterParams = { + search?: string; + startDate?: string; + endDate?: string; + flagged?: number; + archived?: number; + minComments?: number; +}; + +interface FilterWindowProps { + onClose: () => void; + onApply: (filters: FilterParams) => void; +} + +export function FilterWindow({ onClose, onApply }: FilterWindowProps) { + const [filters, setFilters] = useState({}); + + useEffect(() => { + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") onClose(); + } + + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [onClose]); + + const update = ( + key: K, + value: FilterParams[K], + ) => { + setFilters((s) => ({ ...s, [key]: value })); + }; + + const handleApply = () => { + onApply(filters); + onClose(); + }; + + const handleReset = () => setFilters({}); + + return ( +
+
e.stopPropagation()} + > +
+

Search Filters

+ +
+ +
+ + +
+ + +
+ +
+
Flagged
+ +
+ +
+
Archived
+ +
+ + +
+ +
+ + + +
+
+
+ ); +} diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx index 34a75e5..38a8fa7 100644 --- a/src/components/SearchBar.tsx +++ b/src/components/SearchBar.tsx @@ -1,6 +1,8 @@ "use client"; import { useState, type FormEvent } from "react"; +import { FilterWindow } from "./FilterWindow"; +import type { FilterParams } from "./FilterWindow"; interface Post { id: string; @@ -18,16 +20,15 @@ interface SearchResponse { } export function SearchBar() { + const [showFilter, setShowFilter] = useState(false); const [query, setQuery] = useState(""); const [isLoading, setIsLoading] = useState(false); const [results, setResults] = useState([]); const [error, setError] = useState(null); const [hasSearched, setHasSearched] = useState(false); - const handleSubmit = async (e: FormEvent) => { - e.preventDefault(); - - if (!query.trim()) { + const performSearch = async (searchQuery: string, filters?: FilterParams) => { + if (!searchQuery.trim()) { return; } @@ -37,7 +38,23 @@ export function SearchBar() { setHasSearched(true); try { - const response = await fetch(`/api/search/${encodeURIComponent(query)}`); + const params = new URLSearchParams(); + params.set("search", searchQuery); + + if (filters) { + if (filters.startDate !== undefined) + params.set("startDate", filters.startDate); + if (filters.endDate !== undefined) + params.set("endDate", filters.endDate); + if (filters.flagged !== undefined) + params.set("flagged", String(filters.flagged)); + if (filters.archived !== undefined) + params.set("archived", String(filters.archived)); + if (filters.minComments !== undefined) + params.set("minComments", String(filters.minComments)); + } + + const response = await fetch(`/api/search?${params.toString()}`); if (!response.ok) { throw new Error("Search failed"); @@ -52,6 +69,18 @@ export function SearchBar() { } }; + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + performSearch(query); + }; + + const handleFilterApply = async (filters: FilterParams) => { + // Use search term from filter or fall back to current query + const searchQuery = filters.search || query; + setQuery(searchQuery); + performSearch(searchQuery, filters); + }; + return (
{ e.preventDefault(); - // Add filter functionality here + setShowFilter(true); }} > + {showFilter && ( + setShowFilter(false)} + onApply={handleFilterApply} + /> + )} + {error && (
Error: {error} diff --git a/tsconfig.json b/tsconfig.json index e789723..bc9eb3c 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,7 +14,11 @@ "noUncheckedIndexedAccess": true, "checkJs": true, /* Bundled projects */ - "lib": ["dom", "dom.iterable", "ESNext"], + "lib": [ + "dom", + "dom.iterable", + "ESNext" + ], "noEmit": true, "module": "ESNext", "moduleResolution": "Bundler", @@ -28,7 +32,9 @@ /* Path Aliases */ "baseUrl": ".", "paths": { - "~/*": ["./src/*"] + "~/*": [ + "./src/*" + ] } }, "include": [ @@ -40,5 +46,7 @@ ".next/types/**/*.ts", ".next/dev/types/**/*.ts" ], - "exclude": ["node_modules"] + "exclude": [ + "node_modules" + ] }