From 5ea97a3b07dafc922f1b88f604d60f5dfc5bb308 Mon Sep 17 00:00:00 2001 From: Csaba Polyak Date: Tue, 29 Jul 2025 18:59:32 +0200 Subject: [PATCH] feat: add apps and categories management --- src/actions/app-admin-actions.ts | 54 +++++++++++++++++++ .../admin/_components/admin-sidebar.tsx | 8 +++ src/app/(admin)/admin/apps/page.tsx | 31 +++++++++++ src/app/(admin)/admin/categories/page.tsx | 31 +++++++++++ .../(marketing)/categories/[slug]/page.tsx | 40 ++++++++++++++ src/app/(marketing)/categories/page.tsx | 19 +++++++ src/components/category-card.tsx | 30 +++++++++++ src/db/apps.ts | 54 +++++++++++++++++++ .../0011_add_apps_and_categories.sql | 23 ++++++++ src/db/migrations/meta/_journal.json | 7 +++ src/db/schema.ts | 37 +++++++++++++ 11 files changed, 334 insertions(+) create mode 100644 src/actions/app-admin-actions.ts create mode 100644 src/app/(admin)/admin/apps/page.tsx create mode 100644 src/app/(admin)/admin/categories/page.tsx create mode 100644 src/app/(marketing)/categories/[slug]/page.tsx create mode 100644 src/app/(marketing)/categories/page.tsx create mode 100644 src/components/category-card.tsx create mode 100644 src/db/apps.ts create mode 100644 src/db/migrations/0011_add_apps_and_categories.sql diff --git a/src/actions/app-admin-actions.ts b/src/actions/app-admin-actions.ts new file mode 100644 index 0000000..7b957b0 --- /dev/null +++ b/src/actions/app-admin-actions.ts @@ -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 }; + }); diff --git a/src/app/(admin)/admin/_components/admin-sidebar.tsx b/src/app/(admin)/admin/_components/admin-sidebar.tsx index bcce9a4..ebd63bc 100644 --- a/src/app/(admin)/admin/_components/admin-sidebar.tsx +++ b/src/app/(admin)/admin/_components/admin-sidebar.tsx @@ -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) { diff --git a/src/app/(admin)/admin/apps/page.tsx b/src/app/(admin)/admin/apps/page.tsx new file mode 100644 index 0000000..5d9c98d --- /dev/null +++ b/src/app/(admin)/admin/apps/page.tsx @@ -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 ( +
+ +

Apps

+ {!data ? ( +

Loading...

+ ) : ( +
    + {data.apps.map((app: App) => ( +
  • +
    {app.name}
    +
    {app.slug}
    +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/app/(admin)/admin/categories/page.tsx b/src/app/(admin)/admin/categories/page.tsx new file mode 100644 index 0000000..d0c276c --- /dev/null +++ b/src/app/(admin)/admin/categories/page.tsx @@ -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 ( +
+ +

Categories

+ {!data ? ( +

Loading...

+ ) : ( +
    + {data.categories.map((cat: AppCategory) => ( +
  • +
    {cat.name}
    +
    {cat.slug}
    +
  • + ))} +
+ )} +
+ ); +} diff --git a/src/app/(marketing)/categories/[slug]/page.tsx b/src/app/(marketing)/categories/[slug]/page.tsx new file mode 100644 index 0000000..f024b8f --- /dev/null +++ b/src/app/(marketing)/categories/[slug]/page.tsx @@ -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 { + 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 ( +
+
+ {category.emoji &&
{category.emoji}
} +

{category.name}

+

{category.description}

+
+
+ {apps.length ? ( + apps.map(app => ) + ) : ( +

No apps available in this category yet.

+ )} +
+
+ ); +} diff --git a/src/app/(marketing)/categories/page.tsx b/src/app/(marketing)/categories/page.tsx new file mode 100644 index 0000000..4b3edb5 --- /dev/null +++ b/src/app/(marketing)/categories/page.tsx @@ -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 ( +
+ {categories.map(cat => ( + + ))} +
+ ); +} diff --git a/src/components/category-card.tsx b/src/components/category-card.tsx new file mode 100644 index 0000000..d696ee9 --- /dev/null +++ b/src/components/category-card.tsx @@ -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 ( + + + + {category.emoji && {category.emoji}} + {category.name} + + +

+ {category.description} +

+
+
+ + ); +} diff --git a/src/db/apps.ts b/src/db/apps.ts new file mode 100644 index 0000000..081fd6b --- /dev/null +++ b/src/db/apps.ts @@ -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 +} diff --git a/src/db/migrations/0011_add_apps_and_categories.sql b/src/db/migrations/0011_add_apps_and_categories.sql new file mode 100644 index 0000000..c203c0c --- /dev/null +++ b/src/db/migrations/0011_add_apps_and_categories.sql @@ -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`); diff --git a/src/db/migrations/meta/_journal.json b/src/db/migrations/meta/_journal.json index 241fecf..e918d4e 100644 --- a/src/db/migrations/meta/_journal.json +++ b/src/db/migrations/meta/_journal.json @@ -79,5 +79,12 @@ "tag": "0010_add_nickname", "breakpoints": true } + ,{ + "idx": 11, + "version": "6", + "when": 1753364478250, + "tag": "0011_add_apps_and_categories", + "breakpoints": true + } ] } \ No newline at end of file diff --git a/src/db/schema.ts b/src/db/schema.ts index 9aef5d6..7d0d675 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -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), @@ -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; export type PassKeyCredential = InferSelectModel; export type CreditTransaction = InferSelectModel; @@ -403,3 +438,5 @@ export type TeamRole = InferSelectModel; export type TeamInvitation = InferSelectModel; export type SlowRequestLog = InferSelectModel; export type Post = InferSelectModel; +export type AppCategory = InferSelectModel; +export type App = InferSelectModel;