feat: improvements and initial UI design

This commit is contained in:
DecDuck
2025-08-09 23:13:50 +10:00
parent f26e7f1a45
commit 1587e9601b
38 changed files with 2640 additions and 66 deletions

View File

@@ -1,6 +1,5 @@
<template>
<div>
<NuxtRouteAnnouncer />
<NuxtWelcome />
</div>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>

77
app/assets/css/core.scss Normal file
View File

@@ -0,0 +1,77 @@
$motiva: (
("MotivaSansThin.ttf", "ttf", 100, normal),
("MotivaSansLight.woff.ttf", "woff", 300, normal),
("MotivaSansRegular.woff.ttf", "woff", 400, normal),
("MotivaSansMedium.woff.ttf", "woff", 500, normal),
("MotivaSansBold.woff.ttf", "woff", 600, normal),
("MotivaSansExtraBold.ttf", "woff", 700, normal),
("MotivaSansBlack.woff.ttf", "woff", 900, normal)
);
$helvetica: (
("Helvetica.woff", "woff", 400, normal),
("Helvetica-Oblique.woff", "woff", 400, italic),
("Helvetica-Bold.woff", "woff", 600, normal),
("Helvetica-BoldOblique.woff", "woff", 600, italic),
("helvetica-light-587ebe5a59211.woff2", "woff2", 300, normal)
);
@each $file, $format, $weight, $style in $motiva {
@font-face {
font-family: "Motiva Sans";
src: url("/fonts/motiva/#{$file}") format($format);
font-weight: $weight;
font-style: $style;
}
}
@each $file, $format, $weight, $style in $helvetica {
@font-face {
font-family: "Helvetica";
src: url("/fonts/helvetica/#{$file}") format($format);
font-weight: $weight;
font-style: $style;
}
}
@font-face {
font-family: "Inter";
src: url("/fonts/inter/InterVariable.ttf");
font-style: normal;
}
@font-face {
font-family: "Inter";
src: url("/fonts/inter/InterVariable-Italic.ttf");
font-style: italic;
}
.carousel__icon {
color: #f4f4f5;
}
.carousel__pagination-button::after {
background-color: #3f3f46;
border-radius: 999999px;
}
.carousel__pagination-button:hover::after {
background-color: #27272a;
border-radius: 999999px;
}
.carousel__pagination-button--active::after {
background-color: #a1a1aa;
}
.carousel__pagination-button--active:hover::after {
background-color: #d4d4d8;
}
.store-carousel > .carousel__viewport {
overflow: visible !important;
}
button {
cursor: pointer !important;
}
html {
background-color: oklch(0.21 0.006 285.885);
}

View File

@@ -0,0 +1,13 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@plugin "@tailwindcss/forms";
@config "../../../tailwind.config.js";
@layer base {
input[type="number"]::-webkit-outer-spin-button,
input[type="number"]::-webkit-inner-spin-button,
input[type="number"] {
-webkit-appearance: none;
-moz-appearance: textfield !important;
}
}

8
app/components/Logo.vue Normal file
View File

@@ -0,0 +1,8 @@
<template>
<svg aria-label="Drop Logo" class="text-blue-400" viewBox="0 0 24 24" fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M4 13.5C4 11.0008 5.38798 8.76189 7.00766 7C8.43926 5.44272 10.0519 4.25811 11.0471 3.5959C11.6287 3.20893 12.3713 3.20893 12.9529 3.5959C13.9481 4.25811 15.5607 5.44272 16.9923 7C18.612 8.76189 20 11.0008 20 13.5C20 17.9183 16.4183 21.5 12 21.5C7.58172 21.5 4 17.9183 4 13.5Z"
stroke="currentColor" stroke-width="2" />
</svg>
</template>

View File

@@ -0,0 +1,69 @@
<template>
<ul class="space-y-3">
<li v-for="[path, methods] in collapsedAPINav.entries()">
<span class="font-display text-zinc-100 font-semibold">{{ path }}</span>
<ul class="mt-1 space-y-1">
<li v-for="method in methods">
<NuxtLink
class="transition hover:bg-zinc-900 px-1 py-0.5 rounded ml-1 text-zinc-400 grid grid-cols-4 gap-x-2"
:href="method.path">
<span
:class="[apiMethodColours[method.method], 'text-center text-xs font-bold ring-1 rounded-full px-1 py-0.5']">{{
method.method }}</span>
<div class="col-span-3 text-sm relative whitespace-nowrap overflow-hidden"><span
class="absolute right-0">{{ method.methodlessName }}</span></div>
</NuxtLink>
</li>
</ul>
</li>
</ul>
</template>
<script setup lang="ts">
import type { ContentNavigationItem } from '@nuxt/content';
const rawAPINav = await queryCollectionNavigation("docs");
const apiNavRoot = rawAPINav.at(0);
if (!apiNavRoot) throw createError({ statusCode: 500, statusMessage: "Failed to fetch API docs navigation", fatal: true });
const collapsedAPINav = ref<Map<string, Array<ContentNavigationItem & { method: string, methodlessName: string }>>>(new Map());
const apiMethods = ["GET", "POST", "DELETE", "PATCH"];
const apiMethodColours: { [key: string]: string } = {
"GET": "text-green-500 bg-green-500/10",
"POST": "text-orange-500 bg-orange-500/10",
"PATCH": "text-yellow-500 bg-yellow-500/10",
"DELETE": "text-red-500 bg-red-500/10",
"WS": "text-blue-500 bg-blue-500/10",
}
function recursivelyCollapseAPINav(nav: ContentNavigationItem, parent?: ContentNavigationItem) {
const potentialMethod = nav.title.split(" ").at(0);
if (!potentialMethod || !apiMethods.includes(potentialMethod.toUpperCase()) || !parent) {
if (nav.children) {
for (const child of nav.children)
recursivelyCollapseAPINav(child, nav);
}
return;
}
const methodlessName = nav.title.slice(potentialMethod.length).trim();
const newValue = collapsedAPINav.value.get(parent.path) ?? [];
newValue.push({ ...nav, method: potentialMethod, methodlessName })
collapsedAPINav.value.set(parent.path, newValue);
}
recursivelyCollapseAPINav(apiNavRoot)
collapsedAPINav.value = new Map([...collapsedAPINav.value].sort((a, b) => a[0].localeCompare(b[0])).map((v) => {
v[1].sort((a, b) => {
const methodSort = a.method.length - b.method.length;
if (methodSort == 0) return a.methodlessName.length - b.methodlessName.length;
return methodSort;
});
return v;
}))
</script>

View File

@@ -0,0 +1,29 @@
<template>
<div class="inline-flex justify-center items-center gap-x-1 -mb-1 relative">
<svg aria-hidden="true" viewBox="0 0 418 42" class="absolute inset-0 h-full w-full fill-blue-300/30 scale-75"
preserveAspectRatio="none">
<path
d="M203.371.916c-26.013-2.078-76.686 1.963-124.73 9.946L67.3 12.749C35.421 18.062 18.2 21.766 6.004 25.934 1.244 27.561.828 27.778.874 28.61c.07 1.214.828 1.121 9.595-1.176 9.072-2.377 17.15-3.92 39.246-7.496C123.565 7.986 157.869 4.492 195.942 5.046c7.461.108 19.25 1.696 19.17 2.582-.107 1.183-7.874 4.31-25.75 10.366-21.992 7.45-35.43 12.534-36.701 13.884-2.173 2.308-.202 4.407 4.442 4.734 2.654.187 3.263.157 15.593-.78 35.401-2.686 57.944-3.488 88.365-3.143 46.327.526 75.721 2.23 130.788 7.584 19.787 1.924 20.814 1.98 24.557 1.332l.066-.011c1.201-.203 1.53-1.825.399-2.335-2.911-1.31-4.893-1.604-22.048-3.261-57.509-5.556-87.871-7.36-132.059-7.842-23.239-.254-33.617-.116-50.627.674-11.629.54-42.371 2.494-46.696 2.967-2.359.259 8.133-3.625 26.504-9.81 23.239-7.825 27.934-10.149 28.304-14.005.417-4.348-3.529-6-16.878-7.066Z" />
</svg>
<Logo aria-hidden="true" class="h-6" />
<span class="text-blue-400 font-display font-bold text-xl uppercase">
Drop
</span>
<span class="-mb-0.5 ml-1 text-zinc-100 font-bold text-xl uppercase">
API
</span>
<span class="blink h-[3px] w-3 rounded-full bg-blue-400 absolute translate-x-full right-[-3px] bottom-[1px]" />
</div>
</template>
<style lang="css" scoped>
.blink {
animation: blink 1.5s step-start 0s infinite;
}
@keyframes blink {
50% {
opacity: 0.0;
}
}
</style>

24
app/layouts/default.vue Normal file
View File

@@ -0,0 +1,24 @@
<template>
<div class="bg-zinc-950 h-screen w-full">
<div class="h-full lg:ml-72 xl:ml-80">
<div class="contents lg:pointer-events-none lg:fixed lg:inset-0 lg:z-40 lg:flex">
<div
class="contents lg:pointer-events-auto lg:block lg:w-72 lg:overflow-y-auto lg:border-r lg:border-zinc-100/10 lg:px-6 lg:pt-4 lg:pb-8 xl:w-80">
<div class="hidden lg:flex">
<NuxtLink href="/" aria-label="Home">
<Wordmark class="h-6" />
</NuxtLink>
</div>
<Header />
<Navigation class="hidden lg:mt-10 lg:block" />
</div>
</div>
<div class="relative overflow-y-auto flex h-full flex-col px-4 pt-14 sm:px-6 lg:px-8">
<main class="flex-auto">
<slot />
</main>
<Footer />
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,30 @@
<script setup lang="ts">
const route = useRoute();
const { data: page } = await useAsyncData(route.path, () => queryCollection('docs').path(route.path).first())
page.value?.body.toc
useSeoMeta({
title: page.value?.title,
description: page.value?.description
})
</script>
<template>
<div v-if="page" class="flex flex-row gap-8">
<ContentRenderer class="grow prose prose-invert prose-blue max-w-none" :value="page" />
<div class="sticky top-0 w-96 h-min" v-if="page.body.toc">
<h1 class="text-zinc-400">On this page</h1>
<ul class="mt-1 ">
<li v-for="link in page.body.toc.links">
<a :href="`#${link.id}`" class="transition text-zinc-500 hover:text-zinc-200 text-sm">
{{ link.text }}
</a>
</li>
</ul>
</div>
</div>
<div v-else>Page not found</div>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
const route = useRoute();
const { data: page } = await useAsyncData(route.path, () => queryCollection('guides').path(route.path).first())
useSeoMeta({
title: page.value?.title,
description: page.value?.description
})
</script>
<template>
<ContentRenderer v-if="page" :value="page" />
<div v-else>Page not found</div>
</template>

3
app/pages/index.vue Normal file
View File

@@ -0,0 +1,3 @@
<template>
<h1>Hello world</h1>
</template>

22
content.config.ts Normal file
View File

@@ -0,0 +1,22 @@
import { defineContentConfig, defineCollection } from "@nuxt/content";
export default defineContentConfig({
collections: {
docs: defineCollection({
type: "page",
source: {
include: "**/*.md",
prefix: "api",
cwd: "./docs",
},
}),
guides: defineCollection({
type: "page",
source: {
include: "**/*.md",
prefix: "guides",
cwd: "./guides",
},
}),
},
});

2
drop

Submodule drop updated: 7af29ef0eb...29fdfcbdd4

1
guides/creating-token.md Normal file
View File

@@ -0,0 +1 @@
# How to create an API token

View File

@@ -1,12 +1,21 @@
import tailwindcss from "@tailwindcss/vite";
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
compatibilityDate: "2025-07-15",
devtools: { enabled: true },
modules: ["@nuxt/content"],
vite: { plugins: [tailwindcss()] },
css: ["~/assets/css/core.scss", "~/assets/css/tailwind.css"],
hooks: {
"build:before": async () => {
// Dynamic import so I can ignore errors. (I'm lazy)
const build = (await import("./tools/index")).default;
await build();
}
}
})
},
},
});

View File

@@ -10,10 +10,19 @@
"postinstall": "nuxt prepare"
},
"dependencies": {
"@nuxt/content": "^3.6.3",
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.16",
"@tailwindcss/vite": "^4.1.11",
"better-sqlite3": "^12.2.0",
"nuxt": "^4.0.1",
"prettier": "^3.6.2",
"tailwindcss": "^4.1.11",
"typescript": "^5.9.2",
"vue": "^3.5.18",
"vue-router": "^4.5.1"
},
"devDependencies": {
"sass-embedded": "^1.90.0"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,2 +0,0 @@
User-Agent: *
Disallow:

24
tailwind.config.js Normal file
View File

@@ -0,0 +1,24 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./app/components/**/*.{js,vue,ts}",
"./app/layouts/**/*.vue",
"./app/pages/**/*.vue",
"./app/plugins/**/*.{js,ts}",
"./app/app.vue",
"./app/error.vue",
],
theme: {
extend: {
fontFamily: {
sans: ["Inter"],
display: ["Motiva Sans"],
},
colors: {
zinc: {
925: "#111112",
},
},
},
},
};

View File

@@ -1,31 +1,51 @@
import { Route, RouteMetadata } from ".";
import * as ts from "typescript";
import type { Route, RouteMetadata } from ".";
import prettier from "prettier";
function encouragePrettierLinebreaks(rawType: string) {
return rawType.startsWith("{")
? rawType.slice(0, 1) + "\n" + rawType.slice(1)
: rawType;
}
export default async function generateDocsPage(
route: Route,
metadata: RouteMetadata
) {
// ===== Body =====
let bodyText = "";
if (metadata.body) {
bodyText = `
## Request Body
${metadata.bodyComment ?? ""}
Request type: \`application/json\`. Body type (TypeScript definition):
`.trimStart();
bodyText += "```typescript\n";
const rawType = metadata.checker.typeToString(metadata.body);
const newLinedType = rawType.slice(0, 1) + "\n" + rawType.slice(1);
const rawType = metadata.checker.typeToString(
metadata.body,
undefined,
ts.TypeFormatFlags.NoTruncation
);
const newLinedType = encouragePrettierLinebreaks(rawType);
const pretty = await prettier.format(`type Body = ${newLinedType}`, {
parser: "typescript",
});
bodyText += pretty;
bodyText += "\n```";
} else if (metadata.bodyComment) {
bodyText = `
## Request Body
${metadata.bodyComment ?? ""}`;
}
// ===== Route Params =====
let paramTable = "";
if (metadata.routeParams) {
paramTable =
@@ -35,6 +55,18 @@ Request type: \`application/json\`. Body type (TypeScript definition):
}
}
let queryTable = "";
if (metadata.query) {
const queryProperties = metadata.query.getProperties();
if (queryProperties.length > 0) {
queryTable = "## Query Parameters\n\n| Name | Type |\n| ---- | ---- |\n";
for (const property of queryProperties) {
queryTable += `| ${property.escapedName} | string |\n`;
}
}
}
// ===== ACLs =====
let routeAuthentication = "";
if (metadata.acls && metadata.acls.length > 0) {
routeAuthentication = `
@@ -50,12 +82,46 @@ ${metadata.acls.map((e) => ` - \`${e}\``).join("\n")}
routeAuthentication = `You can access this route with any \`${metadata.aclMode}\` token.`;
}
// ===== Client Warning =====
let clientWarning = "";
if (metadata.clientRoute) {
clientWarning =
"**This is a client route. It is not accessible through API tokens.**";
}
// ===== Response ======
let responseText = "";
if (metadata.response) {
responseText = `
## Response Body
${metadata.responseComment ?? ""}
${metadata.responseTag ? `This endpoint returns '${metadata.responseTag}'` : ""}
Response definition:
`.trimStart();
responseText += "```typescript\n";
const rawType = metadata.checker.typeToString(
metadata.response,
undefined,
ts.TypeFormatFlags.NoTruncation
);
const newLinedType = encouragePrettierLinebreaks(rawType);
const pretty = await prettier.format(`type Response = ${newLinedType}`, {
parser: "typescript",
});
responseText += pretty;
responseText += "\n```";
} else if (metadata.responseComment) {
responseText = `
## Response Body
${metadata.responseComment}`.trim();
}
const rawText = `
# ${route.method} \`${route.urlPath}\`
@@ -66,10 +132,14 @@ ${routeAuthentication}
${clientWarning}
${queryTable}
${paramTable}
${bodyText}
${responseText}
`;
// Just for my sanity

View File

@@ -67,7 +67,15 @@ async function transformRoutes(base: string, routes: string[]) {
export interface RouteMetadata {
checker: ts.TypeChecker;
body?: ts.Type;
bodyComment?: string;
query?: ts.Type;
response?: ts.Type;
responseTag?: string;
responseComment?: string;
routeDescription?: string;
routeParams?: { [key: string]: { type: string; comment: string } };
@@ -118,36 +126,49 @@ async function generateDocs(
const isClient = eventHandlerName.toLowerCase().includes("client");
const metadata: RouteMetadata = { checker, clientRoute: isClient };
// Extract the body type from the generic args
// Complicated and messy
if (eventHandler.typeArguments) {
// Only run on client/normal handlers
if (!eventHandlerName.toLowerCase().includes("websocket")) {
const resolvedSignature = checker.getResolvedSignature(eventHandler);
if (!resolvedSignature)
throw "Type arguments but no resolved signature? Somehow an invalid node?";
throw "No resolved signature? Somehow an invalid node?";
// Get handler (function) type
const handlerParam = resolvedSignature.getParameters().at(0)!;
const handlerType = checker.getTypeOfSymbolAtLocation(
let handlerType = checker.getTypeOfSymbolAtLocation(
handlerParam,
handlerParam.declarations!.at(0)!
);
if (!handlerType.isUnion())
throw "Weird defineEventHandler result - did Nitro change something interally?";
if (handlerType.isUnion()) handlerType = handlerType.types.at(0)!;
const eventHandlerRequestType = handlerType.types.at(0)!;
const typeArguments = eventHandlerRequestType[
const typeArguments = (handlerType as any)[
"resolvedTypeArguments"
] as ts.Type[];
const requestConfigurationType = typeArguments.at(0)!;
if (typeArguments) {
const requestConfigurationType = typeArguments.at(0)!;
const properties = requestConfigurationType.getProperties();
for (const property of properties) {
metadata[property.escapedName!] = checker.getTypeOfSymbolAtLocation(
property,
property.declarations!.at(0)!
);
const properties = requestConfigurationType.getProperties();
for (const property of properties) {
const type = checker.getTypeOfSymbolAtLocation(
property,
property.declarations!.at(0)!
);
if (type.flags & ts.TypeFlags.Any) continue;
(metadata as any)[property.escapedName!] = type;
}
const responseType = typeArguments.at(1)!;
if (!(responseType.flags & ts.TypeFlags.Any))
metadata.response = checker.getAwaitedType(responseType);
}
} else {
// If it's websocket, change the "method"
route.method = "WS";
}
// A kinda inefficient search through every node in the AST
@@ -191,19 +212,33 @@ async function generateDocs(
// Extract and parse the JSDoc for additional metadata
const jsDoc = ts.getJSDocCommentsAndTags(exportAssignment).at(0);
if (jsDoc) {
metadata.routeParams ??= {};
const description = jsDoc.comment;
if (description) {
metadata.routeDescription = description.toString();
}
for (const tag of jsDoc.getChildren()) {
if (!ts.isJSDocParameterTag(tag)) continue;
const type = tag.typeExpression?.getText().slice(1, -1) ?? "string";
const paramName = tag.name.getText();
const comment = tag.comment?.toString() ?? "";
metadata.routeParams[paramName] = { type, comment };
if (ts.isJSDocParameterTag(tag)) {
const type = tag.typeExpression?.getText().slice(1, -1) ?? "string";
const paramName = tag.name.getText();
const comment = tag.comment?.toString() ?? "";
metadata.routeParams ??= {};
metadata.routeParams[paramName] = { type, comment };
}
if (ts.isJSDocReturnTag(tag)) {
metadata.responseTag = tag.comment?.toString();
}
if (ts.isJSDocUnknownTag(tag)) {
if (tag.tagName.escapedText == "request") {
// Custom request text
metadata.bodyComment = tag.comment?.toString();
}
if (tag.tagName.escapedText == "response") {
metadata.responseComment = tag.comment?.toString();
}
}
}
}

2201
yarn.lock

File diff suppressed because it is too large Load Diff