6 Commits

Author SHA1 Message Date
0ef0b27c50 fix create techstack form 2026-03-10 21:14:36 +01:00
495bd52c5b neon stack item 2026-03-10 20:59:46 +01:00
bf2085fcce project description 2026-03-10 20:57:09 +01:00
61e016d829 project description 2026-03-10 20:35:30 +01:00
e77eda0220 project page 2026-03-10 20:20:47 +01:00
fc4fab9478 fix adminwrap issue 2026-03-10 19:52:00 +01:00
16 changed files with 294 additions and 26 deletions

1
.gitignore vendored
View File

@@ -46,3 +46,4 @@ yarn-error.log*
.idea
# clerk configuration (can include secrets)
/.clerk/
.claudesession

View File

@@ -40,6 +40,7 @@
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@t3-oss/env-nextjs": "^0.13.10",
"@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-query-next-experimental": "^5.91.0",
"@testing-library/user-event": "^14.6.1",
@@ -777,6 +778,8 @@
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.2.1", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "postcss": "^8.5.6", "tailwindcss": "4.2.1" } }, "sha512-OEwGIBnXnj7zJeonOh6ZG9woofIjGrd2BORfvE5p9USYKDCZoQmfqLcfNiRWoJlRWLdNPn2IgVZuWAOM4iTYMw=="],
"@tailwindcss/typography": ["@tailwindcss/typography@0.5.19", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg=="],
"@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="],
"@tanstack/react-query": ["@tanstack/react-query@5.90.21", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg=="],
@@ -1903,7 +1906,7 @@
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
"postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="],
"postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="],
"postgres": ["postgres@3.4.8", "", {}, "sha512-d+JFcLM17njZaOLkv6SCev7uoLaBtfK86vMUXhW1Z4glPWh4jozno9APvW/XKFJ3CCxVoC7OL38BqRydtu5nGg=="],
@@ -2571,6 +2574,8 @@
"shadcn/diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
"shadcn/postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="],
"shadcn/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"sharp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],

View File

@@ -54,6 +54,7 @@
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@t3-oss/env-nextjs": "^0.13.10",
"@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-query-next-experimental": "^5.91.0",
"@testing-library/user-event": "^14.6.1",

View File

@@ -1,7 +1,10 @@
import { isAdmin } from "~/app/actions"
export default async function AdminWrap({children,}: Readonly<{ children: React.ReactNode }>) {
if (await isAdmin()) {
// "use client"
// import { isAdmin } from "~/app/actions"
import { useUser } from "@clerk/nextjs"
import { env } from "~/env"
export default function AdminWrap({children,}: Readonly<{ children: React.ReactNode }>) {
const user = useUser();
if (user.isSignedIn && user.user.id == env.NEXT_PUBLIC_ADMIN_USER_CLERK_ID) {
return <>{children}</>
}
return (<></>)

View File

@@ -1,6 +1,7 @@
"use client"
import Link from "next/link"
import AdminWrap from "./AdminWrap"
import { Show, SignInButton, SignOutButton, SignUpButton, UserButton } from "@clerk/nextjs"
import { ClerkLoaded, Show, SignInButton, SignOutButton, SignUpButton, UserButton } from "@clerk/nextjs"
import { Button } from "~/components/ui/button"
import { ThemeSwitch } from "./ThemeSwitch"
@@ -39,6 +40,7 @@ export default function TopNav() {
</Button>
</Show>
<ThemeSwitch />
<ClerkLoaded>
<Show when="signed-in">
<Button asChild className="flex h-10 lg:h-full cursor-pointer lg:w-20 content-center" variant={"outline"}>
<div>
@@ -46,6 +48,7 @@ export default function TopNav() {
</div>
</Button>
</Show>
</ClerkLoaded>
</div>
</nav>
</div>

View File

@@ -47,7 +47,7 @@ export default function CvPage() {
entires[0].filter((e) => { return e.categoryId == cat.id }).length > 0 ? (
<>
{entires[0].filter((e) => { return e.categoryId == cat.id }).map((entry) => (
<CollapsibleForm entityName="Entry" form={CreateUpdateCvEntryForm} entity={entry} entityLabelIndex="title" />
<CollapsibleForm key={entry.id} entityName="Entry" form={CreateUpdateCvEntryForm} entity={entry} entityLabelIndex="title" />
))}
</>
) : (<></>)

View File

@@ -8,19 +8,23 @@ import type { IterableElement } from 'type-fest'
import { entitySchemas, makeOnSuccess } from "~/lib/utils";
import { useEffect, useState } from "react";
import type { RouterOutputs } from '~/server/routers/_app';
import { SelectFormField, TextInputFormField } from '~/app/_components/Form/Fields'
import { SelectFormField, TextInputFormField, MdeFormField } from '~/app/_components/Form/Fields'
import { FormScaffold } from '~/app/_components/Form/Components';
import { usePathname, useRouter } from 'next/navigation';
import { useTheme } from 'next-themes';
import { makeUseRelationShipWithNameIndex } from '~/lib/hooks';
import { FormMutationContextProvider } from '~/app/_components/Form/Components/MutationProvider';
export default function CreateUpdateProjectForm(params: { className?: string, entity?: IterableElement<RouterOutputs['project']['select']> }) {
const [id, setId] = useState<string | undefined>(params.entity ? params.entity.id : undefined)
const { theme } = useTheme()
const schemas = entitySchemas('project')
const { data: stacks, id: stackId, name: stackName, success: stacksSuccess, error: stackError } = makeUseRelationShipWithNameIndex('stackItems')(trpc.techStack.select.useQuery({}), id, (items) => { return items ? items.join('-') : "" })
const form = useForm<z.infer<typeof schemas.insert>>({
resolver: zodResolver(schemas.insert),
defaultValues: {
id: id ? id : crypto.randomUUID(),
title: params.entity ? params.entity.title : "",
description: params.entity ? params.entity.description : "",
stackId: params.entity ? params.entity.stackId : stacksSuccess ? stacks?.at(0)?.id : "",
releaseStatus: params.entity ? params.entity.releaseStatus : "unreleased",
releaseLink: params.entity ? params.entity.releaseLink : "",
@@ -64,6 +68,7 @@ export default function CreateUpdateProjectForm(params: { className?: string, en
}
</SelectFormField>
<TextInputFormField control={form.control} name='title' label='Title' />
<MdeFormField control={form.control} name='description' label='Description' dataColorMode={(theme as "dark" | "light") ?? "dark"} />
<SelectFormField control={form.control} name='sourceType' label='Source Type' defaultValue={'open'} placeholder='open' >
<SelectItem value="open"> open </SelectItem>
<SelectItem value="closed"> closed </SelectItem>

View File

@@ -38,7 +38,7 @@ export default function ProjectList() {
techStacks[0].filter((e) => { return e.id == project.stackId }).length > 0 ? (
<>
{techStacks[0].filter((e) => { return e.id == project.stackId }).map((stack) => (
<CollapsibleForm entityName="Stack" form={CreateUpdateStackForm} entity={stack} entityLabelIndex="stackItems"/>
<CollapsibleForm key={stack.id} entityName="Stack" form={CreateUpdateStackForm} entity={stack} entityLabelIndex="stackItems"/>
))}
</>
) : (<></>)

View File

@@ -31,7 +31,7 @@ export default function CreateUpdateStackForm(params: { className?: string, enti
const deleteMutation = trpc.techStack.delete.useMutation({ onSuccess: makeOnSuccess('delete', form, undefined, path, router) })
function onSubmit(values: z.infer<typeof schemas.insert>) {
setSubmitted(true)
params.entity ?
id ?
updateMutation.mutate(values) :
createMutation.mutate(values);
}

View File

@@ -1,12 +1,93 @@
'use client'
import { usePathname } from "next/navigation"
import { trpc } from "~/app/_trpc/Client";
import * as Card from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
import { StackBadge } from "~/components/StackBadge";
import Markdown from "react-markdown";
export default function Page() {
const pathName = usePathname()
export default function ProjectsPage() {
const { data: projects, isLoading } = trpc.projectv2.listWithStack.useQuery();
if (isLoading) {
return (
<div>
{pathName}
<div className="flex justify-center items-center min-h-[200px] text-muted-foreground">
Loading...
</div>
)
);
}
if (!projects?.length) {
return (
<div className="flex justify-center items-center min-h-[200px] text-muted-foreground">
No projects yet.
</div>
);
}
return (
<div className="w-full max-w-4xl mx-auto px-4 py-8 flex flex-col gap-4">
{projects.map((project) => (
<Card.Card key={project.id}>
<Card.CardHeader>
<div className="flex items-start justify-between gap-2 flex-wrap">
<Card.CardTitle>{project.title}</Card.CardTitle>
<div className="flex gap-2 flex-wrap">
{project.sourceType && (
<Badge variant={project.sourceType === "open" ? "secondary" : "outline"}>
{project.sourceType === "open" ? "Open Source" : "Closed Source"}
</Badge>
)}
{project.releaseStatus && (
<Badge variant={project.releaseStatus === "released" ? "default" : "outline"}>
{project.releaseStatus === "released" ? "Released" : "Unreleased"}
</Badge>
)}
</div>
</div>
</Card.CardHeader>
{(project.description || project.sourceLink || project.releaseLink || project.techStack?.stackItems?.length) && (
<Card.CardContent className="flex flex-col gap-3">
{project.description && (
<div className="prose prose-sm dark:prose-invert max-w-none text-muted-foreground">
<Markdown>{project.description}</Markdown>
</div>
)}
{(project.sourceLink || project.releaseLink) && (
<div className="flex gap-3 flex-wrap">
{project.sourceLink && (
<a
href={project.sourceLink}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-muted-foreground hover:text-foreground underline underline-offset-4 transition-colors"
>
Source
</a>
)}
{project.releaseLink && (
<a
href={project.releaseLink}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-muted-foreground hover:text-foreground underline underline-offset-4 transition-colors"
>
Live
</a>
)}
</div>
)}
{project.techStack?.stackItems && project.techStack.stackItems.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{project.techStack.stackItems.map((item) => (
<StackBadge key={item} item={item} />
))}
</div>
)}
</Card.CardContent>
)}
</Card.Card>
))}
</div>
);
}

View File

@@ -0,0 +1,156 @@
import { Badge } from "~/components/ui/badge";
interface SvglIcon {
light: string;
dark: string;
}
const STACK_META: Record<string, { label: string; icon?: SvglIcon }> = {
drizzle: {
label: "Drizzle ORM",
icon: {
light: "https://svgl.app/library/drizzle-orm_light.svg",
dark: "https://svgl.app/library/drizzle-orm_dark.svg",
},
},
postgres: {
label: "PostgreSQL",
icon: {
light: "https://svgl.app/library/postgresql.svg",
dark: "https://svgl.app/library/postgresql.svg",
},
},
nextjs: {
label: "Next.js",
icon: {
light: "https://svgl.app/library/nextjs_icon_dark.svg",
dark: "https://svgl.app/library/nextjs_icon_dark.svg",
},
},
react: {
label: "React",
icon: {
light: "https://svgl.app/library/react_light.svg",
dark: "https://svgl.app/library/react_dark.svg",
},
},
servercomponents: { label: "Server Components" },
php: {
label: "PHP",
icon: {
light: "https://svgl.app/library/php.svg",
dark: "https://svgl.app/library/php_dark.svg",
},
},
laravel: {
label: "Laravel",
icon: {
light: "https://svgl.app/library/laravel.svg",
dark: "https://svgl.app/library/laravel.svg",
},
},
reactnative: {
label: "React Native",
icon: {
light: "https://svgl.app/library/react_light.svg",
dark: "https://svgl.app/library/react_dark.svg",
},
},
"react-native": {
label: "React Native",
icon: {
light: "https://svgl.app/library/react_light.svg",
dark: "https://svgl.app/library/react_dark.svg",
},
},
expo: {
label: "Expo",
icon: {
light: "https://svgl.app/library/expo.svg",
dark: "https://svgl.app/library/expo.svg",
},
},
mysql: {
label: "MySQL",
icon: {
light: "https://svgl.app/library/mysql-icon-light.svg",
dark: "https://svgl.app/library/mysql-icon-dark.svg",
},
},
nginx: {
label: "Nginx",
icon: {
light: "https://svgl.app/library/nginx.svg",
dark: "https://svgl.app/library/nginx.svg",
},
},
protobuf: { label: "Protobuf" },
grpc: { label: "gRPC" },
java: {
label: "Java",
icon: {
light: "https://svgl.app/library/java.svg",
dark: "https://svgl.app/library/java.svg",
},
},
graalvm: { label: "GraalVM" },
spring: {
label: "Spring",
icon: {
light: "https://svgl.app/library/spring.svg",
dark: "https://svgl.app/library/spring.svg",
},
},
aws: {
label: "AWS",
icon: {
light: "https://svgl.app/library/aws_light.svg",
dark: "https://svgl.app/library/aws_dark.svg",
},
},
s3: { label: "Amazon S3" },
linux: {
label: "Linux",
icon: {
light: "https://svgl.app/library/linux.svg",
dark: "https://svgl.app/library/linux.svg",
},
},
debian: { label: "Debian" },
htmx: { label: "HTMX" },
neon: {
label: "Neon",
icon: {
light: "https://svgl.app/library/neon.svg",
dark: "https://svgl.app/library/neon.svg",
},
},
};
export function StackBadge({ item }: { item: string }) {
const meta = STACK_META[item] ?? { label: item };
return (
<Badge variant="outline">
{meta.icon && (
<>
<img
src={meta.icon.light}
alt=""
width={12}
height={12}
className="dark:hidden shrink-0"
/>
<img
src={meta.icon.dark}
alt=""
width={12}
height={12}
className="hidden dark:block shrink-0"
/>
</>
)}
{meta.label}
</Badge>
);
}

View File

@@ -38,6 +38,7 @@ export const env = createEnv({
* `NEXT_PUBLIC_`.
*/
client: {
NEXT_PUBLIC_ADMIN_USER_CLERK_ID: z.string(),
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: z.string()
// NEXT_PUBLIC_CLIENTVAR: z.string(),
},
@@ -63,6 +64,7 @@ export const env = createEnv({
POSTGRES_URL_NO_SSL: process.env.POSTGRES_URL_NO_SSL,
POSTGRES_PRISMA_URL: process.env.POSTGRES_PRISMA_URL,
ADMIN_USER_CLERK_ID: process.env.ADMIN_USER_CLERK_ID,
NEXT_PUBLIC_ADMIN_USER_CLERK_ID: process.env.NEXT_PUBLIC_ADMIN_USER_CLERK_ID,
CLERK_SECRET_KEY: process.env.CLERK_SECRET_KEY,
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
NODE_ENV: process.env.NODE_ENV,

View File

@@ -4,6 +4,7 @@ import { env } from "~/env";
const isTenantAdminRoute = createRouteMatcher(['/admin(.*)'])
export default clerkMiddleware(async (auth,req) => {
if (isTenantAdminRoute(req)) {
console.log("running clerk middleware");
let userid = (await auth()).userId
if (userid != env.ADMIN_USER_CLERK_ID) {
await auth.protect()

View File

@@ -55,13 +55,14 @@ export const cvEntryRelations = relations(cvEntry, ({one}) => ({
export const sourceTypeEnum = pgEnum('source_type',['open','closed'])
export const releaseStatus = pgEnum('release_status',['released','unreleased'])
export const stackItemEnum = pgEnum('stack_item',['drizzle','postgres','nextjs','react','servercomponents','php','laravel','reactnative','expo','mysql','nginx','protobuf','grpc'])
export const stackItemEnum = pgEnum('stack_item',['drizzle','postgres','nextjs','react','servercomponents','php','laravel','reactnative','expo','mysql','nginx','protobuf','grpc','java','graalvm','spring','aws','s3','react-native','linux','debian','htmx','neon'])
export const project = createTable(
"project",
(d) => ({
id: d.uuid().primaryKey().notNull(),
title: d.varchar({length: 50}).notNull(),
description: d.text(),
sourceType: sourceTypeEnum(),
sourceLink: d.varchar({length: 200}),
releaseStatus: releaseStatus(),

View File

@@ -1,4 +1,12 @@
import { router } from "~/server/trpc";
import { publicProcedure, router } from "~/server/trpc";
import { db } from "~/server/db";
export const projectRouter = router({
listWithStack: publicProcedure.query(async () => {
return db.query.project.findMany({
with: {
techStack: true,
},
});
}),
});

View File

@@ -1,7 +1,8 @@
import type { Config } from "tailwindcss"
const config = {
plugins: [
require("tailwindcss-motion")
require("tailwindcss-motion"),
require("@tailwindcss/typography")
]
} satisfies Config