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
54 changes: 54 additions & 0 deletions src/actions/app-admin-actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
"use server";

import { z } from "zod";
import { createServerAction } from "zsa";
import { createApp, createCategory, getAllApps, getAllCategories } from "@/db/apps";
import { requireAdmin } from "@/utils/auth";

export const getAppsAction = createServerAction()
.handler(async () => {
await requireAdmin();
const apps = await getAllApps();
return { apps };
});

export const getCategoriesAction = createServerAction()
.handler(async () => {
await requireAdmin();
const categories = await getAllCategories();
return { categories };
});

const createCategorySchema = z.object({
slug: z.string().min(1),
name: z.string().min(1),
emoji: z.string().optional(),
description: z.string().optional(),
});

export const createCategoryAction = createServerAction()
.input(createCategorySchema)
.handler(async ({ input }) => {
await requireAdmin();
const cat = await createCategory(input);
return { category: cat };
});

const createAppSchema = z.object({
name: z.string().min(1),
slug: z.string().min(1),
description: z.string().optional(),
url: z.string().optional(),
icon: z.string().optional(),
categorySlug: z.string().min(1),
type: z.string().optional(),
featured: z.boolean().optional(),
});

export const createAppAction = createServerAction()
.input(createAppSchema)
.handler(async ({ input }) => {
await requireAdmin();
const app = await createApp({ ...input, featured: input.featured ? 1 : 0 });
return { app };
});
8 changes: 8 additions & 0 deletions src/app/(admin)/admin/_components/admin-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ const adminNavItems: NavMainItem[] = [
icon: Users,
isActive: true,
},
{
title: "Apps",
url: "/admin/apps",
},
{
title: "Categories",
url: "/admin/categories",
},
]

export function AdminSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
Expand Down
31 changes: 31 additions & 0 deletions src/app/(admin)/admin/apps/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { PageHeader } from "@/components/page-header";
import { getAppsAction } from "@/actions/app-admin-actions";
import { useServerAction } from "zsa-react";
import { useEffect } from "react";
import type { App } from "@/db/schema";

export default function AdminAppsPage() {
const { execute, data } = useServerAction(getAppsAction);
useEffect(() => {
execute();
}, [execute]);

return (
<div className="container mx-auto py-10 px-6">
<PageHeader items={[{ href: "/admin/apps", label: "Apps" }]} />
<h1 className="text-3xl font-bold mb-4">Apps</h1>
{!data ? (
<p>Loading...</p>
) : (
<ul className="space-y-2">
{data.apps.map((app: App) => (
<li key={app.id} className="border p-4 rounded-md">
<div className="font-semibold">{app.name}</div>
<div className="text-sm text-muted-foreground">{app.slug}</div>
</li>
))}
</ul>
)}
</div>
);
}
31 changes: 31 additions & 0 deletions src/app/(admin)/admin/categories/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { PageHeader } from "@/components/page-header";
import { getCategoriesAction } from "@/actions/app-admin-actions";
import { useServerAction } from "zsa-react";
import { useEffect } from "react";
import type { AppCategory } from "@/db/schema";

export default function AdminCategoriesPage() {
const { execute, data } = useServerAction(getCategoriesAction);
useEffect(() => {
execute();
}, [execute]);

return (
<div className="container mx-auto py-10 px-6">
<PageHeader items={[{ href: "/admin/categories", label: "Categories" }]} />
<h1 className="text-3xl font-bold mb-4">Categories</h1>
{!data ? (
<p>Loading...</p>
) : (
<ul className="space-y-2">
{data.categories.map((cat: AppCategory) => (
<li key={cat.slug} className="border p-4 rounded-md">
<div className="font-semibold">{cat.name}</div>
<div className="text-sm text-muted-foreground">{cat.slug}</div>
</li>
))}
</ul>
)}
</div>
);
}
40 changes: 40 additions & 0 deletions src/app/(marketing)/categories/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Metadata } from "next";
import { getCategoryBySlug, getAppsByCategorySlug, getAllCategories } from "@/db/apps";
import { AppCard } from "@/components/app-card";
import { notFound } from "next/navigation";

interface PageProps {
params: { slug: string };
}

export async function generateStaticParams() {
const categories = await getAllCategories();
return categories.map(c => ({ slug: c.slug }));
}

export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const category = await getCategoryBySlug(params.slug);
return { title: category ? `${category.name} Apps` : "Category" };
}

export default async function CategoryPage({ params }: PageProps) {
const category = await getCategoryBySlug(params.slug);
if (!category) return notFound();
const apps = await getAppsByCategorySlug(params.slug);
return (
<div className="pb-12">
<section className="py-12 px-4 text-center space-y-4">
{category.emoji && <div className="text-6xl">{category.emoji}</div>}
<h1 className="text-4xl font-bold">{category.name}</h1>
<p className="text-muted-foreground">{category.description}</p>
</section>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3 p-4">
{apps.length ? (
apps.map(app => <AppCard key={app.slug} app={app} />)
) : (
<p>No apps available in this category yet.</p>
)}
</div>
</div>
);
}
19 changes: 19 additions & 0 deletions src/app/(marketing)/categories/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Metadata } from "next";
import { getAllCategories } from "@/db/apps";
import { CategoryCard } from "@/components/category-card";

export const metadata: Metadata = {
title: "Categories",
description: "Browse app categories",
};

export default async function CategoriesPage() {
const categories = await getAllCategories();
return (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3 p-4">
{categories.map(cat => (
<CategoryCard key={cat.slug} category={cat} />
))}
</div>
);
}
30 changes: 30 additions & 0 deletions src/components/category-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"use client";

import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import Link from "next/link";
import type { Route } from "next";

export interface CategoryInfo {
slug: string;
name: string;
emoji: string | null;
description: string | null;
}

export function CategoryCard({ category }: { category: CategoryInfo }) {
return (
<Link href={`/categories/${category.slug}` as Route}>
<Card className="hover:shadow-md transition-shadow h-full">
<CardHeader className="flex items-center gap-2">
{category.emoji && <span className="text-4xl">{category.emoji}</span>}
<CardTitle>{category.name}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
{category.description}
</p>
</CardContent>
</Card>
</Link>
);
}
54 changes: 54 additions & 0 deletions src/db/apps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { getDB } from './index'
import { appTable, appCategoryTable } from './schema'
import { eq } from 'drizzle-orm'

export async function getAllCategories() {
const db = getDB()
return db.select().from(appCategoryTable)
}

export async function getCategoryBySlug(slug: string) {
const db = getDB()
return db.query.appCategoryTable.findFirst({ where: eq(appCategoryTable.slug, slug) })
}

export async function getAllApps() {
const db = getDB()
return db.select().from(appTable)
}

export async function getAppsByCategorySlug(slug: string) {
const db = getDB()
return db.query.appTable.findMany({ where: eq(appTable.categorySlug, slug) })
}

export async function getAppBySlug(slug: string) {
const db = getDB()
return db.query.appTable.findFirst({ where: eq(appTable.slug, slug) })
}

export async function createCategory(data: {
slug: string
name: string
emoji?: string | null
description?: string | null
}) {
const db = getDB()
const [cat] = await db.insert(appCategoryTable).values(data).returning()
return cat
}

export async function createApp(data: {
name: string
slug: string
description?: string | null
url?: string | null
icon?: string | null
categorySlug: string
type?: string | null
featured?: number | boolean
}) {
const db = getDB()
const [app] = await db.insert(appTable).values(data).returning()
return app
}
23 changes: 23 additions & 0 deletions src/db/migrations/0011_add_apps_and_categories.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
CREATE TABLE `app_category` (
`slug` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`emoji` text,
`description` text
);

CREATE TABLE `app` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`slug` text NOT NULL,
`description` text,
`url` text,
`icon` text,
`categorySlug` text NOT NULL,
`type` text,
`featured` integer DEFAULT 0 NOT NULL,
`createdAt` integer NOT NULL,
`updatedAt` integer NOT NULL,
FOREIGN KEY (`categorySlug`) REFERENCES `app_category`(`slug`)
);
CREATE UNIQUE INDEX `app_slug_idx` ON `app` (`slug`);
CREATE INDEX `app_category_idx` ON `app` (`categorySlug`);
7 changes: 7 additions & 0 deletions src/db/migrations/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,5 +79,12 @@
"tag": "0010_add_nickname",
"breakpoints": true
}
,{
"idx": 11,
"version": "6",
"when": 1753364478250,
"tag": "0011_add_apps_and_categories",
"breakpoints": true
}
]
}
37 changes: 37 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,30 @@ export const slowRequestLogTable = sqliteTable("slow_request_log", {
index('slow_request_log_user_idx').on(table.userId),
]));

export const appCategoryTable = sqliteTable("app_category", {
slug: text().primaryKey().notNull(),
name: text().notNull(),
emoji: text(),
description: text(),
});

export const appTable = sqliteTable("app", {
id: text().primaryKey().$defaultFn(() => `app_${createId()}`).notNull(),
name: text().notNull(),
slug: text().notNull().unique(),
description: text(),
url: text(),
icon: text(),
categorySlug: text().notNull().references(() => appCategoryTable.slug),
type: text(),
featured: integer().default(0).notNull(),
createdAt: integer({ mode: "timestamp" }).$defaultFn(() => new Date()).notNull(),
updatedAt: integer({ mode: "timestamp" }).$onUpdateFn(() => new Date()).notNull(),
}, (table) => ([
index('app_slug_idx').on(table.slug),
index('app_category_idx').on(table.categorySlug),
]));

export const teamRelations = relations(teamTable, ({ many }) => ({
memberships: many(teamMembershipTable),
invitations: many(teamInvitationTable),
Expand Down Expand Up @@ -393,6 +417,17 @@ export const slowRequestLogRelations = relations(slowRequestLogTable, ({ one })
}),
}));

export const appCategoryRelations = relations(appCategoryTable, ({ many }) => ({
apps: many(appTable),
}));

export const appRelations = relations(appTable, ({ one }) => ({
category: one(appCategoryTable, {
fields: [appTable.categorySlug],
references: [appCategoryTable.slug],
}),
}));

export type User = InferSelectModel<typeof userTable>;
export type PassKeyCredential = InferSelectModel<typeof passKeyCredentialTable>;
export type CreditTransaction = InferSelectModel<typeof creditTransactionTable>;
Expand All @@ -403,3 +438,5 @@ export type TeamRole = InferSelectModel<typeof teamRoleTable>;
export type TeamInvitation = InferSelectModel<typeof teamInvitationTable>;
export type SlowRequestLog = InferSelectModel<typeof slowRequestLogTable>;
export type Post = InferSelectModel<typeof postTable>;
export type AppCategory = InferSelectModel<typeof appCategoryTable>;
export type App = InferSelectModel<typeof appTable>;