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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
},
"keywords": [],
"author": "",
"license": "MIT",
"license": "AGPL-3.0-only",
"packageManager": "pnpm@10.29.2",
"dependencies": {
"@base-ui/react": "^1.2.0",
Expand Down
133 changes: 51 additions & 82 deletions scripts/cache-purge.ts
Original file line number Diff line number Diff line change
@@ -1,94 +1,63 @@
import "dotenv/config";

const zoneId = process.env.CF_ZONE_ID;
const apiToken = process.env.CF_API_TOKEN;
const kvNamespaceId = process.env.CF_KV_NAMESPACE_ID;

if (!zoneId || !apiToken) {
console.error("Missing CF_ZONE_ID or CF_API_TOKEN environment variables");
process.exit(1);
import { execSync } from "node:child_process";
import { writeFileSync, readFileSync, unlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

const KV_NAMESPACE_ID = "47a12e13810f43f0b110ac082b60e133";

const args = process.argv.slice(2);
const purgeAll = args.includes("--all");
const keyArgs = args.filter((a) => !a.startsWith("--"));

if (!purgeAll && keyArgs.length === 0) {
console.log("Usage:");
console.log(" pnpm cache:purge <key> [key...] Purge specific KV keys");
console.log(" pnpm cache:purge --all Purge all KV keys");
console.log("");
console.log("Examples:");
console.log(" pnpm cache:purge getPopularApps getRecentApps");
console.log(" pnpm cache:purge --all");
process.exit(0);
}

// Purge Cloudflare edge cache
const cacheResponse = await fetch(
`https://api.cloudflare.com/client/v4/zones/${zoneId}/purge_cache`,
{
method: "POST",
headers: {
Authorization: `Bearer ${apiToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ purge_everything: true }),
},
);

const cacheResult = await cacheResponse.json();

if (cacheResult.success) {
console.log("Edge cache purged");
} else {
console.error("Edge cache purge failed:", cacheResult.errors);
process.exit(1);
}

// Purge KV namespace
if (!kvNamespaceId) {
console.log("No CF_KV_NAMESPACE_ID set, skipping KV purge");
} else {
const accountId = process.env.CF_ACCOUNT_ID;
if (!accountId) {
console.error("Missing CF_ACCOUNT_ID for KV purge");
process.exit(1);
}

// List all keys
let cursor: string | undefined;
const allKeys: string[] = [];

do {
const params = new URLSearchParams();
if (cursor) params.set("cursor", cursor);

const listRes = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${accountId}/storage/kv/namespaces/${kvNamespaceId}/keys?${params}`,
{ headers: { Authorization: `Bearer ${apiToken}` } },
if (keyArgs.length > 0) {
for (const key of keyArgs) {
console.log(`Deleting key: ${key}`);
execSync(
`npx wrangler kv key delete "${key}" --namespace-id=${KV_NAMESPACE_ID} --remote`,
{ stdio: "inherit" },
);
}
console.log(`Purged ${keyArgs.length} key(s)`);
} else {
const listFile = join(tmpdir(), "kv-keys-list.json");
const deleteFile = join(tmpdir(), "kv-keys-to-delete.json");

const listData: any = await listRes.json();
if (!listData.success) {
console.error("KV list failed:", listData.errors);
process.exit(1);
}
console.log("Listing remote KV keys...");
execSync(
`npx wrangler kv key list --namespace-id=${KV_NAMESPACE_ID} --remote > "${listFile}"`,
{ stdio: ["pipe", "pipe", "pipe"] },
);

allKeys.push(...listData.result.map((k: { name: string }) => k.name));
cursor = listData.result_info?.cursor;
} while (cursor);
const keys: { name: string }[] = JSON.parse(
readFileSync(listFile, "utf-8"),
);
unlinkSync(listFile);

if (allKeys.length === 0) {
if (keys.length === 0) {
console.log("KV namespace empty, nothing to purge");
} else {
// Bulk delete (max 10,000 per request)
for (let i = 0; i < allKeys.length; i += 10000) {
const batch = allKeys.slice(i, i + 10000);
const delRes = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${accountId}/storage/kv/namespaces/${kvNamespaceId}/bulk`,
{
method: "DELETE",
headers: {
Authorization: `Bearer ${apiToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(batch),
},
writeFileSync(deleteFile, JSON.stringify(keys.map((k) => k.name)));
try {
execSync(
`npx wrangler kv bulk delete "${deleteFile}" --namespace-id=${KV_NAMESPACE_ID} --remote --force`,
{ stdio: "inherit" },
);

const delData: any = await delRes.json();
if (!delData.success) {
console.error("KV bulk delete failed:", delData.errors);
process.exit(1);
}
console.log(`KV purged: ${keys.length} keys deleted`);
} finally {
try {
unlinkSync(deleteFile);
} catch {}
}

console.log(`KV purged: ${allKeys.length} keys deleted`);
}
}
2 changes: 2 additions & 0 deletions src/components/alternative-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,10 @@ export function AlternativeCard({
</CardFooter>

{app.sources && app.sources.length > 0 && (
// biome-ignore lint/a11y/noStaticElementInteractions: prevents card link click-through
<div
className="px-4 pb-4"
role="presentation"
onClick={(e) => e.preventDefault()}
onKeyDown={(e) => {
if (e.key === "Enter") e.preventDefault();
Expand Down
28 changes: 24 additions & 4 deletions src/components/grid-layout-switcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ const layouts: { value: GridLayout; label: string; icon: React.ReactNode }[] = [
value: "list",
label: "List",
icon: (
<svg viewBox="0 0 16 16" className="size-3.5" fill="currentColor">
<svg
viewBox="0 0 16 16"
className="size-3.5"
fill="currentColor"
aria-hidden="true"
>
<rect x="0" y="1" width="16" height="3" rx="0.5" />
<rect x="0" y="6.5" width="16" height="3" rx="0.5" />
<rect x="0" y="12" width="16" height="3" rx="0.5" />
Expand All @@ -18,7 +23,12 @@ const layouts: { value: GridLayout; label: string; icon: React.ReactNode }[] = [
value: "2",
label: "2 columns",
icon: (
<svg viewBox="0 0 16 16" className="size-3.5" fill="currentColor">
<svg
viewBox="0 0 16 16"
className="size-3.5"
fill="currentColor"
aria-hidden="true"
>
<rect x="0" y="0" width="7" height="7" rx="1" />
<rect x="9" y="0" width="7" height="7" rx="1" />
<rect x="0" y="9" width="7" height="7" rx="1" />
Expand All @@ -30,7 +40,12 @@ const layouts: { value: GridLayout; label: string; icon: React.ReactNode }[] = [
value: "3",
label: "3 columns",
icon: (
<svg viewBox="0 0 16 16" className="size-3.5" fill="currentColor">
<svg
viewBox="0 0 16 16"
className="size-3.5"
fill="currentColor"
aria-hidden="true"
>
<rect x="0" y="0" width="4.3" height="7" rx="0.7" />
<rect x="5.8" y="0" width="4.3" height="7" rx="0.7" />
<rect x="11.7" y="0" width="4.3" height="7" rx="0.7" />
Expand All @@ -44,7 +59,12 @@ const layouts: { value: GridLayout; label: string; icon: React.ReactNode }[] = [
value: "4",
label: "4 columns",
icon: (
<svg viewBox="0 0 16 16" className="size-3.5" fill="currentColor">
<svg
viewBox="0 0 16 16"
className="size-3.5"
fill="currentColor"
aria-hidden="true"
>
<rect x="0" y="0" width="3" height="7" rx="0.5" />
<rect x="4.3" y="0" width="3" height="7" rx="0.5" />
<rect x="8.6" y="0" width="3" height="7" rx="0.5" />
Expand Down
1 change: 1 addition & 0 deletions src/components/html-description.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export function HtmlDescription({ html, className }: HtmlDescriptionProps) {
"prose-description text-sm leading-relaxed text-muted-foreground [&_a]:text-sun-accent [&_a]:underline [&_a:hover]:text-sun-text [&_ol]:list-decimal [&_ol]:pl-5 [&_p+p]:mt-2 [&_ul]:list-disc [&_ul]:pl-5 [&_li]:mt-1",
className,
)}
// biome-ignore lint/security/noDangerouslySetInnerHtml: HTML is sanitized with sanitize-html before storage
dangerouslySetInnerHTML={{ __html: html }}
/>
);
Expand Down
14 changes: 12 additions & 2 deletions src/components/layout/site-footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ export function SiteFooter() {
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-full border border-accent-foreground/20 bg-accent px-3 py-1 text-xs font-medium text-accent-foreground transition-colors hover:bg-accent-foreground/15"
>
<svg className="size-3.5" viewBox="0 0 24 24" fill="currentColor">
<svg
className="size-3.5"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path d="M23.881 8.948c-.773-4.085-4.859-4.593-4.859-4.593H.723c-.604 0-.679.798-.679.798s-.082 7.324-.022 11.822c.164 2.424 2.586 2.672 2.586 2.672s8.267-.023 11.966-.049c2.438-.426 2.683-2.566 2.658-3.734 4.352.24 7.422-2.831 6.649-6.916zm-11.062 3.511c-1.246 1.453-4.011 3.976-4.011 3.976s-.121.119-.31.023c-.076-.057-.108-.09-.108-.09-.443-.441-3.368-3.049-4.034-3.954-.709-.965-1.041-2.7-.091-3.71.951-1.01 3.005-1.086 4.363.407 0 0 1.565-1.782 3.468-.963 1.904.82 1.832 3.011.723 4.311zm6.173.478c-.928.116-1.682.028-1.682.028V7.284h1.77s1.971.551 1.971 2.638c0 1.913-.985 2.667-2.059 3.015z" />
</svg>
Ko-fi
Expand All @@ -32,7 +37,12 @@ export function SiteFooter() {
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-full border border-accent-foreground/20 bg-accent px-3 py-1 text-xs font-medium text-accent-foreground transition-colors hover:bg-accent-foreground/15"
>
<svg className="size-3.5" viewBox="0 0 24 24" fill="currentColor">
<svg
className="size-3.5"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path d="M17.625 1.499c-2.32 0-4.354 1.203-5.625 3.03-1.271-1.827-3.305-3.03-5.625-3.03C3.129 1.499 0 4.253 0 8.249c0 4.275 3.068 7.847 5.828 10.227a33.14 33.14 0 0 0 5.616 3.876l.028.017.008.003-.001.003c.163.085.342.126.521.125.179.001.358-.041.521-.125l-.001-.003.008-.003.028-.017a33.14 33.14 0 0 0 5.616-3.876C20.932 16.096 24 12.524 24 8.249c0-3.996-3.129-6.75-6.375-6.75z" />
</svg>
Sponsor
Expand Down
1 change: 0 additions & 1 deletion src/routes/alternatives/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ function AlternativesIndexPage() {
"@type": "ItemList",
name: "Open Source Alternatives to Popular Apps",
numberOfItems: apps.length,
// biome-ignore lint/suspicious/noExplicitAny: loader return type
itemListElement: apps.map((app: any, index: number) => ({
"@type": "ListItem",
position: index + 1,
Expand Down
10 changes: 7 additions & 3 deletions src/routes/apps/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,6 @@ function AppsPage() {
"@type": "ItemList",
name: "Open Source Privacy-Respecting Apps",
numberOfItems: apps.length,
// biome-ignore lint/suspicious/noExplicitAny: loader return type
itemListElement: apps.map((app: any, index: number) => ({
"@type": "ListItem",
position: index + 1,
Expand Down Expand Up @@ -280,6 +279,7 @@ function CategoryDropdown({
viewBox="0 0 12 12"
className={cn("size-3 transition-transform", open && "rotate-180")}
fill="currentColor"
aria-hidden="true"
>
<path
d="M2.5 4.5L6 8L9.5 4.5"
Expand All @@ -294,8 +294,12 @@ function CategoryDropdown({

{open && (
<>
{/* biome-ignore lint/a11y/useKeyWithClickEvents: backdrop dismiss */}
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
{/* biome-ignore lint/a11y/noStaticElementInteractions: backdrop dismiss overlay */}
<div
role="presentation"
className="fixed inset-0 z-40"
onClick={() => setOpen(false)}
/>
<div className="absolute left-0 z-50 mt-1 max-h-64 w-48 overflow-y-auto rounded-lg border border-border bg-popover p-1 shadow-lg">
{tags.map((tag) => {
const isActive = selectedSlugs.includes(tag.slug);
Expand Down
2 changes: 1 addition & 1 deletion src/routes/search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export const Route = createFileRoute("/search")({
if (!deps.q.trim()) return { apps: [], proprietaryApps: [] };
return fetchSearchResults({ data: { query: deps.q } });
},
head: ({ loaderData }) => {
head: () => {
const title = "Search — Unclouded";
const description =
"Search for open source, privacy-respecting apps and alternatives.";
Expand Down
Loading