Skip to content
Merged
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
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