Compare commits

...

11 Commits

Author SHA1 Message Date
f34730b609 chore: update demo
All checks were successful
ci / ci (22, ubuntu-latest) (push) Successful in 9m57s
Build and Push Docker Image / build (push) Successful in 1m47s
2026-04-11 17:16:44 +02:00
e72f7aa839 chore: update readme
All checks were successful
ci / ci (22, ubuntu-latest) (push) Successful in 9m55s
Build and Push Docker Image / build (push) Successful in 2m40s
2026-04-10 22:56:00 +02:00
e858825749 chore: update readme
Some checks failed
ci / ci (22, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Image / build (push) Has been cancelled
2026-04-10 22:53:22 +02:00
90a81f353d chore: fix eslint and make nuxt typecheck happpy
All checks were successful
ci / ci (22, ubuntu-latest) (push) Successful in 9m44s
Build and Push Docker Image / build (push) Successful in 1m29s
2026-03-25 21:42:20 +01:00
8f4be48fd1 feat: page titles and favicon 2026-03-25 21:39:00 +01:00
ffba95a411 feat: ui tweaks 2026-03-25 21:31:39 +01:00
5d5d691bab feat: improve theme 2026-03-25 21:12:03 +01:00
b763eb70db feat: custom error page 2026-03-25 20:54:58 +01:00
783ee1b334 fix: wrong router in traefik tags
Some checks failed
ci / ci (22, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Image / build (push) Successful in 1m25s
2026-03-25 20:06:25 +01:00
73c6bdb89b chore: generate migration
Some checks failed
ci / ci (22, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Image / build (push) Successful in 1m28s
2026-03-25 19:48:56 +01:00
1d5fb09eb0 fix: repair docker build and apply migrations
Some checks failed
ci / ci (22, ubuntu-latest) (push) Has been cancelled
Build and Push Docker Image / build (push) Has been cancelled
2026-03-25 19:24:15 +01:00
25 changed files with 294 additions and 39 deletions

View File

@@ -1,10 +1,9 @@
FROM node:22-alpine AS base FROM node:22-slim AS base
RUN corepack enable pnpm RUN corepack enable
FROM base AS deps FROM base AS deps
WORKDIR /app WORKDIR /app
RUN apk add --no-cache python3 make g++ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
FROM base AS build FROM base AS build
@@ -16,7 +15,6 @@ RUN pnpm build
FROM base AS runtime FROM base AS runtime
WORKDIR /app WORKDIR /app
COPY --from=build /app/.output ./.output COPY --from=build /app/.output ./.output
COPY --from=build /app/server/db/migrations ./server/db/migrations
ENV NODE_ENV=production
EXPOSE 3000 EXPOSE 3000
CMD ["node", "./.output/server/index.mjs"] CMD ["node", ".output/server/index.mjs"]

View File

@@ -1 +1,38 @@
# pihka.al <div align="center">
<h1>pihka.al</h1>
<img src="docs/demo.gif" alt="demo" />
</div>
Personal dashboard for managing my [pihka.al](https://pihka.al) domain. I'm building it for my own use, it's not designed as a generic tool and not meant to be reused as-is.
## What it does
- Manage short URLs served from [pihka.al](https://pihka.al)
## Next features
- **API**: Create an API token that can be used to manage short URLs from anywere
- **File management**: Upload and manage files directly in the dasboard
- **Text service**: Pastebin-like service, including burner messages
## Stack
- [Nuxt 4](https://nuxt.com) + [Nuxt UI](https://ui.nuxt.com)
- [SQLite](https://sqlite.org) via [Drizzle ORM](https://orm.drizzle.team)
## Running locally
```sh
pnpm install
pnpm dev
```
```
# .env.example
DATABASE_URL=sqlite.db
ADMIN_USERNAME=admin
ADMIN_PASSWORD=strong_password
REDIRECT_DOMAIN=pihka.al
NUXT_SESSION_PASSWORD=strong_password
```

10
app/app.config.ts Normal file
View File

@@ -0,0 +1,10 @@
export default defineAppConfig({
ui: {
colors: {
primary: "neutral",
neutral: "zinc",
success: "emerald",
error: "rose",
}
}
})

View File

@@ -1,2 +1,6 @@
@import "tailwindcss"; @import "tailwindcss";
@import "@nuxt/ui"; @import "@nuxt/ui";
:root {
--ui-radius: 0rem;
}

View File

@@ -15,7 +15,7 @@ const emit = defineEmits<{ close: [confirmed: boolean] }>();
<template #footer> <template #footer>
<div class="flex gap-2 justify-end w-full"> <div class="flex gap-2 justify-end w-full">
<UButton variant="ghost" color="neutral" @click="emit('close', false)">Cancel</UButton> <UButton variant="ghost" color="neutral" @click="emit('close', false)">Cancel</UButton>
<UButton color="error" @click="emit('close', true)">Delete</UButton> <UButton color="error" icon="i-lucide-trash-2" @click="emit('close', true)">Delete</UButton>
</div> </div>
</template> </template>
</UModal> </UModal>

View File

@@ -73,7 +73,7 @@ const onSubmit = async (event: FormSubmitEvent<Schema>) => {
<div class="flex justify-end gap-2 pt-2"> <div class="flex justify-end gap-2 pt-2">
<UButton variant="ghost" @click="emit('close', false)">Cancel</UButton> <UButton variant="ghost" @click="emit('close', false)">Cancel</UButton>
<UButton type="submit" :loading="submitting">{{ link ? "Save" : "Create" }}</UButton> <UButton type="submit" variant="subtle" :loading="submitting" :icon="link ? 'i-lucide-save' : 'i-lucide-plus'">{{ link ? "Save" : "Create" }}</UButton>
</div> </div>
</UForm> </UForm>
</template> </template>

View File

@@ -3,7 +3,7 @@ import type { TableColumn } from "@nuxt/ui";
const UBadge = resolveComponent("UBadge"); const UBadge = resolveComponent("UBadge");
const props = defineProps<{ defineProps<{
data: Link[]; data: Link[];
status: "pending" | "idle" | "success" | "error"; status: "pending" | "idle" | "success" | "error";
}>(); }>();

20
app/error.vue Normal file
View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import type { NuxtError } from "#app"
defineProps<{ error: NuxtError }>();
useHead({ title: 'Not found' });
</script>
<template>
<UApp>
<div class="min-h-screen flex items-center justify-center">
<div class="text-center space-y-2">
<p class="text-8xl font-black tracking-tight">404</p>
<p class="text-lg font-medium">This link doesn't exist.</p>
<p class="text-muted text-sm">
Maybe check out <a href="https://pihkaal.me" class="underline underline-offset-2 hover:text-default transition-colors">pihkaal.me</a> instead?
</p>
</div>
</div>
</UApp>
</template>

View File

@@ -2,6 +2,8 @@
import { z } from "zod"; import { z } from "zod";
import type { AuthFormField, FormSubmitEvent } from "@nuxt/ui"; import type { AuthFormField, FormSubmitEvent } from "@nuxt/ui";
useHead({ title: 'Sign in' });
definePageMeta({ definePageMeta({
middleware: () => { middleware: () => {
const { loggedIn } = useUserSession(); const { loggedIn } = useUserSession();

View File

@@ -23,6 +23,13 @@ const category = computed(() => {
return "all"; return "all";
}); });
const categoryTitle = computed(() => {
if (category.value === "active") return "Active links";
if (category.value === "disabled") return "Disabled links";
return "All links";
});
useHead({ title: categoryTitle });
const filteredLinks = computed(() => { const filteredLinks = computed(() => {
if (!links.value) return []; if (!links.value) return [];
if (category.value === "active") return links.value.filter((l) => !l.disabled); if (category.value === "active") return links.value.filter((l) => !l.disabled);
@@ -54,9 +61,9 @@ const deleteLink = async (link: Link) => {
<template> <template>
<UDashboardGroup> <UDashboardGroup>
<UDashboardSidebar :toggle="false"> <UDashboardSidebar :toggle="false" :ui="{ header: 'border-b border-default' }">
<template #header> <template #header>
<UDashboardNavbar title="pihka.al" :toggle="false" /> <span class="font-semibold text-highlighted text-lg">pihka.al</span>
</template> </template>
<UNavigationMenu <UNavigationMenu
@@ -88,9 +95,7 @@ const deleteLink = async (link: Link) => {
class="px-2" class="px-2"
/> />
<template #footer> <template #footer>
<UButton variant="ghost" icon="i-lucide-log-out" block @click="signOut"> <UButton variant="link" icon="i-lucide-log-out" block @click="signOut">Sign out</UButton>
Sign out
</UButton>
</template> </template>
</UDashboardSidebar> </UDashboardSidebar>
@@ -98,9 +103,7 @@ const deleteLink = async (link: Link) => {
<template #header> <template #header>
<UDashboardNavbar :title="category === 'all' ? 'All links' : category === 'active' ? 'Active links' : 'Disabled links'"> <UDashboardNavbar :title="category === 'all' ? 'All links' : category === 'active' ? 'Active links' : 'Disabled links'">
<template #right> <template #right>
<UButton icon="i-lucide-plus" @click="openModal(null)"> <UButton icon="i-lucide-plus" variant="subtle" @click="openModal(null)">New link</UButton>
New link
</UButton>
</template> </template>
</UDashboardNavbar> </UDashboardNavbar>
</template> </template>

View File

@@ -0,0 +1,6 @@
export default defineNuxtPlugin(() => {
const event = useRequestEvent()
if (event?.context.redirectNotFound) {
showError({ statusCode: 404, message: 'Link not found' })
}
})

View File

@@ -1,4 +1,7 @@
import { z } from "zod"; import { z } from "zod";
import type { InternalApi } from "nitropack/types";
export type Link = InternalApi["/api/links"]["get"][number];
const apiErrorSchema = z.object({ const apiErrorSchema = z.object({
data: z.object({ data: z.object({

22
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,22 @@
services:
app:
container_name: pihka-al-dev
build:
context: .
target: build
command: pnpm dev
ports:
- "3000:3000"
environment:
- DATABASE_URL=/data/db.sqlite
- ADMIN_USERNAME=admin
- ADMIN_PASSWORD=admin
- REDIRECT_DOMAIN=localhost
- NUXT_SESSION_PASSWORD=dev-session-password-32-chars-min
volumes:
- .:/app
- /app/node_modules
- db:/data
volumes:
db:

View File

@@ -20,13 +20,13 @@ services:
# dashboard domain # dashboard domain
- traefik.http.routers.pihka-al-dashboard.rule=Host(`${DASHBOARD_DOMAIN}`) - traefik.http.routers.pihka-al-dashboard.rule=Host(`${DASHBOARD_DOMAIN}`)
- traefik.http.routers.pihka-al-dashboard.tls.certresolver=myresolver - traefik.http.routers.pihka-al-dashboard.tls.certresolver=myresolver
- traefik.http.routers.pihkaal-me.tls=true - traefik.http.routers.pihka-al.tls=true
- traefik.http.routers.pihka-al-dashboard.service=pihka-al - traefik.http.routers.pihka-al-dashboard.service=pihka-al
# redirect domain # redirect domain
- traefik.http.routers.pihka-al-redirect.rule=Host(`${REDIRECT_DOMAIN}`) - traefik.http.routers.pihka-al-redirect.rule=Host(`${REDIRECT_DOMAIN}`)
- traefik.http.routers.pihka-al-redirect.tls.certresolver=myresolver - traefik.http.routers.pihka-al-redirect.tls.certresolver=myresolver
- traefik.http.routers.pihkaal-me.tls=true - traefik.http.routers.pihka-al.tls=true
- traefik.http.routers.pihka-al-redirect.service=pihka-al - traefik.http.routers.pihka-al-redirect.service=pihka-al
- traefik.http.routers.pihka-al.middlewares=umami-middleware@file - traefik.http.routers.pihka-al.middlewares=umami-middleware@file

BIN
docs/demo.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 577 KiB

View File

@@ -10,20 +10,24 @@ export default defineNuxtConfig({
} }
}, },
app: {
head: {
titleTemplate: '%s · pihka.al',
link: [{ rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' }],
},
},
devtools: { devtools: {
enabled: true enabled: true
}, },
css: ['~/assets/css/main.css'], css: ['~/assets/css/main.css'],
compatibilityDate: '2025-01-15', nitro: {
externals: {
external: ['better-sqlite3'],
},
},
eslint: { compatibilityDate: '2025-01-15',
config: {
stylistic: {
commaDangle: 'never',
braceStyle: '1tbs'
}
}
}
}) })

6
pnpm-workspace.yml Normal file
View File

@@ -0,0 +1,6 @@
onlyBuiltDependencies:
- "@parcel/watcher"
- better-sqlite3
- esbuild
- unrs-resolver
- vue-demi

51
public/favicon.svg Normal file
View File

@@ -0,0 +1,51 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 18">
<style>
.bg { fill: #000000; }
.fg { fill: #ffffff; }
</style>
<rect x="0" y="11" width="5" height="5" class="bg" />
<rect x="1" y="10" width="7" height="5" class="bg" />
<rect x="6" y="10" width="22" height="6" class="bg" />
<rect x="9" y="5" width="18" height="12" class="bg" />
<rect x="10" y="16" width="16" height="2" class="bg" />
<rect x="7" y="7" width="16" height="4" class="bg" />
<rect x="8" y="6" width="2" height="2" class="bg" />
<rect x="26" y="9" width="2" height="2" class="bg" />
<rect x="11" y="4" width="16" height="2" class="bg" />
<rect x="12" y="3" width="15" height="2" class="bg" />
<rect x="13" y="2" width="6" height="2" class="bg" />
<rect x="14" y="1" width="5" height="2" class="bg" />
<rect x="15" y="0" width="3" height="2" class="bg" />
<rect x="21" y="2" width="6" height="2" class="bg" />
<rect x="22" y="1" width="5" height="2" class="bg" />
<rect x="23" y="0" width="3" height="2" class="bg" />
<rect x="2" y="11" width="5" height="3" class="fg" />
<rect x="1" y="12" width="3" height="3" class="fg" />
<rect x="10" y="6" width="16" height="10" class="fg" />
<rect x="8" y="10" width="19" height="5" class="fg" />
<rect x="7" y="12" width="2" height="3" class="fg" />
<rect x="6" y="12" width="2" height="2" class="fg" />
<rect x="8" y="8" width="18" height="3" class="fg" />
<rect x="9" y="7" width="2" height="2" class="fg" />
<rect x="12" y="15" width="13" height="2" class="fg" />
<rect x="25" y="10" width="2" height="5" class="fg" />
<rect x="15" y="11" width="3" height="1" class="bg" />
<rect x="22" y="11" width="3" height="1" class="bg" />
<rect x="12" y="5" width="14" height="2" class="fg" />
<rect x="13" y="4" width="6" height="2" class="fg" />
<rect x="14" y="3" width="4" height="2" class="fg" />
<rect x="15" y="2" width="3" height="2" class="fg" />
<rect x="16" y="1" width="1" height="2" class="fg" />
<rect x="21" y="4" width="5" height="2" class="fg" />
<rect x="22" y="3" width="4" height="2" class="fg" />
<rect x="23" y="2" width="3" height="2" class="fg" />
<rect x="24" y="1" width="1" height="2" class="fg" />
<rect x="16" y="3" width="1" height="2" class="bg" />
<rect x="15" y="4" width="1" height="2" class="bg" />
<rect x="14" y="5" width="2" height="1" class="bg" />
<rect x="15" y="4" width="2" height="1" class="bg" />
<rect x="24" y="3" width="1" height="3" class="bg" />
<rect x="23" y="4" width="2" height="1" class="bg" />
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

2
public/robots.txt Normal file
View File

@@ -0,0 +1,2 @@
User-agent: *
Disallow: /

View File

@@ -0,0 +1 @@
DROP INDEX `links_url_unique`;

View File

@@ -0,0 +1,79 @@
{
"version": "6",
"dialect": "sqlite",
"id": "a78f922b-eacd-483e-b14d-5cdfadaac74b",
"prevId": "0e8befef-3e4f-43f1-a031-95c2ca514e30",
"tables": {
"links": {
"name": "links",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"path": {
"name": "path",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"disabled": {
"name": "disabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
}
},
"indexes": {
"links_name_unique": {
"name": "links_name_unique",
"columns": [
"name"
],
"isUnique": true
},
"links_path_unique": {
"name": "links_path_unique",
"columns": [
"path"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -15,6 +15,13 @@
"when": 1773792538248, "when": 1773792538248,
"tag": "0001_tense_grey_gargoyle", "tag": "0001_tense_grey_gargoyle",
"breakpoints": true "breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1774463081828,
"tag": "0002_narrow_raider",
"breakpoints": true
} }
] ]
} }

View File

@@ -19,12 +19,9 @@ export default defineEventHandler(async (event) => {
where: eq(tables.links.path, path), where: eq(tables.links.path, path),
}); });
if (!link) { if (!link || link.disabled) {
throw createError({ statusCode: 404, message: "Not found" }); event.context.redirectNotFound = true;
} return;
if (link.disabled) {
throw createError({ statusCode: 410, message: "This link has been disabled" });
} }
return sendRedirect(event, link.url, 302); return sendRedirect(event, link.url, 302);

View File

@@ -0,0 +1,6 @@
import { migrate } from 'drizzle-orm/better-sqlite3/migrator'
import { db } from '../db'
export default defineNitroPlugin(() => {
migrate(db, { migrationsFolder: 'server/db/migrations' })
})

View File

@@ -1,3 +0,0 @@
import type { InternalApi } from "nitropack/types";
export type Link = InternalApi["/api/links"]["get"][number];