Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions migrations/atlas.hcl
Original file line number Diff line number Diff line change
@@ -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"
}
}
77 changes: 77 additions & 0 deletions src/app/api/search/route.ts
Original file line number Diff line number Diff line change
@@ -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 },
);
}
}
177 changes: 177 additions & 0 deletions src/components/FilterWindow.tsx
Original file line number Diff line number Diff line change
@@ -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<FilterParams>({});

useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}

window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);

const update = <K extends keyof FilterParams>(
key: K,
value: FilterParams[K],
) => {
setFilters((s) => ({ ...s, [key]: value }));
};

const handleApply = () => {
onApply(filters);
onClose();
};

const handleReset = () => setFilters({});

return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
onClick={onClose}
>
<div
className="w-full max-w-lg rounded-lg bg-white p-6 shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold">Search Filters</h3>
<button
aria-label="Close filters"
onClick={onClose}
className="text-gray-500 hover:text-gray-700"
>
</button>
</div>

<div className="space-y-3">
<label className="block">
<div className="text-sm text-gray-600">Search Term</div>
<input
value={filters.search ?? ""}
onChange={(e) => update("search", e.target.value || undefined)}
className="mt-1 w-full rounded border px-2 py-1"
placeholder="Search posts..."
/>
</label>

<div className="flex gap-2">
<label className="flex-1">
<div className="text-sm text-gray-600">Start date</div>
<input
type="date"
value={filters.startDate ?? ""}
onChange={(e) =>
update("startDate", e.target.value || undefined)
}
className="mt-1 w-full rounded border px-2 py-1"
/>
</label>
<label className="flex-1">
<div className="text-sm text-gray-600">End date</div>
<input
type="date"
value={filters.endDate ?? ""}
onChange={(e) => update("endDate", e.target.value || undefined)}
className="mt-1 w-full rounded border px-2 py-1"
/>
</label>
</div>

<div className="block">
<div className="mb-1 text-sm text-gray-600">Flagged</div>
<button
type="button"
onClick={() =>
update("flagged", filters.flagged === 1 ? undefined : 1)
}
className={`w-full rounded px-3 py-2 text-sm font-medium ${
filters.flagged === 1
? "bg-red-500 text-white"
: "border border-gray-300 bg-white text-gray-700 hover:bg-gray-50"
}`}
>
{filters.flagged === 1 ? "Flagged" : "Not Flagged"}
</button>
</div>

<div className="block">
<div className="mb-1 text-sm text-gray-600">Archived</div>
<button
type="button"
onClick={() =>
update("archived", filters.archived === 1 ? undefined : 1)
}
className={`w-full rounded px-3 py-2 text-sm font-medium ${
filters.archived === 1
? "bg-yellow-500 text-white"
: "border border-gray-300 bg-white text-gray-700 hover:bg-gray-50"
}`}
>
{filters.archived === 1 ? "Archived" : "Not Archived"}
</button>
</div>

<label className="block">
<div className="text-sm text-gray-600">Minimum comments</div>
<input
type="number"
min={0}
value={filters.minComments ?? ""}
onChange={(e) =>
update(
"minComments",
e.target.value ? Number(e.target.value) : undefined,
)
}
className="mt-1 w-full rounded border px-2 py-1"
placeholder="e.g. 5"
/>
</label>
</div>

<div className="mt-6 flex justify-end gap-3">
<button
type="button"
onClick={handleReset}
className="rounded border px-3 py-1 text-sm hover:bg-gray-50"
>
Reset
</button>
<button
type="button"
onClick={onClose}
className="rounded border px-3 py-1 text-sm hover:bg-gray-50"
>
Cancel
</button>
<button
type="button"
onClick={handleApply}
className="rounded bg-[#02ACF7] px-4 py-1 text-sm text-white hover:bg-blue-700"
>
Apply
</button>
</div>
</div>
</div>
);
}
48 changes: 42 additions & 6 deletions src/components/SearchBar.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<Post[]>([]);
const [error, setError] = useState<string | null>(null);
const [hasSearched, setHasSearched] = useState(false);

const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();

if (!query.trim()) {
const performSearch = async (searchQuery: string, filters?: FilterParams) => {
if (!searchQuery.trim()) {
return;
}

Expand All @@ -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");
Expand All @@ -52,6 +69,18 @@ export function SearchBar() {
}
};

const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
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 (
<div className="mx-auto max-w-4xl p-5 font-sans">
<form
Expand Down Expand Up @@ -90,7 +119,7 @@ export function SearchBar() {
className="ml-1.5 flex cursor-pointer items-center border-none bg-transparent p-1 sm:ml-2" // Smaller margin on mobile
onClick={(e) => {
e.preventDefault();
// Add filter functionality here
setShowFilter(true);
}}
>
<svg
Expand Down Expand Up @@ -123,6 +152,13 @@ export function SearchBar() {
</button>
</form>

{showFilter && (
<FilterWindow
onClose={() => setShowFilter(false)}
onApply={handleFilterApply}
/>
)}

{error && (
<div className="mb-5 rounded-md border border-red-300 bg-red-100 p-4 text-red-700">
Error: {error}
Expand Down
Loading
Loading