Skip to content
564 changes: 564 additions & 0 deletions apps/dashboard/src/components/filters/filter-bar.tsx

Large diffs are not rendered by default.

62 changes: 62 additions & 0 deletions apps/dashboard/src/components/filters/filter-cookie.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { createServerFn } from "@tanstack/react-start";

const COOKIE_NAME = "diffkit-filters";
const MAX_AGE = 60 * 60 * 24 * 365; // 1 year

type SerializedPageState = {
searchQuery: string;
activeFilters: { fieldId: string; values: string[] }[];
sortId: string;
};

export type SerializedFilterStore = Record<string, SerializedPageState>;

// ── Server: read cookie ────────────────────────────────────────────────

export const getFilterCookie = createServerFn({ method: "GET" }).handler(
async (): Promise<SerializedFilterStore> => {
const { getRequest } = await import("@tanstack/react-start/server");
const request = getRequest();
const cookieHeader = request.headers.get("cookie") ?? "";
return parseCookieValue(cookieHeader);
},
);

function parseCookieValue(cookieHeader: string): SerializedFilterStore {
try {
for (const cookie of cookieHeader.split(";")) {
const [rawName, ...rawValue] = cookie.trim().split("=");
if (rawName === COOKIE_NAME) {
const decoded = decodeURIComponent(rawValue.join("="));
const parsed = JSON.parse(decoded);
if (
typeof parsed === "object" &&
parsed !== null &&
!Array.isArray(parsed)
) {
return parsed as SerializedFilterStore;
}
}
}
} catch {
// corrupted cookie — return empty
}
return {};
}

// ── Client: write cookie ───────────────────────────────────────────────

export function writeFilterCookie(store: SerializedFilterStore) {
try {
const value = encodeURIComponent(JSON.stringify(store));
// biome-ignore lint/suspicious/noDocumentCookie: Cookie Store API not available in all browsers
document.cookie = `${COOKIE_NAME}=${value}; path=/; max-age=${MAX_AGE}; SameSite=Lax`;
} catch {
// silently ignore
}
}

export function readFilterCookieClient(): SerializedFilterStore {
if (typeof document === "undefined") return {};
return parseCookieValue(document.cookie);
}
7 changes: 7 additions & 0 deletions apps/dashboard/src/components/filters/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export { FilterBar } from "./filter-bar";
export type { SerializedFilterStore } from "./filter-cookie";
export { getFilterCookie } from "./filter-cookie";
export { issueFilterDefs, issueSortOptions } from "./issue-filters";
export { pullFilterDefs, pullSortOptions } from "./pull-filters";
export type { FilterableItem, ListFilterState } from "./use-list-filters";
export { applyFilters, useListFilters } from "./use-list-filters";
143 changes: 143 additions & 0 deletions apps/dashboard/src/components/filters/issue-filters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import {
CircleIcon,
FolderLibraryIcon,
IssuesIcon,
UserCircleIcon,
} from "@diffkit/icons";
import { createElement } from "react";
import type {
FilterableItem,
FilterDefinition,
SortOption,
} from "./use-list-filters";

type IssueFilterableItem = FilterableItem & {
stateReason: string | null;
};

function asIssue(item: FilterableItem): IssueFilterableItem {
return item as IssueFilterableItem;
}

export const issueFilterDefs: FilterDefinition[] = [
{
id: "repo",
label: "Repository",
icon: FolderLibraryIcon,
extractOptions: (items) => {
const repos = new Map<string, string>();
for (const item of items) {
const name = item.repository.fullName;
if (!repos.has(name)) repos.set(name, name);
}
return [...repos.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([value, label]) => ({ value, label }));
},
match: (item, values) => values.has(item.repository.fullName),
},
{
id: "author",
label: "Author",
icon: UserCircleIcon,
extractOptions: (items) => {
const authors = new Map<string, { login: string; avatarUrl: string }>();
for (const item of items) {
if (item.author && !authors.has(item.author.login)) {
authors.set(item.author.login, item.author);
}
}
return [...authors.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([login, author]) => ({
value: login,
label: login,
icon: createElement("img", {
src: author.avatarUrl,
alt: login,
className: "size-4 rounded-full",
}),
}));
},
match: (item, values) =>
item.author ? values.has(item.author.login) : false,
},
{
id: "status",
label: "Status",
icon: CircleIcon,
extractOptions: (items) => {
const statuses = new Set<string>();
for (const item of items) {
const issue = asIssue(item);
if (issue.state === "closed") {
statuses.add(
issue.stateReason === "not_planned" ? "not_planned" : "completed",
);
} else {
statuses.add("open");
}
}
const all = [
{ value: "open", label: "Open" },
{ value: "completed", label: "Closed (completed)" },
{ value: "not_planned", label: "Closed (not planned)" },
];
const colorMap: Record<string, string> = {
open: "text-green-500",
completed: "text-purple-500",
not_planned: "text-muted-foreground",
};
return all
.filter((s) => statuses.has(s.value))
.map((s) => ({
value: s.value,
label: s.label,
icon: createElement(IssuesIcon, {
size: 14,
className: colorMap[s.value],
}),
}));
},
match: (item, values) => {
const issue = asIssue(item);
if (issue.state === "closed") {
return issue.stateReason === "not_planned"
? values.has("not_planned")
: values.has("completed");
}
return values.has("open");
},
},
];

export const issueSortOptions: SortOption[] = [
{
id: "updated",
label: "Recently updated",
compare: (a, b) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
},
{
id: "created",
label: "Newest first",
compare: (a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
},
{
id: "created-asc",
label: "Oldest first",
compare: (a, b) =>
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
},
{
id: "comments",
label: "Most comments",
compare: (a, b) => b.comments - a.comments,
},
{
id: "title",
label: "Title A–Z",
compare: (a, b) => a.title.localeCompare(b.title),
},
];
148 changes: 148 additions & 0 deletions apps/dashboard/src/components/filters/pull-filters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import {
CircleIcon,
FolderLibraryIcon,
GitMergeIcon,
GitPullRequestClosedIcon,
GitPullRequestDraftIcon,
GitPullRequestIcon,
UserCircleIcon,
} from "@diffkit/icons";
import { createElement } from "react";
import type {
FilterableItem,
FilterDefinition,
SortOption,
} from "./use-list-filters";

type PullFilterableItem = FilterableItem & {
isDraft: boolean;
mergedAt: string | null;
};

function asPull(item: FilterableItem): PullFilterableItem {
return item as PullFilterableItem;
}

export const pullFilterDefs: FilterDefinition[] = [
{
id: "repo",
label: "Repository",
icon: FolderLibraryIcon,
extractOptions: (items) => {
const repos = new Map<string, string>();
for (const item of items) {
const name = item.repository.fullName;
if (!repos.has(name)) repos.set(name, name);
}
return [...repos.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([value, label]) => ({ value, label }));
},
match: (item, values) => values.has(item.repository.fullName),
},
{
id: "author",
label: "Author",
icon: UserCircleIcon,
extractOptions: (items) => {
const authors = new Map<string, { login: string; avatarUrl: string }>();
for (const item of items) {
if (item.author && !authors.has(item.author.login)) {
authors.set(item.author.login, item.author);
}
}
return [...authors.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([login, author]) => ({
value: login,
label: login,
icon: createElement("img", {
src: author.avatarUrl,
alt: login,
className: "size-4 rounded-full",
}),
}));
},
match: (item, values) =>
item.author ? values.has(item.author.login) : false,
},
{
id: "status",
label: "Status",
icon: CircleIcon,
extractOptions: (items) => {
const statuses = new Set<string>();
for (const item of items) {
const pull = asPull(item);
if (pull.mergedAt) statuses.add("merged");
else if (pull.state === "closed") statuses.add("closed");
else if (pull.isDraft) statuses.add("draft");
else statuses.add("open");
}
const all: {
value: string;
label: string;
icon: React.ComponentType<{ size?: number; className?: string }>;
}[] = [
{ value: "open", label: "Open", icon: GitPullRequestIcon },
{ value: "draft", label: "Draft", icon: GitPullRequestDraftIcon },
{ value: "merged", label: "Merged", icon: GitMergeIcon },
{ value: "closed", label: "Closed", icon: GitPullRequestClosedIcon },
];
const colorMap: Record<string, string> = {
open: "text-green-500",
draft: "text-muted-foreground",
merged: "text-purple-500",
closed: "text-red-500",
};
return all
.filter((s) => statuses.has(s.value))
.map((s) => ({
value: s.value,
label: s.label,
icon: createElement(s.icon, {
size: 14,
className: colorMap[s.value],
}),
}));
},
match: (item, values) => {
const pull = asPull(item);
if (pull.mergedAt) return values.has("merged");
if (pull.state === "closed") return values.has("closed");
if (pull.isDraft) return values.has("draft");
return values.has("open");
},
},
];

export const pullSortOptions: SortOption[] = [
{
id: "updated",
label: "Recently updated",
compare: (a, b) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(),
},
{
id: "created",
label: "Newest first",
compare: (a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
},
{
id: "created-asc",
label: "Oldest first",
compare: (a, b) =>
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
},
{
id: "comments",
label: "Most comments",
compare: (a, b) => b.comments - a.comments,
},
{
id: "title",
label: "Title A–Z",
compare: (a, b) => a.title.localeCompare(b.title),
},
];
Loading
Loading