tr]:last:border-b-0",
48 | className,
49 | )}
50 | {...props}
51 | />
52 | );
53 | }
54 |
55 | function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
56 | return (
57 |
65 | );
66 | }
67 |
68 | function TableHead({ className, ...props }: React.ComponentProps<"th">) {
69 | return (
70 | [role=checkbox]]:translate-y-[2px]",
74 | className,
75 | )}
76 | {...props}
77 | />
78 | );
79 | }
80 |
81 | function TableCell({ className, ...props }: React.ComponentProps<"td">) {
82 | return (
83 | [role=checkbox]]:translate-y-[2px]",
87 | className,
88 | )}
89 | {...props}
90 | />
91 | );
92 | }
93 |
94 | function TableCaption({
95 | className,
96 | ...props
97 | }: React.ComponentProps<"caption">) {
98 | return (
99 |
104 | );
105 | }
106 |
107 | export {
108 | Table,
109 | TableHeader,
110 | TableBody,
111 | TableFooter,
112 | TableHead,
113 | TableRow,
114 | TableCell,
115 | TableCaption,
116 | };
117 |
--------------------------------------------------------------------------------
/migrations/0000_initial_schema.sql:
--------------------------------------------------------------------------------
1 | -- Title: Initial Database Schema
2 | -- Description: Creates the complete initial database schema including UUID extension, devices, playlists, playlist_items, logs, and system_logs tables
3 | CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
4 |
5 | CREATE TABLE IF NOT EXISTS public.devices (
6 | id BIGSERIAL PRIMARY KEY,
7 | friendly_id VARCHAR NOT NULL UNIQUE,
8 | name VARCHAR NOT NULL,
9 | mac_address VARCHAR NOT NULL UNIQUE,
10 | api_key VARCHAR NOT NULL UNIQUE,
11 | screen VARCHAR NULL DEFAULT NULL,
12 | refresh_schedule JSONB NULL,
13 | timezone TEXT NOT NULL DEFAULT 'UTC',
14 | last_update_time TIMESTAMPTZ NULL,
15 | next_expected_update TIMESTAMPTZ NULL,
16 | last_refresh_duration INTEGER NULL,
17 | battery_voltage NUMERIC NULL,
18 | firmware_version TEXT NULL,
19 | rssi INTEGER NULL,
20 | playlist_id UUID,
21 | use_playlist BOOLEAN DEFAULT FALSE,
22 | current_playlist_index INT DEFAULT 0,
23 | created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
24 | updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
25 | );
26 |
27 | CREATE INDEX IF NOT EXISTS idx_devices_refresh_schedule ON public.devices USING GIN (refresh_schedule);
28 |
29 | CREATE TABLE IF NOT EXISTS public.playlists (
30 | id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
31 | name TEXT NOT NULL,
32 | created_at TIMESTAMPTZ DEFAULT NOW(),
33 | updated_at TIMESTAMPTZ DEFAULT NOW()
34 | );
35 |
36 | CREATE TABLE IF NOT EXISTS public.playlist_items (
37 | id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
38 | playlist_id UUID REFERENCES public.playlists(id) ON DELETE CASCADE,
39 | screen_id TEXT NOT NULL,
40 | duration INT NOT NULL DEFAULT 30,
41 | start_time TIME,
42 | end_time TIME,
43 | days_of_week JSONB,
44 | order_index INT NOT NULL,
45 | created_at TIMESTAMPTZ DEFAULT NOW()
46 | );
47 |
48 | -- Add foreign key constraint for devices.playlist_id if it doesn't exist
49 | DO $$
50 | BEGIN
51 | IF NOT EXISTS (
52 | SELECT 1 FROM pg_constraint WHERE conname = 'devices_playlist_id_fkey'
53 | ) THEN
54 | ALTER TABLE public.devices
55 | ADD CONSTRAINT devices_playlist_id_fkey FOREIGN KEY (playlist_id) REFERENCES public.playlists(id);
56 | END IF;
57 | END $$;
58 |
59 | CREATE TABLE IF NOT EXISTS public.logs (
60 | id BIGSERIAL PRIMARY KEY,
61 | friendly_id TEXT NULL,
62 | log_data TEXT NOT NULL,
63 | created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
64 | CONSTRAINT logs_friendly_id_fkey FOREIGN KEY (friendly_id) REFERENCES public.devices (friendly_id)
65 | );
66 |
67 | CREATE TABLE IF NOT EXISTS public.system_logs (
68 | id UUID DEFAULT uuid_generate_v4() PRIMARY KEY,
69 | created_at TIMESTAMPTZ DEFAULT now(),
70 | level VARCHAR NOT NULL,
71 | message TEXT NOT NULL,
72 | source VARCHAR NULL,
73 | metadata TEXT NULL,
74 | trace TEXT NULL
75 | );
76 |
77 | CREATE INDEX IF NOT EXISTS idx_system_logs_created_at ON public.system_logs (created_at);
78 | CREATE INDEX IF NOT EXISTS idx_system_logs_level ON public.system_logs (level);
79 |
80 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "byos-nextjs",
3 | "version": "0.1.2",
4 | "private": true,
5 | "scripts": {
6 | "generate:sql": "node scripts/generate-sql-statements.js && pnpm format",
7 | "generate:types": "kysely-codegen --dialect postgres --include-pattern='public.*' --out-file lib/database/db.d.ts",
8 | "prebuild": "pnpm generate:sql",
9 | "dev": "pnpm generate:sql && next dev --turbopack",
10 | "build": "next build",
11 | "start": "next start",
12 | "lint": "biome check ./app ./components ./lib ./utils ./hooks",
13 | "lint:fix": "biome check --write --unsafe ./app ./components ./lib ./utils ./hooks",
14 | "format": "biome format --write ./app ./components ./lib ./utils ./hooks",
15 | "release:major": "pnpm version major -m 'chore(release): %s' && git push --follow-tags",
16 | "release:minor": "pnpm version minor -m 'chore(release): %s' && git push --follow-tags",
17 | "release:patch": "pnpm version patch -m 'chore(release): %s' && git push --follow-tags"
18 | },
19 | "dependencies": {
20 | "@radix-ui/react-aspect-ratio": "^1.1.8",
21 | "@radix-ui/react-collapsible": "^1.1.12",
22 | "@radix-ui/react-dialog": "^1.1.15",
23 | "@radix-ui/react-dropdown-menu": "^2.1.16",
24 | "@radix-ui/react-label": "^2.1.8",
25 | "@radix-ui/react-popover": "^1.1.15",
26 | "@radix-ui/react-scroll-area": "^1.2.10",
27 | "@radix-ui/react-select": "^2.2.6",
28 | "@radix-ui/react-slider": "^1.3.6",
29 | "@radix-ui/react-slot": "^1.2.4",
30 | "@radix-ui/react-switch": "^1.2.6",
31 | "@radix-ui/react-tabs": "^1.1.13",
32 | "@radix-ui/react-toggle": "^1.1.10",
33 | "@radix-ui/react-toggle-group": "^1.1.11",
34 | "@radix-ui/react-tooltip": "^1.2.8",
35 | "@redux-devtools/extension": "^3.3.0",
36 | "@takumi-rs/core": "^0.57.3",
37 | "@takumi-rs/helpers": "^0.57.3",
38 | "@takumi-rs/wasm": "^0.57.3",
39 | "@tanstack/react-virtual": "^3.13.12",
40 | "@types/d3": "^7.4.3",
41 | "class-variance-authority": "^0.7.1",
42 | "clsx": "^2.1.1",
43 | "cmdk": "1.1.1",
44 | "d3": "^7.9.0",
45 | "jimp": "^1.6.0",
46 | "kysely": "^0.28.8",
47 | "lucide-react": "^0.554.0",
48 | "next": "16.0.7",
49 | "next-themes": "^0.4.6",
50 | "pg": "^8.16.3",
51 | "postgres": "^3.4.7",
52 | "react": "^19.2.1",
53 | "react-dom": "^19.2.1",
54 | "react-virtuoso": "^4.14.1",
55 | "satori": "^0.4.3",
56 | "sharp": "^0.34.5",
57 | "sonner": "^2.0.7",
58 | "tailwind-merge": "^3.4.0",
59 | "tailwindcss-animate": "^1.0.7",
60 | "zod": "^4.1.13",
61 | "zustand": "^5.0.8"
62 | },
63 | "devDependencies": {
64 | "@biomejs/biome": "2.3.7",
65 | "@eslint/eslintrc": "^3.3.1",
66 | "@tailwindcss/postcss": "^4.1.17",
67 | "@types/node": "^24.10.1",
68 | "@types/pg": "^8.15.6",
69 | "@types/react": "^19.2.7",
70 | "@types/react-dom": "^19.2.3",
71 | "eslint": "^9.39.1",
72 | "eslint-config-next": "16.0.7",
73 | "eslint-plugin-react-hooks": "^7.0.1",
74 | "kysely-codegen": "^0.19.0",
75 | "tailwindcss": "^4.1.17",
76 | "typescript": "^5.9.3"
77 | },
78 | "pnpm": {
79 | "onlyBuiltDependencies": [
80 | "@biomejs/biome",
81 | "@tailwindcss/oxide",
82 | "unrs-resolver"
83 | ]
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/docs/api.md:
--------------------------------------------------------------------------------
1 | # API Reference
2 |
3 | This document describes the HTTP interface exposed by BYOS for TRMNL devices. All endpoints return JSON and are designed to be called by the device firmware. Use HTTPS in production.
4 |
5 | ## Base Requirements
6 | - Include device MAC address in every request via the `ID` header.
7 | - When available, include the issued API key via the `Access-Token` header.
8 | - Responses include a `status` field; `0` or `200` indicates success.
9 |
10 | ## Endpoints
11 |
12 | ### `GET /api/setup`
13 | Registers a device and returns credentials when a device is new or has been reset.
14 |
15 | **Headers**
16 | - `ID` (required): Device MAC address.
17 |
18 | **Example**
19 | ```bash
20 | curl -X GET http:///api/setup \
21 | -H "ID: 12:34:56:78:9A:BC"
22 | ```
23 |
24 | **Success Response**
25 | ```json
26 | {
27 | "status": 200,
28 | "api_key": "uniqueApiKeyGenerated",
29 | "friendly_id": "DEVICE_ABC123",
30 | "message": "Device successfully registered"
31 | }
32 | ```
33 |
34 | ### `GET /api/display`
35 | Returns the next screen for a device along with refresh and optional firmware instructions.
36 |
37 | **Headers**
38 | - `ID` (required): Device MAC address.
39 | - `Access-Token` (recommended): Issued API key.
40 | - Optional telemetry: `Refresh-Rate`, `Battery-Voltage`, `FW-Version`, `RSSI`.
41 |
42 | **Example**
43 | ```bash
44 | curl -X GET http:///api/display \
45 | -H "ID: 12:34:56:78:9A:BC" \
46 | -H "Access-Token: uniqueApiKey" \
47 | -H "Battery-Voltage: 3.7" \
48 | -H "FW-Version: 1.0.0" \
49 | -H "RSSI: -45"
50 | ```
51 |
52 | **Success Response**
53 | ```json
54 | {
55 | "status": 0,
56 | "image_url": "https:///api/bitmap/DEVICE_ID_TIMESTAMP.bmp",
57 | "filename": "DEVICE_ID_TIMESTAMP.bmp",
58 | "refresh_rate": 180,
59 | "reset_firmware": false,
60 | "update_firmware": false,
61 | "firmware_url": null,
62 | "special_function": "restart_playlist"
63 | }
64 | ```
65 |
66 | ### `POST /api/log`
67 | Captures device-side errors for later diagnosis.
68 |
69 | **Headers**
70 | - `ID` (required): Device MAC address.
71 | - `Access-Token` (recommended): Issued API key.
72 |
73 | **Body**
74 | ```json
75 | {
76 | "message": "Human-readable description",
77 | "metadata": {
78 | "stack": "optional stack or telemetry"
79 | }
80 | }
81 | ```
82 |
83 | ## Device Status Tracking
84 | The server records the following fields on display requests to aid debugging and scheduling:
85 | - Battery voltage
86 | - Firmware version
87 | - RSSI
88 | - Last update time and next expected update
89 | - Last refresh duration
90 |
91 | ## Screen Generation Pipeline
92 | - Image format: 800x480 pixel 1-bit BMP.
93 | - Renderer: Takumi (default) or Satori (`REACT_RENDERER` env var).
94 | - Pipeline: JSX component → renderer (PNG) → Sharp (BMP) → TRMNL-specific header.
95 | - Caching: 60-second cache with background revalidation by Next.js (development uses in-memory cache).
96 |
97 | ## Authentication Notes
98 | - Devices can authenticate by MAC address only, API key only, or both.
99 | - Unknown devices with valid API keys auto-register.
100 | - Production deployments should add middleware, rate limiting, and user management if required.
101 |
--------------------------------------------------------------------------------
/utils/pre-satori.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { extractFontFamily } from "@/lib/fonts";
3 | import { cn } from "@/lib/utils";
4 | import {
5 | getResetStyles,
6 | processDither,
7 | processGap,
8 | processResponsive,
9 | } from "./pre-satori-tailwind";
10 |
11 | interface PreSatoriProps {
12 | useDoubling?: boolean;
13 | width?: number;
14 | height?: number;
15 | children: React.ReactNode;
16 | }
17 | export const getRendererType = (): "takumi" | "satori" => {
18 | const renderer = process.env.REACT_RENDERER?.toLowerCase();
19 | return renderer === "satori" ? "satori" : "takumi";
20 | };
21 |
22 | export const PreSatori: React.FC = ({
23 | useDoubling = false,
24 | width = 800,
25 | height = 480,
26 | children,
27 | }) => {
28 | // Define a helper to recursively transform children.
29 | const transform = (child: React.ReactNode): React.ReactNode => {
30 | if (React.isValidElement(child)) {
31 | const {
32 | className,
33 | style,
34 | children: childChildren,
35 | ...restProps
36 | } = child.props as {
37 | className?: string;
38 | style?: React.CSSProperties;
39 | children?: React.ReactNode;
40 | [key: string]: unknown;
41 | };
42 | const fontFamily = extractFontFamily(className);
43 | const newStyle: React.CSSProperties = {
44 | ...style,
45 | fontSmooth: "always",
46 | ...(fontFamily ? { fontFamily } : {}),
47 | };
48 |
49 | // Special handling for display properties
50 | if (getRendererType() === "satori") {
51 | if (
52 | style?.display !== "flex" &&
53 | style?.display !== "contents" &&
54 | style?.display !== "none"
55 | ) {
56 | newStyle.display = "flex";
57 | }
58 | }
59 |
60 | // Process className for dither patterns, gap classes, and responsive breakpoints
61 | const responsiveClass = processResponsive(className, width);
62 | // Check if element should be hidden - don't render it at all
63 | if (
64 | responsiveClass.includes("hidden") &&
65 | getRendererType() === "satori"
66 | ) {
67 | return null;
68 | }
69 | let afterGapClass = responsiveClass;
70 | let gapStyle = {};
71 | if (getRendererType() === "satori") {
72 | ({ style: gapStyle, className: afterGapClass } =
73 | processGap(responsiveClass));
74 | }
75 | const { style: ditherStyle, className: finalClass } =
76 | processDither(afterGapClass);
77 |
78 | Object.assign(newStyle, gapStyle, ditherStyle);
79 |
80 | // Determine reset styles
81 | const resetStyles = getResetStyles(child);
82 |
83 | // Construct new props
84 | const newProps: Record = {
85 | ...restProps,
86 | style: newStyle,
87 | className: cn(resetStyles, finalClass), // Keep for browser/React
88 | // Pass Tailwind classes to 'tw' prop for Takumi/Satori rendering
89 | // We combine reset styles with user classes
90 | tw: cn(resetStyles, finalClass),
91 | };
92 |
93 | // Recursively transform children
94 | if (childChildren) {
95 | newProps.children = React.Children.map(childChildren, (c) =>
96 | transform(c),
97 | );
98 | }
99 |
100 | return React.cloneElement(child, newProps);
101 | }
102 | return child;
103 | };
104 |
105 | return (
106 |
115 | {React.Children.map(children, (child) => transform(child))}
116 | {/* {children} */}
117 |
118 | );
119 | };
120 |
--------------------------------------------------------------------------------
/lib/fonts.ts:
--------------------------------------------------------------------------------
1 | import fs from "fs";
2 | import { Geist_Mono as FontMono, Geist as FontSans } from "next/font/google";
3 | import localFont from "next/font/local";
4 | import path from "path";
5 | import { cache } from "react";
6 |
7 | const fontPaths = {
8 | blockKie: path.join(process.cwd(), "public", "fonts", "BlockKie.ttf"),
9 | geneva9: path.join(process.cwd(), "public", "fonts", "geneva-9.ttf"),
10 | inter: path.join(process.cwd(), "public", "fonts", "Inter_18pt-Regular.ttf"),
11 | };
12 |
13 | // System fonts configuration
14 | export const fontSans = FontSans({
15 | subsets: ["latin"],
16 | variable: "--font-sans",
17 | display: "swap",
18 | preload: true,
19 | adjustFontFallback: true, // Automatically handles fallback fonts
20 | });
21 |
22 | export const fontMono = FontMono({
23 | subsets: ["latin"],
24 | variable: "--font-mono",
25 | display: "swap",
26 | preload: true,
27 | adjustFontFallback: true,
28 | });
29 |
30 | // Display fonts configuration
31 | export const blockKie = localFont({
32 | src: "../public/fonts/BlockKie.ttf",
33 | variable: "--font-blockkie",
34 | preload: true,
35 | display: "block", // Block rendering until font is loaded for consistent display
36 | weight: "400",
37 | style: "normal",
38 | });
39 |
40 | // UI fonts configuration
41 | export const geneva9 = localFont({
42 | src: "../public/fonts/geneva-9.ttf",
43 | variable: "--font-geneva9",
44 | preload: true,
45 | display: "swap", // Use fallback while loading
46 | weight: "400",
47 | style: "normal",
48 | });
49 |
50 | export const inter = localFont({
51 | src: "../public/fonts/Inter_18pt-Regular.ttf",
52 | variable: "--font-inter",
53 | preload: true,
54 | display: "swap",
55 | weight: "400",
56 | style: "normal",
57 | });
58 |
59 | // Font variables organized by purpose
60 | export const fonts = {
61 | sans: fontSans,
62 | mono: fontMono,
63 | blockKie: blockKie,
64 | geneva9: geneva9,
65 | inter: inter,
66 | } as const;
67 |
68 | // Helper to get all font variables
69 | export const getAllFontVariables = () =>
70 | Object.values(fonts)
71 | .map((font) => font.variable)
72 | .join(" ");
73 |
74 | export const loadFont = cache(() => {
75 | try {
76 | return Object.entries(fontPaths).reduce(
77 | (acc, [fontName, fontPath]) => {
78 | acc[fontName] = Buffer.from(fs.readFileSync(fontPath));
79 | return acc;
80 | },
81 | {} as Record,
82 | );
83 | } catch (error) {
84 | console.error("Error loading fonts:", error);
85 | return null;
86 | }
87 | });
88 |
89 | /**
90 | * Returns an array of Takumi-compatible font objects
91 | * @param fonts Object containing font buffers from loadFont()
92 | * @returns Array of font configurations for Takumi
93 | */
94 | export const getTakumiFonts = () => {
95 | const fonts = loadFont();
96 | if (!fonts) return [];
97 | const weight = 400 as const;
98 | const style = "normal" as const;
99 |
100 | const takumiFonts = Object.entries(fonts).map(([fontName, fontBuffer]) => {
101 | let data: ArrayBuffer;
102 | if (fontBuffer instanceof ArrayBuffer) {
103 | data = fontBuffer;
104 | }
105 | data = Uint8Array.from(fontBuffer).buffer;
106 |
107 | return {
108 | name: fontName,
109 | data: data,
110 | weight: weight,
111 | style: style,
112 | };
113 | });
114 |
115 | return takumiFonts;
116 | };
117 |
118 | export const extractFontFamily = (className?: string): string | undefined => {
119 | const defaultFont = "blockkie";
120 | if (!className) return defaultFont;
121 |
122 | const fontClass = className.split(" ").find((cls) => cls.startsWith("font-"));
123 | return fontClass?.replace("font-", "") || defaultFont;
124 | };
125 |
--------------------------------------------------------------------------------
/components/device-logs/device-logs-container.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { usePathname, useRouter, useSearchParams } from "next/navigation";
4 | import { fetchDeviceSystemLogs } from "@/app/actions/system";
5 | import SystemLogsViewer from "@/components/system-logs/system-logs-viewer";
6 | import {
7 | Card,
8 | CardContent,
9 | CardDescription,
10 | CardHeader,
11 | CardTitle,
12 | } from "@/components/ui/card";
13 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
14 | import type { Device } from "@/lib/types";
15 | import DeviceLogsViewer from "./device-logs-viewer";
16 |
17 | interface DeviceLogsContainerProps {
18 | device: Device;
19 | }
20 |
21 | export default function DeviceLogsContainer({
22 | device,
23 | }: DeviceLogsContainerProps) {
24 | const router = useRouter();
25 | const pathname = usePathname();
26 | const searchParams = useSearchParams();
27 |
28 | // Get the active tab from URL or default to device-logs
29 | const activeTab = searchParams.get("activeTab") || "device-logs";
30 |
31 | // Handle tab change
32 | const handleTabChange = (value: string) => {
33 | const newSearchParams = new URLSearchParams(searchParams.toString());
34 | newSearchParams.set("activeTab", value);
35 | router.push(`${pathname}?${newSearchParams.toString()}`, { scroll: false });
36 | };
37 |
38 | return (
39 |
40 |
41 | Device Logs
42 |
43 | View logs and events for this device
44 |
45 |
46 |
47 |
48 |
49 | Device Logs
50 | System Logs
51 |
52 |
53 |
54 |
58 |
59 |
60 | {/* Custom SystemLogsViewer that filters for this device */}
61 |
67 |
68 |
69 |
70 |
71 |
72 | );
73 | }
74 |
75 | // Custom SystemLogsViewer that filters for a specific device
76 | interface SystemLogsViewerWithDeviceFilterProps {
77 | friendlyId: string;
78 | macAddress: string;
79 | apiKey: string;
80 | paramPrefix: string;
81 | }
82 |
83 | function SystemLogsViewerWithDeviceFilter({
84 | friendlyId,
85 | macAddress,
86 | apiKey,
87 | paramPrefix,
88 | }: SystemLogsViewerWithDeviceFilterProps) {
89 | // This is a wrapper around the SystemLogsViewer that pre-filters for this device
90 | // We're using the existing SystemLogsViewer component but with custom fetch logic
91 |
92 | return (
93 |
94 |
95 | Showing system logs related to device {friendlyId}
96 |
97 |
{
99 | return fetchDeviceSystemLogs({
100 | ...params,
101 | friendlyId,
102 | macAddress,
103 | apiKey,
104 | });
105 | }}
106 | paramPrefix={paramPrefix}
107 | />
108 |
109 | );
110 | }
111 |
--------------------------------------------------------------------------------
/lib/database/db.d.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * This file was generated by kysely-codegen.
3 | * Please do not edit it manually.
4 | */
5 |
6 | import type { ColumnType } from "kysely";
7 |
8 | export type DeviceDisplayMode = "mixup" | "playlist" | "screen";
9 |
10 | export type Generated = T extends ColumnType
11 | ? ColumnType
12 | : ColumnType;
13 |
14 | export type Int8 = ColumnType<
15 | string,
16 | bigint | number | string,
17 | bigint | number | string
18 | >;
19 |
20 | export type Json = JsonValue;
21 |
22 | export type JsonArray = JsonValue[];
23 |
24 | export type JsonObject = {
25 | [x: string]: JsonValue | undefined;
26 | };
27 |
28 | export type JsonPrimitive = boolean | number | string | null;
29 |
30 | export type JsonValue = JsonArray | JsonObject | JsonPrimitive;
31 |
32 | export type MixupLayoutId =
33 | | "horizontal-halves"
34 | | "left-rail"
35 | | "quarters"
36 | | "top-banner"
37 | | "vertical-halves";
38 |
39 | export type Numeric = ColumnType;
40 |
41 | export type Timestamp = ColumnType;
42 |
43 | export interface Devices {
44 | api_key: string;
45 | battery_voltage: Numeric | null;
46 | created_at: Generated;
47 | current_playlist_index: Generated;
48 | display_mode: Generated;
49 | firmware_version: string | null;
50 | friendly_id: string;
51 | id: Generated;
52 | last_refresh_duration: number | null;
53 | last_update_time: Timestamp | null;
54 | mac_address: string;
55 | mixup_id: string | null;
56 | name: string;
57 | next_expected_update: Timestamp | null;
58 | playlist_id: string | null;
59 | refresh_schedule: Json | null;
60 | rssi: number | null;
61 | screen: string | null;
62 | timezone: Generated;
63 | updated_at: Generated;
64 | }
65 |
66 | export interface Logs {
67 | created_at: Generated;
68 | friendly_id: string | null;
69 | id: Generated;
70 | log_data: string;
71 | }
72 |
73 | export interface Mixups {
74 | created_at: Generated;
75 | id: Generated;
76 | layout_id: MixupLayoutId;
77 | name: string;
78 | updated_at: Generated;
79 | }
80 |
81 | export interface MixupSlots {
82 | created_at: Generated;
83 | id: Generated;
84 | mixup_id: string | null;
85 | order_index: number;
86 | recipe_slug: string | null;
87 | slot_id: string;
88 | }
89 |
90 | export interface PlaylistItems {
91 | created_at: Generated;
92 | days_of_week: Json | null;
93 | duration: Generated;
94 | end_time: string | null;
95 | id: Generated;
96 | order_index: number;
97 | playlist_id: string | null;
98 | screen_id: string;
99 | start_time: string | null;
100 | }
101 |
102 | export interface Playlists {
103 | created_at: Generated;
104 | id: Generated;
105 | name: string;
106 | updated_at: Generated;
107 | }
108 |
109 | export interface ScreenConfigs {
110 | created_at: Generated;
111 | id: Generated;
112 | params: Json;
113 | screen_id: string;
114 | updated_at: Generated;
115 | }
116 |
117 | export interface SystemLogs {
118 | created_at: Generated;
119 | id: Generated;
120 | level: string;
121 | message: string;
122 | metadata: string | null;
123 | source: string | null;
124 | trace: string | null;
125 | }
126 |
127 | export interface DB {
128 | devices: Devices;
129 | logs: Logs;
130 | mixup_slots: MixupSlots;
131 | mixups: Mixups;
132 | playlist_items: PlaylistItems;
133 | playlists: Playlists;
134 | screen_configs: ScreenConfigs;
135 | system_logs: SystemLogs;
136 | }
137 |
--------------------------------------------------------------------------------
/app/actions/screens-params.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { revalidatePath } from "next/cache";
4 | import { db } from "@/lib/database/db";
5 | import type { JsonObject } from "@/lib/database/db.d";
6 | import { checkDbConnection } from "@/lib/database/utils";
7 | import type { RecipeParamDefinitions } from "@/lib/recipes/recipe-renderer";
8 |
9 | /**
10 | *
11 | * @param slug - The slug of the screen to update
12 | * @param params - The parameters to update
13 | * @param definitions - The definitions of the parameters
14 | * @returns A promise that resolves to an object with a success property and an error property if the update failed
15 | */
16 | export async function updateScreenParams(
17 | slug: string,
18 | params: Record,
19 | definitions?: RecipeParamDefinitions,
20 | ) {
21 | "use server";
22 | const { ready } = await checkDbConnection();
23 | if (!ready) {
24 | return { success: false, error: "Database client not initialized" };
25 | }
26 |
27 | // Filter params to only include those in definitions
28 | const sanitizedParams: Record = {};
29 | if (definitions) {
30 | for (const key of Object.keys(definitions)) {
31 | if (params[key] !== undefined) {
32 | sanitizedParams[key] = params[key];
33 | }
34 | }
35 | } else {
36 | Object.assign(sanitizedParams, params);
37 | }
38 |
39 | const now = new Date().toISOString();
40 |
41 | try {
42 | await db
43 | .insertInto("screen_configs")
44 | .values({
45 | screen_id: slug,
46 | params: sanitizedParams as JsonObject,
47 | created_at: now,
48 | updated_at: now,
49 | })
50 | .onConflict((oc) =>
51 | oc.column("screen_id").doUpdateSet({
52 | params: sanitizedParams as JsonObject,
53 | updated_at: now,
54 | }),
55 | )
56 | .execute();
57 | revalidatePath(`/recipes/${slug}`);
58 | revalidatePath(`/api/bitmap/${slug}.bmp`);
59 | return { success: true };
60 | } catch (error) {
61 | console.error("Failed to save screen params", error);
62 | return {
63 | success: false,
64 | error: error instanceof Error ? error.message : "Unknown error",
65 | };
66 | }
67 | }
68 |
69 | /**
70 | * Get the screen params from the database
71 | * @param slug - The slug of the screen to get the params for
72 | * @param definitions - The definitions of the parameters
73 | * @returns A promise that resolves to an object with the parameters
74 | */
75 | export async function getScreenParams(
76 | slug: string,
77 | definitions?: RecipeParamDefinitions,
78 | ): Promise> {
79 | const { ready } = await checkDbConnection();
80 | if (!ready) {
81 | const params: Record = {};
82 | if (definitions) {
83 | for (const [key, definition] of Object.entries(definitions)) {
84 | if (definition.default !== undefined) {
85 | params[key] = definition.default;
86 | }
87 | }
88 | }
89 | return params;
90 | }
91 |
92 | const row = await db
93 | .selectFrom("screen_configs")
94 | .select(["params"])
95 | .where("screen_id", "=", slug)
96 | .executeTakeFirst();
97 |
98 | const rawParams = row?.params ?? {};
99 | const parsedParams =
100 | typeof rawParams === "string"
101 | ? (JSON.parse(rawParams) as JsonObject)
102 | : (rawParams as JsonObject);
103 |
104 | const merged: Record = {};
105 | if (definitions) {
106 | for (const [key, definition] of Object.entries(definitions)) {
107 | const incoming = parsedParams?.[key];
108 | if (
109 | incoming !== undefined &&
110 | incoming !== null &&
111 | !(typeof incoming === "string" && incoming.trim() === "")
112 | ) {
113 | merged[key] = incoming;
114 | } else if (definition.default !== undefined) {
115 | merged[key] = definition.default;
116 | }
117 | }
118 | } else {
119 | return parsedParams ?? {};
120 | }
121 |
122 | return merged;
123 | }
124 |
--------------------------------------------------------------------------------
/components/dashboard/dashboard-skeleton.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Card,
3 | CardContent,
4 | CardDescription,
5 | CardHeader,
6 | CardTitle,
7 | } from "@/components/ui/card";
8 | import { Skeleton } from "@/components/ui/skeleton";
9 | import {
10 | Table,
11 | TableBody,
12 | TableCell,
13 | TableHead,
14 | TableHeader,
15 | TableRow,
16 | } from "@/components/ui/table";
17 | import { cn } from "@/lib/utils";
18 |
19 | export const DashboardSkeleton = ({ className }: { className?: string }) => {
20 | return (
21 |
22 |
23 |
24 |
25 | System Information
26 |
27 |
28 |
29 | {[...Array(4)].map((_, i) => (
30 |
31 |
32 |
33 |
34 | ))}
35 |
36 |
37 |
38 |
39 |
40 |
41 | Latest Screen
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 | System Status
61 | Overview of all connected devices
62 |
63 |
64 |
65 |
66 |
Online Devices
67 |
68 | {[...Array(3)].map((_, i) => (
69 |
70 | ))}
71 |
72 |
73 |
74 |
Offline Devices
75 |
76 | {[...Array(2)].map((_, i) => (
77 |
78 | ))}
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 | Recent System Logs
88 | Latest system events and alerts
89 |
90 |
91 |
92 |
93 |
94 | Time
95 | Level
96 | Message
97 | Source
98 | Metadata
99 |
100 |
101 |
102 | {[...Array(5)].map((_, i) => (
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 |
119 |
120 | ))}
121 |
122 |
123 |
124 |
125 |
126 | );
127 | };
128 |
--------------------------------------------------------------------------------
/app/recipes/screens/bitcoin-price/bitcoin-price.tsx:
--------------------------------------------------------------------------------
1 | import { Graph } from "@/components/ui/graph";
2 | import { PreSatori } from "@/utils/pre-satori";
3 |
4 | interface CryptoPriceProps {
5 | price?: string;
6 | change24h?: string;
7 | marketCap?: string;
8 | volume24h?: string;
9 | lastUpdated?: string;
10 | high24h?: string;
11 | low24h?: string;
12 | historicalPrices?: Array<{ timestamp: number; price: number }>;
13 | cryptoName?: string;
14 | cryptoImage?: string;
15 | width?: number;
16 | height?: number;
17 | }
18 |
19 | export default function CryptoPrice({
20 | price = "Loading...",
21 | change24h = "0",
22 | marketCap = "Loading...",
23 | volume24h = "Loading...",
24 | lastUpdated = "Loading...",
25 | high24h = "Loading...",
26 | low24h = "Loading...",
27 | historicalPrices = [],
28 | cryptoName = "Bitcoin",
29 | cryptoImage,
30 | width = 800,
31 | height = 480,
32 | }: CryptoPriceProps) {
33 | // Calculate if price change is positive or negative
34 | const isPositive = !change24h.startsWith("-");
35 | const changeValue = isPositive ? change24h : change24h.substring(1);
36 |
37 | // Pre-generated array for Bitcoin price statistics
38 | const priceStats = [
39 | { label: "Market Cap", value: marketCap },
40 | { label: "24h Volume", value: volume24h },
41 | { label: "24h High", value: high24h },
42 | { label: "24h Low", value: low24h },
43 | ];
44 |
45 | const graphData = historicalPrices.map((d) => ({
46 | x: new Date(d.timestamp),
47 | y: d.price,
48 | }));
49 |
50 | const isHalfScreen = width === 400 && height === 480;
51 |
52 | return (
53 |
54 |
55 |
56 |
57 |
58 |
59 | ${price}
60 |
61 | {cryptoImage && (
62 |
63 | {/* YOU CANNOT USE NEXTJS IMAGE COMPONENT HERE, BECAUSE SATORI/TAKUMI DOES NOT SUPPORT IT */}
64 |
71 |
72 | )}
73 |
74 |
75 | {isPositive ? "↑" : "↓"} {changeValue}%
76 |
77 |
78 |
79 |
80 | {!isHalfScreen && (
81 |
82 |
83 |
84 |
85 |
86 |
92 |
93 |
94 | )}
95 |
96 | {priceStats.map((stat, index) => (
97 |
101 |
102 | {stat.label}
103 |
104 |
105 | ${stat.value}
106 |
107 |
108 | ))}
109 |
110 |
111 |
112 |
{cryptoName} Price Tracker
113 |
{lastUpdated && Last updated: {lastUpdated} }
114 |
115 |
116 |
117 | );
118 | }
119 |
--------------------------------------------------------------------------------
/app/tools/page.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 | import { Suspense } from "react";
3 | import tools from "@/app/tools/tools.json";
4 | import { Badge } from "@/components/ui/badge";
5 |
6 | // Tool configuration type
7 | type ToolConfig = {
8 | title: string;
9 | published: boolean;
10 | description: string;
11 | tags: string[];
12 | author: {
13 | name: string;
14 | github: string;
15 | };
16 | version: string;
17 | category: string;
18 | createdAt: string;
19 | updatedAt: string;
20 | };
21 |
22 | // Get published tools
23 | const getPublishedTools = () => {
24 | const toolEntries = Object.entries(tools as Record);
25 |
26 | // Filter out unpublished tools in production
27 | return process.env.NODE_ENV === "production"
28 | ? toolEntries.filter(([, config]) => config.published)
29 | : toolEntries;
30 | };
31 |
32 | // Component for a single card
33 | const ToolCard = ({ slug, config }: { slug: string; config: ToolConfig }) => {
34 | return (
35 |
40 |
41 |
42 | {config.title}
43 |
44 |
45 | {config.description}
46 |
47 |
48 |
49 | {config.tags.slice(0, 3).map((tag: string) => (
50 |
51 | {tag}
52 |
53 | ))}
54 | {config.tags.length > 3 && (
55 | +{config.tags.length - 3} more
56 | )}
57 |
58 |
59 | v{config.version}
60 | {new Date(config.updatedAt).toLocaleDateString()}
61 |
62 |
63 |
64 | );
65 | };
66 |
67 | // Component for a category section
68 | const CategorySection = ({
69 | category,
70 | tools,
71 | }: {
72 | category: string;
73 | tools: Array<[string, ToolConfig]>;
74 | }) => {
75 | return (
76 |
77 |
78 | {category.replace(/-/g, " ")}
79 |
80 |
81 | {tools.map(([slug, config]) => (
82 |
83 | ))}
84 |
85 |
86 | );
87 | };
88 |
89 | // Main component that organizes tools by category
90 | const ToolsGrid = () => {
91 | const publishedTools = getPublishedTools();
92 |
93 | // Group tools by category
94 | const toolsByCategory = publishedTools.reduce(
95 | (acc, [slug, config]) => {
96 | const category = config.category || "uncategorized";
97 | if (!acc[category]) {
98 | acc[category] = [];
99 | }
100 | acc[category].push([slug, config]);
101 | return acc;
102 | },
103 | {} as Record>,
104 | );
105 |
106 | // Sort categories alphabetically
107 | const sortedCategories = Object.keys(toolsByCategory).sort();
108 |
109 | return (
110 |
111 | {sortedCategories.map((category) => (
112 |
117 | ))}
118 |
119 | );
120 | };
121 |
122 | export default function ToolsIndex() {
123 | return (
124 |
125 |
126 |
Tools
127 |
128 | Explore and use helpful tools for your workflow and creative projects.
129 |
130 |
131 |
Loading tools... }>
132 |
133 |
134 |
135 | );
136 | }
137 |
--------------------------------------------------------------------------------
/app/recipes/screens/bitmap-patterns/bitmap-patterns.tsx:
--------------------------------------------------------------------------------
1 | import { PreSatori } from "@/utils/pre-satori";
2 |
3 | export default function BitmapPatterns({
4 | width = 800,
5 | height = 480,
6 | }: {
7 | width?: number;
8 | height?: number;
9 | }) {
10 | // Define an array of dither values and their corresponding percentages
11 | const ditherValues = [
12 | { value: 0, percentage: "0%" },
13 | { value: 15, percentage: "1.5%" },
14 | { value: 25, percentage: "2.5%" },
15 | { value: 50, percentage: "5%" },
16 | { value: 100, percentage: "10%" },
17 | { value: 150, percentage: "15%" },
18 | { value: 250, percentage: "25%" },
19 | { value: 300, percentage: "30%" },
20 | { value: 400, percentage: "40%" },
21 | { value: 450, percentage: "45%" },
22 | { value: 500, percentage: "50%" },
23 | { value: 550, percentage: "55%" },
24 | { value: 600, percentage: "60%" },
25 | { value: 700, percentage: "70%" },
26 | { value: 750, percentage: "75%" },
27 | { value: 850, percentage: "85%" },
28 | { value: 900, percentage: "90%" },
29 | { value: 950, percentage: "95%" },
30 | { value: 975, percentage: "97.5%" },
31 | { value: 985, percentage: "98.5%" },
32 | { value: 1000, percentage: "100%" },
33 | ];
34 |
35 | // Calculate row height to evenly distribute across the container
36 | const rowHeight = height / Math.ceil(ditherValues.length / 2);
37 | return (
38 |
39 |
40 |
52 | {ditherValues.map(({ value }, index) => {
53 | const realIndex = ditherValues.length - index;
54 | // because the smallest get rather last, we need to reverse the index
55 | // note it starts from 1 not 0, as total 6 - last index 5 is 1
56 |
57 | let size = { w: 0, h: 0 };
58 | // use height for the first 6
59 | const deltaRadiusForFirst6 = height / 6;
60 | size = {
61 | w: deltaRadiusForFirst6 * realIndex,
62 | h: deltaRadiusForFirst6 * realIndex,
63 | };
64 | const location = {
65 | x: -1 * Math.round(size.w / 2) + width / 2,
66 | y: (6 - realIndex) * deltaRadiusForFirst6,
67 | };
68 | return (
69 |
81 | );
82 | })}
83 |
84 |
95 | {ditherValues
96 | .reverse()
97 | .slice(0, 11)
98 | .map(({ value }) => (
99 |
850
103 | ? "text-white sm:text-white"
104 | : "text-white sm:text-black"
105 | }
106 | style={{
107 | height: `${rowHeight}px`,
108 | }}
109 | >
110 |
118 | {value} | {1000 - value}
119 |
120 |
121 | ))}
122 |
123 |
124 |
22 shades of gray
125 |
0: white, 1000: black
126 |
127 |
128 |
129 | );
130 | }
131 |
--------------------------------------------------------------------------------
/components/mixup/mixup-list.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Edit, LayoutGrid, Trash2 } from "lucide-react";
4 | import { AspectRatio } from "@/components/ui/aspect-ratio";
5 | import { Button } from "@/components/ui/button";
6 | import { FormattedDate } from "@/components/ui/formatted-date";
7 | import { getLayoutById } from "@/lib/mixup/constants";
8 | import {
9 | DEFAULT_IMAGE_HEIGHT,
10 | DEFAULT_IMAGE_WIDTH,
11 | } from "@/lib/recipes/constants";
12 | import type { Mixup } from "@/lib/types";
13 |
14 | interface MixupListProps {
15 | mixups: Mixup[];
16 | onEditMixup?: (mixup: Mixup) => void;
17 | onDeleteMixup?: (mixupId: string) => void;
18 | isLoading?: boolean;
19 | }
20 |
21 | const LayoutBadge = ({ layoutId }: { layoutId: string }) => {
22 | const layout = getLayoutById(layoutId);
23 | if (!layout) return null;
24 |
25 | return (
26 |
27 |
28 | {layoutId.replace(/-/g, " ")}
29 |
30 | ({layout.slots.length} slots)
31 |
32 |
33 | );
34 | };
35 |
36 | export function MixupList({
37 | mixups,
38 | onEditMixup,
39 | onDeleteMixup,
40 | isLoading = false,
41 | }: MixupListProps) {
42 | if (mixups.length === 0) {
43 | return (
44 |
45 |
46 | No mixups found. Create your first mixup to get started.
47 |
48 |
49 | );
50 | }
51 |
52 | return (
53 |
54 | {mixups.map((mixup) => {
55 | const layout = getLayoutById(mixup.layout_id);
56 | const slotCount = layout?.slots.length ?? 0;
57 |
58 | return (
59 |
63 |
67 |
68 |
72 |
80 |
81 |
82 |
83 |
84 |
85 | {mixup.name}
86 |
87 |
88 | {slotCount
89 | ? `Uses a ${mixup.layout_id.replace(/-/g, " ")} layout with ${slotCount} slots.`
90 | : "Mixup layout details unavailable."}
91 |
92 |
93 |
94 |
95 | {mixup.updated_at ? (
96 |
97 | ) : (
98 | No date
99 | )}
100 |
101 |
102 |
103 | onEditMixup?.(mixup)}
107 | disabled={isLoading}
108 | >
109 |
110 | Edit
111 |
112 | onDeleteMixup?.(mixup.id)}
117 | disabled={isLoading}
118 | >
119 |
120 | Delete
121 |
122 |
123 |
124 |
125 | );
126 | })}
127 |
128 | );
129 | }
130 |
--------------------------------------------------------------------------------
/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as DialogPrimitive from "@radix-ui/react-dialog";
4 | import { XIcon } from "lucide-react";
5 | import * as React from "react";
6 |
7 | import { cn } from "@/lib/utils";
8 |
9 | function Dialog({
10 | ...props
11 | }: React.ComponentProps) {
12 | return ;
13 | }
14 |
15 | function DialogTrigger({
16 | ...props
17 | }: React.ComponentProps) {
18 | return ;
19 | }
20 |
21 | function DialogPortal({
22 | ...props
23 | }: React.ComponentProps) {
24 | return ;
25 | }
26 |
27 | function DialogClose({
28 | ...props
29 | }: React.ComponentProps) {
30 | return ;
31 | }
32 |
33 | function DialogOverlay({
34 | className,
35 | ...props
36 | }: React.ComponentProps) {
37 | return (
38 |
46 | );
47 | }
48 |
49 | function DialogContent({
50 | className,
51 | children,
52 | showCloseButton = true,
53 | ...props
54 | }: React.ComponentProps & {
55 | showCloseButton?: boolean;
56 | }) {
57 | return (
58 |
59 |
60 |
68 | {children}
69 | {showCloseButton && (
70 |
74 |
75 | Close
76 |
77 | )}
78 |
79 |
80 | );
81 | }
82 |
83 | function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
84 | return (
85 |
90 | );
91 | }
92 |
93 | function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
94 | return (
95 |
103 | );
104 | }
105 |
106 | function DialogTitle({
107 | className,
108 | ...props
109 | }: React.ComponentProps) {
110 | return (
111 |
116 | );
117 | }
118 |
119 | function DialogDescription({
120 | className,
121 | ...props
122 | }: React.ComponentProps) {
123 | return (
124 |
129 | );
130 | }
131 |
132 | export {
133 | Dialog,
134 | DialogClose,
135 | DialogContent,
136 | DialogDescription,
137 | DialogFooter,
138 | DialogHeader,
139 | DialogOverlay,
140 | DialogPortal,
141 | DialogTitle,
142 | DialogTrigger,
143 | };
144 |
--------------------------------------------------------------------------------
/app/recipes/screens/weather/weather.tsx:
--------------------------------------------------------------------------------
1 | import { PreSatori } from "@/utils/pre-satori";
2 | import {
3 | CloudIcon,
4 | FogIcon,
5 | humidityIcon,
6 | pressureIcon,
7 | RainIcon,
8 | SnowIcon,
9 | SunIcon,
10 | sunriseIcon,
11 | sunsetIcon,
12 | ThunderIcon,
13 | tempDown,
14 | tempIcon,
15 | tempUp,
16 | windIcon,
17 | } from "./icons";
18 |
19 | interface WeatherProps {
20 | temperature?: string;
21 | feelsLike?: string;
22 | humidity?: string;
23 | windSpeed?: string;
24 | description?: string;
25 | location?: string;
26 | lastUpdated?: string;
27 | highTemp?: string;
28 | lowTemp?: string;
29 | pressure?: string;
30 | sunset?: string;
31 | sunrise?: string;
32 | latitude?: number;
33 | longitude?: number;
34 | width?: number;
35 | height?: number;
36 | }
37 |
38 | export default function Weather({
39 | temperature = "Loading...",
40 | feelsLike = "Loading...",
41 | humidity = "Loading...",
42 | windSpeed = "Loading...",
43 | description = "Loading...",
44 | location = "Loading...",
45 | lastUpdated = "Loading...",
46 | highTemp = "Loading...",
47 | lowTemp = "Loading...",
48 | pressure = "Loading...",
49 | sunset = "Loading...",
50 | sunrise = "Loading...",
51 | width = 800,
52 | height = 480,
53 | }: WeatherProps) {
54 | // Weather statistics
55 | const weatherStats = [
56 | { label: "Feels Like", value: `${feelsLike}°C`, icon: tempIcon },
57 | { label: "Humidity", value: `${humidity}%`, icon: humidityIcon },
58 | { label: "Wind Speed", value: `${windSpeed} km/h`, icon: windIcon },
59 | { label: "Pressure", value: `${pressure} hPa`, icon: pressureIcon },
60 | { label: "Sunrise", value: `${sunrise}`, icon: sunriseIcon },
61 | { label: "Sunset", value: `${sunset}`, icon: sunsetIcon },
62 | ];
63 |
64 | // Get weather icon based on description
65 | const getWeatherIcon = (desc: string) => {
66 | const lowerDesc = desc.toLowerCase();
67 | if (lowerDesc.includes("rain") || lowerDesc.includes("drizzle"))
68 | return RainIcon;
69 | if (lowerDesc.includes("snow")) return SnowIcon;
70 | if (lowerDesc.includes("cloud")) return CloudIcon;
71 | if (lowerDesc.includes("clear") || lowerDesc.includes("sun"))
72 | return SunIcon;
73 | if (lowerDesc.includes("fog") || lowerDesc.includes("mist")) return FogIcon;
74 | if (lowerDesc.includes("thunder")) return ThunderIcon;
75 | return CloudIcon; // default
76 | };
77 |
78 | const isHalfScreen = width === 400 && height === 480;
79 |
80 | return (
81 |
82 |
83 |
86 |
89 | {temperature}°C
90 |
91 |
92 | {getWeatherIcon(description)}
93 | {!isHalfScreen && (
94 |
95 |
96 | {tempUp} {highTemp}°C
97 | {tempDown} {lowTemp}°C
98 |
99 |
100 | )}
101 |
102 |
103 |
104 |
107 | {weatherStats.map((stat, index) => (
108 |
112 |
{stat.icon}
113 |
114 |
117 | {stat.label}
118 |
119 |
122 | {stat.value}
123 |
124 |
125 |
126 | ))}
127 |
128 |
129 |
{location}
130 |
{lastUpdated && Last updated: {lastUpdated} }
131 |
132 |
133 |
134 |
135 | );
136 | }
137 |
--------------------------------------------------------------------------------
/scripts/generate-sql-statements.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | const fs = require("fs");
4 | const path = require("path");
5 |
6 | const migrationsDir = path.join(__dirname, "../migrations");
7 | const outputFile = path.join(__dirname, "../lib/database/sql-statements.ts");
8 |
9 | function parseMigrationFile(content, fileName) {
10 | const titleMatch = content.match(/--\s*Title:\s*(.+)/i);
11 | const title = titleMatch
12 | ? titleMatch[1].trim()
13 | : fileName.replace(/\.sql$/, "");
14 |
15 | const descriptionMatch = content.match(/--\s*Description:\s*(.+)/i);
16 | const description = descriptionMatch
17 | ? descriptionMatch[1].trim()
18 | : `Migration: ${fileName}`;
19 |
20 | const sql = content
21 | .replace(/--\s*Title:\s*.+/i, "")
22 | .replace(/--\s*Description:\s*.+/i, "")
23 | .trim();
24 |
25 | return { title, description, sql };
26 | }
27 |
28 | function extractTableNames(sql) {
29 | const tableNames = new Set();
30 |
31 | // Match CREATE TABLE statements (with or without IF NOT EXISTS)
32 | // Handles both "CREATE TABLE table_name" and "CREATE TABLE IF NOT EXISTS table_name"
33 | // Also handles "CREATE TABLE public.table_name" and "CREATE TABLE IF NOT EXISTS public.table_name"
34 | const createTableRegex =
35 | /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:public\.)?(\w+)/gi;
36 | let match = createTableRegex.exec(sql);
37 |
38 | while (match !== null) {
39 | const tableName = match[1];
40 | if (tableName) {
41 | tableNames.add(tableName);
42 | }
43 | match = createTableRegex.exec(sql);
44 | }
45 |
46 | return Array.from(tableNames);
47 | }
48 |
49 | try {
50 | // Read all SQL files from migrations directory
51 | const files = fs
52 | .readdirSync(migrationsDir)
53 | .filter((f) => f.endsWith(".sql"))
54 | .sort();
55 |
56 | if (files.length === 0) {
57 | console.warn("⚠️ No migration files found in", migrationsDir);
58 | process.exit(1);
59 | }
60 |
61 | // Parse each migration file and extract table names
62 | const migrations = {};
63 | const allTableNames = new Set();
64 |
65 | for (const file of files) {
66 | const content = fs.readFileSync(path.join(migrationsDir, file), "utf-8");
67 | const key = file.replace(/\.sql$/, "").replace(/[-\s]/g, "_");
68 | const parsed = parseMigrationFile(content, file);
69 | migrations[key] = parsed;
70 |
71 | // Extract table names from this migration
72 | const tables = extractTableNames(parsed.sql);
73 | tables.forEach((table) => {
74 | allTableNames.add(table);
75 | });
76 | }
77 |
78 | // Generate validation query - simple query that returns missing tables
79 | const tableNamesArray = Array.from(allTableNames).sort();
80 | const validationQuery = `-- Check for missing required tables
81 | -- Returns empty result if all tables exist, or rows with missing table names if any are missing
82 | SELECT
83 | expected_table as missing_table
84 | FROM unnest(ARRAY[${tableNamesArray
85 | .map((t) => `'${t}'`)
86 | .join(", ")}]::text[]) as expected_table
87 | WHERE NOT EXISTS (
88 | SELECT 1
89 | FROM information_schema.tables
90 | WHERE table_schema = 'public'
91 | AND table_type = 'BASE TABLE'
92 | AND table_name = expected_table
93 | );`;
94 |
95 | // Generate TypeScript code
96 | const output = `// This file is auto-generated from migration files.
97 | // Do not edit manually. Run 'pnpm generate:sql' to regenerate.
98 |
99 | export const SQL_STATEMENTS = {
100 | ${Object.entries(migrations)
101 | .map(
102 | ([key, { title, description, sql }]) => `\t"${key}": {
103 | \t\ttitle: "${title.replace(/"/g, '\\"')}",
104 | \t\tdescription: "${description.replace(/"/g, '\\"')}",
105 | \t\tsql: \`${sql.replace(/`/g, "\\`")}\`,
106 | \t}`,
107 | )
108 | .join(",\n")},
109 | \t"validate_schema": {
110 | \t\ttitle: "Validate Database Schema",
111 | \t\tdescription: "Validates that all required tables exist in the public schema. Returns list of tables with their status and identifies any missing tables.",
112 | \t\tsql: \`${validationQuery.replace(/`/g, "\\`")}\`,
113 | \t}
114 | };
115 | `;
116 |
117 | // Write the output file
118 | fs.writeFileSync(outputFile, output);
119 |
120 | console.log(
121 | `✅ Generated ${
122 | Object.keys(migrations).length
123 | } SQL statements from migrations`,
124 | );
125 | console.log(` Output: ${path.relative(process.cwd(), outputFile)}`);
126 | console.log(
127 | ` Migrations: ${files.map((f) => f.replace(/\.sql$/, "")).join(", ")}`,
128 | );
129 | } catch (error) {
130 | console.error("❌ Error generating SQL statements:", error.message);
131 | process.exit(1);
132 | }
133 |
--------------------------------------------------------------------------------
/utils/render-png.ts:
--------------------------------------------------------------------------------
1 | import fs from "node:fs";
2 | import { crc32, deflate } from "node:zlib";
3 |
4 | const PNG_SIGNATURE = Buffer.from([
5 | 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
6 | ]);
7 |
8 | const CHUNK_iHDR = Buffer.from([
9 | 0x00,
10 | 0x00,
11 | 0x00,
12 | 0x0d,
13 | 0x49,
14 | 0x48,
15 | 0x44,
16 | 0x52, // size 13, 'iHDR'
17 | 0x00,
18 | 0x00,
19 | 0x00,
20 | 0x00,
21 | 0x00,
22 | 0x00,
23 | 0x00,
24 | 0x00, // width (32), height (32)
25 | 0x01,
26 | 0x00,
27 | 0x00,
28 | 0x00,
29 | 0x00, // bit depth, colortype (0 = GRAYSCALE), compression, filter, interlacing
30 | 0x00,
31 | 0x00,
32 | 0x00,
33 | 0x00,
34 | ]); // crc32
35 |
36 | const TYPE_IDAT = Buffer.from([0x49, 0x44, 0x41, 0x54]);
37 | const CHUNK_IEND = Buffer.from([
38 | 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
39 | ]); // size (32), 'IEND', crc32
40 |
41 | const DEBUG = false;
42 |
43 | function chunkSize(datasize: number) {
44 | return datasize + 12;
45 | }
46 |
47 | // creates and returns a buffer, input is raw bmp 1 bit
48 | export async function renderPng(bmp: Buffer) {
49 | const bitsPerPixel = bmp.readUInt16LE(28);
50 | if (bitsPerPixel !== 1) {
51 | console.warn(`input BMP has ${bitsPerPixel} vs expected 1`);
52 | return;
53 | }
54 | const width = bmp.readInt32LE(18);
55 | const height = bmp.readInt32LE(22);
56 | const absHeight = Math.abs(height);
57 | const bmpRowSize = Math.floor((width * bitsPerPixel + 31) / 32) * 4;
58 | const pngRowSize = Math.floor((width + 7) / 8);
59 |
60 | const offsetData = bmp.readUInt32LE(10);
61 | const infoHeaderSize = bmp.readUInt32LE(14);
62 |
63 | if (infoHeaderSize !== 40) {
64 | return;
65 | }
66 | const paletteOffset = 14 + infoHeaderSize;
67 | // white first
68 | const invert = bmp[paletteOffset] === 0xff;
69 |
70 | // compress first to get final size
71 | // Create a buffer that is sufficient to hold the compressed png data
72 | // there is a 1 byte header for each row !
73 | const pngRawRowSize = pngRowSize + 1;
74 |
75 | const uncompressedBuffer = Buffer.alloc((pngRawRowSize + 1) * absHeight);
76 |
77 | // copy all input and invert at the same time (by row)
78 | for (let y = 0; y < absHeight; ++y) {
79 | uncompressedBuffer[y * pngRawRowSize] = 0;
80 | const bmprow = height < 0 ? y : height - y - 1;
81 | for (let x = 0; x < pngRowSize; ++x) {
82 | // white is 0 in bmp
83 | const b = bmp[offsetData + bmprow * bmpRowSize + x];
84 | uncompressedBuffer[y * pngRawRowSize + 1 + x] = invert ? ~b : b;
85 | }
86 | }
87 |
88 | // should be smaller than input
89 | deflate(uncompressedBuffer, { level: 9 }, (error, result) => {
90 | if (error) {
91 | console.warn(`deflate error ${error}`);
92 | return;
93 | }
94 | // create the PNG in memory
95 | const iDATChunkDataSize = result.byteLength;
96 | console.warn(`idat sizer ${iDATChunkDataSize}`);
97 | const pngSize = 8 + 25 + chunkSize(iDATChunkDataSize) + 12;
98 | var buf = Buffer.alloc(pngSize);
99 |
100 | // signature
101 | PNG_SIGNATURE.copy(buf, 0);
102 |
103 | // iHDR
104 | CHUNK_iHDR.copy(buf, 8); // 25 bytes
105 | buf.writeUInt32BE(width, 16); // patch width
106 | buf.writeUInt32BE(height, 20); // patch height
107 | const crc = crc32(buf.subarray(12, 29));
108 | buf.writeUInt32BE(crc, 29);
109 |
110 | let offset = 33;
111 |
112 | // IDAT
113 | offset = beginWriteChunk(buf, offset, TYPE_IDAT, iDATChunkDataSize);
114 | result.copy(buf, offset);
115 | offset = endWriteChunk(buf, offset, iDATChunkDataSize);
116 |
117 | // IEND
118 | CHUNK_IEND.copy(buf, 8 + 25 + chunkSize(iDATChunkDataSize));
119 |
120 | if (DEBUG) {
121 | const pngDestination = "/tmp/toto.png";
122 | fs.writeFile(pngDestination, buf, (err) => {
123 | if (err)
124 | console.warn(
125 | `could no write PNG to ${pngDestination} because of ${err}`,
126 | );
127 | });
128 | }
129 | return buf;
130 | });
131 | }
132 |
133 | // returns next writing pos
134 | function beginWriteChunk(
135 | outBuffer: Buffer,
136 | offset: number,
137 | chunkType: Buffer,
138 | chunkSize: number,
139 | ) {
140 | outBuffer.writeUInt32BE(chunkSize, offset + 0);
141 | chunkType.copy(outBuffer, offset + 4);
142 | return offset + 8;
143 | }
144 |
145 | function endWriteChunk(
146 | outBuffer: Buffer,
147 | chunkStartOffset: number,
148 | chunkSize: number,
149 | ) {
150 | const crc = crc32(
151 | outBuffer.subarray(chunkStartOffset - 4, chunkStartOffset + chunkSize),
152 | );
153 | outBuffer.writeUInt32BE(crc, chunkStartOffset + chunkSize);
154 | return chunkStartOffset + chunkSize + 4;
155 | }
156 |
--------------------------------------------------------------------------------
/app/api/bitmap/[[...slug]]/route.ts:
--------------------------------------------------------------------------------
1 | import type { NextRequest } from "next/server";
2 | import { cache } from "react";
3 | import NotFoundScreen from "@/app/recipes/screens/not-found/not-found";
4 | import screens from "@/app/recipes/screens.json";
5 | import {
6 | addDimensionsToProps,
7 | buildRecipeElement,
8 | DEFAULT_IMAGE_HEIGHT,
9 | DEFAULT_IMAGE_WIDTH,
10 | logger,
11 | renderRecipeOutputs,
12 | } from "@/lib/recipes/recipe-renderer";
13 |
14 | export async function GET(
15 | req: NextRequest,
16 | { params }: { params: Promise<{ slug?: string[] }> },
17 | ) {
18 | try {
19 | // Always await params as required by Next.js 14/15
20 | const { slug = ["not-found"] } = await params;
21 | const bitmapPath = Array.isArray(slug) ? slug.join("/") : slug;
22 | const recipeSlug = bitmapPath.replace(".bmp", "");
23 |
24 | // Get width, height, and grayscale from query parameters
25 | const { searchParams } = new URL(req.url);
26 | const widthParam = searchParams.get("width");
27 | const heightParam = searchParams.get("height");
28 | const grayscaleParam = searchParams.get("grayscale");
29 |
30 | const width = widthParam ? parseInt(widthParam, 10) : DEFAULT_IMAGE_WIDTH;
31 | const height = heightParam
32 | ? parseInt(heightParam, 10)
33 | : DEFAULT_IMAGE_HEIGHT;
34 |
35 | // Validate width and height are positive numbers
36 | const validWidth = width > 0 ? width : DEFAULT_IMAGE_WIDTH;
37 | const validHeight = height > 0 ? height : DEFAULT_IMAGE_HEIGHT;
38 | const grayscaleLevels = grayscaleParam ? parseInt(grayscaleParam, 10) : 2;
39 |
40 | logger.info(
41 | `Bitmap request for: ${bitmapPath} in ${validWidth}x${validHeight} with ${grayscaleLevels} gray levels`,
42 | );
43 |
44 | const recipeId = screens[recipeSlug as keyof typeof screens]
45 | ? recipeSlug
46 | : "simple-text";
47 |
48 | const recipeBuffer = await renderRecipeBitmap(
49 | recipeId,
50 | validWidth,
51 | validHeight,
52 | grayscaleLevels,
53 | );
54 |
55 | if (
56 | !recipeBuffer ||
57 | !(recipeBuffer instanceof Buffer) ||
58 | recipeBuffer.length === 0
59 | ) {
60 | logger.warn(
61 | `Failed to generate bitmap for ${recipeId}, returning fallback`,
62 | );
63 | const fallback = await renderFallbackBitmap();
64 | return fallback;
65 | }
66 |
67 | return new Response(new Uint8Array(recipeBuffer), {
68 | headers: {
69 | "Content-Type": "image/bmp",
70 | "Content-Length": recipeBuffer.length.toString(),
71 | },
72 | });
73 | } catch (error) {
74 | logger.error("Error generating image:", error);
75 |
76 | // Instead of returning an error, return the NotFoundScreen as a fallback
77 | return await renderFallbackBitmap("Error occurred");
78 | }
79 | }
80 |
81 | const renderRecipeBitmap = cache(
82 | async (
83 | recipeId: string,
84 | width: number,
85 | height: number,
86 | grayscaleLevels: number = 2,
87 | ) => {
88 | const { config, Component, props, element } = await buildRecipeElement({
89 | slug: recipeId,
90 | });
91 |
92 | const ComponentToRender =
93 | Component ??
94 | (() => {
95 | return element;
96 | });
97 |
98 | const propsWithDimensions = addDimensionsToProps(props, width, height);
99 |
100 | const renders = await renderRecipeOutputs({
101 | slug: recipeId,
102 | Component: ComponentToRender,
103 | props: propsWithDimensions,
104 | config: config ?? null,
105 | imageWidth: width,
106 | imageHeight: height,
107 | formats: ["bitmap"],
108 | grayscale: grayscaleLevels,
109 | });
110 |
111 | return renders.bitmap ?? Buffer.from([]);
112 | },
113 | );
114 |
115 | const renderFallbackBitmap = cache(async (slug: string = "not-found") => {
116 | try {
117 | const renders = await renderRecipeOutputs({
118 | slug,
119 | Component: NotFoundScreen,
120 | props: { slug },
121 | config: null,
122 | imageWidth: DEFAULT_IMAGE_WIDTH,
123 | imageHeight: DEFAULT_IMAGE_HEIGHT,
124 | formats: ["bitmap"],
125 | grayscale: 2, // Default to 2 levels for fallback
126 | });
127 |
128 | if (!renders.bitmap) {
129 | throw new Error("Missing bitmap buffer for fallback");
130 | }
131 |
132 | return new Response(new Uint8Array(renders.bitmap), {
133 | headers: {
134 | "Content-Type": "image/bmp",
135 | "Content-Length": renders.bitmap.length.toString(),
136 | },
137 | });
138 | } catch (fallbackError) {
139 | logger.error("Error generating fallback image:", fallbackError);
140 | return new Response("Error generating image", {
141 | status: 500,
142 | headers: {
143 | "Content-Type": "text/plain",
144 | },
145 | });
146 | }
147 | });
148 |
--------------------------------------------------------------------------------
/app/actions/execute-sql.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import postgres from "postgres";
4 | import { SQL_STATEMENTS } from "@/lib/database/sql-statements";
5 |
6 | export type SqlExecutionStatus =
7 | | "idle"
8 | | "loading"
9 | | "success"
10 | | "error"
11 | | "warning";
12 |
13 | export interface SqlExecutionResult {
14 | status: SqlExecutionStatus;
15 | result: Record[];
16 | notices: Record[];
17 | error?: string;
18 | executionTime?: number;
19 | }
20 |
21 | export type SqlExecutionState = {
22 | [key in keyof typeof SQL_STATEMENTS]: SqlExecutionResult;
23 | };
24 |
25 | // List of error messages that should be treated as warnings and allow execution to continue
26 | const NON_FATAL_ERRORS = [
27 | "already exists",
28 | "relation already exists",
29 | "duplicate key value violates unique constraint",
30 | ];
31 |
32 | export async function executeSqlStatements(): Promise {
33 | const postgresUrl = process.env.DATABASE_URL;
34 |
35 | if (!postgresUrl) {
36 | // Return error state for all statements
37 | return Object.keys(SQL_STATEMENTS).reduce((acc, key) => {
38 | acc[key as keyof typeof SQL_STATEMENTS] = {
39 | status: "error",
40 | result: [],
41 | notices: [],
42 | error: "DATABASE_URL is not defined",
43 | };
44 | return acc;
45 | }, {} as SqlExecutionState);
46 | }
47 |
48 | // Transform DATABASE_URL to the correct format
49 | function transformPostgresUrl(url: string): string {
50 | try {
51 | const parsedUrl = new URL(url);
52 | const username = parsedUrl.username;
53 | const password = parsedUrl.password;
54 | return `postgresql://${username}:${password}@${parsedUrl.host}${parsedUrl.pathname}${parsedUrl.search}`;
55 | } catch (error) {
56 | console.error("Error transforming URL:", error);
57 | throw new Error("Invalid URL format");
58 | }
59 | }
60 |
61 | const connectionString = transformPostgresUrl(postgresUrl);
62 |
63 | // Initialize result state with all statements in loading state
64 | const resultState: SqlExecutionState = Object.keys(SQL_STATEMENTS).reduce(
65 | (acc, key) => {
66 | acc[key as keyof typeof SQL_STATEMENTS] = {
67 | status: "loading",
68 | result: [],
69 | notices: [],
70 | };
71 | return acc;
72 | },
73 | {} as SqlExecutionState,
74 | );
75 |
76 | const sql = postgres(connectionString, {
77 | ssl: connectionString.includes("sslmode=disable") ? false : "require",
78 | onnotice: () => {
79 | // We'll handle notices per query
80 | },
81 | });
82 |
83 | try {
84 | // Execute each statement in sequence
85 | for (const [key, statement] of Object.entries(SQL_STATEMENTS)) {
86 | const notices: Record[] = [];
87 |
88 | // Create a new SQL client with notice handler for this specific query
89 | const sqlWithNotices = postgres(connectionString, {
90 | ssl: connectionString.includes("sslmode=disable") ? false : "require",
91 | onnotice: (notice) => {
92 | console.log(`Database notice for ${key}:`, notice);
93 | notices.push(notice);
94 | },
95 | });
96 |
97 | try {
98 | const startTime = performance.now();
99 | const result = await sqlWithNotices.unsafe(statement.sql);
100 | const endTime = performance.now();
101 |
102 | resultState[key as keyof typeof SQL_STATEMENTS] = {
103 | status: "success",
104 | result: result || [],
105 | notices,
106 | executionTime: Math.round(endTime - startTime),
107 | };
108 | } catch (error) {
109 | console.error(`Error executing SQL for ${key}:`, error);
110 |
111 | const errorMessage =
112 | error instanceof Error ? error.message : String(error);
113 |
114 | // Check if this is a non-fatal error that should be treated as a warning
115 | const isNonFatalError = NON_FATAL_ERRORS.some((msg) =>
116 | errorMessage.toLowerCase().includes(msg.toLowerCase()),
117 | );
118 |
119 | resultState[key as keyof typeof SQL_STATEMENTS] = {
120 | status: isNonFatalError ? "warning" : "error",
121 | result: [],
122 | notices,
123 | error: errorMessage,
124 | };
125 |
126 | // Only stop execution on fatal errors
127 | if (!isNonFatalError) {
128 | break;
129 | }
130 | // For non-fatal errors, continue with the next statement
131 | } finally {
132 | // Close the connection for this query
133 | await sqlWithNotices.end();
134 | }
135 | }
136 | } catch (error) {
137 | console.error("Unexpected error during SQL execution:", error);
138 | } finally {
139 | // Close the main connection
140 | await sql.end();
141 | }
142 |
143 | return resultState;
144 | }
145 |
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata, Viewport } from "next";
2 | import "./globals.css";
3 | import { Suspense } from "react";
4 | import MainLayout from "@/components/main-layout-server";
5 | import { ThemeProvider } from "@/components/theme-provider";
6 | import { Skeleton } from "@/components/ui/skeleton";
7 | import { Toaster } from "@/components/ui/sonner";
8 | import { getAllFontVariables } from "@/lib/fonts";
9 | import { cn } from "@/lib/utils";
10 |
11 | const META_THEME_COLORS = {
12 | light: "#ffffff",
13 | dark: "#09090b",
14 | };
15 |
16 | export const metadata: Metadata = {
17 | title: "TRMNL Next.js",
18 | description: "Device management dashboard",
19 | };
20 |
21 | export const viewport: Viewport = {
22 | themeColor: [
23 | { media: "(prefers-color-scheme: light)", color: META_THEME_COLORS.light },
24 | { media: "(prefers-color-scheme: dark)", color: META_THEME_COLORS.dark },
25 | ],
26 | };
27 |
28 | // Server Component MainContentFallback for loading states
29 | const MainContentFallback = () => (
30 |
31 | {/* Header section */}
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 | {/* Content cards */}
41 |
42 | {[1, 2, 3].map((i) => (
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 | ))}
53 |
54 |
55 | {/* Table or list section */}
56 |
57 |
58 |
59 | {[1, 2, 3, 4].map((i) => (
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 | ))}
71 |
72 |
73 |
74 | );
75 |
76 | // Layout skeleton to use while the main layout is loading
77 | const LayoutSkeleton = () => (
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 | {[1, 2, 3, 4, 5].map((i) => (
95 |
96 | ))}
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 |
105 |
106 | );
107 |
108 | export default async function RootLayout({
109 | children,
110 | }: Readonly<{
111 | children: React.ReactNode;
112 | }>) {
113 | return (
114 |
115 |
121 | {/* ThemeProvider is a Client Component wrapper */}
122 |
123 | }>
124 |
125 | }>{children}
126 |
127 |
128 |
129 |
130 |
131 |
132 | );
133 | }
134 |
--------------------------------------------------------------------------------
/components/mixup/mixup-page-client.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Plus } from "lucide-react";
4 | import { useState } from "react";
5 | import { toast } from "sonner";
6 | import {
7 | deleteMixup,
8 | fetchMixupWithSlots,
9 | saveMixupWithSlots,
10 | } from "@/app/actions/mixup";
11 | import { Button } from "@/components/ui/button";
12 | import { slotsToAssignments } from "@/lib/mixup/constants";
13 | import type { Mixup } from "@/lib/types";
14 | import { MixupBuilder, type MixupBuilderData } from "./mixup-builder";
15 | import { MixupList } from "./mixup-list";
16 |
17 | type MixupRecipe = {
18 | slug: string;
19 | title: string;
20 | description?: string;
21 | tags?: string[];
22 | };
23 |
24 | interface MixupPageClientProps {
25 | initialMixups: Mixup[];
26 | recipes: MixupRecipe[];
27 | }
28 |
29 | export function MixupPageClient({
30 | initialMixups,
31 | recipes,
32 | }: MixupPageClientProps) {
33 | const [mixups, setMixups] = useState(initialMixups);
34 | const [showEditor, setShowEditor] = useState(false);
35 | const [editingData, setEditingData] = useState(
36 | undefined,
37 | );
38 | const [isLoading, setIsLoading] = useState(false);
39 |
40 | const handleCreateMixup = () => {
41 | setEditingData(undefined);
42 | setShowEditor(true);
43 | };
44 |
45 | const handleEditMixup = async (mixup: Mixup) => {
46 | setIsLoading(true);
47 | try {
48 | const { slots } = await fetchMixupWithSlots(mixup.id);
49 | const assignments = slotsToAssignments(slots);
50 |
51 | setEditingData({
52 | id: mixup.id,
53 | name: mixup.name,
54 | layout_id: mixup.layout_id,
55 | assignments,
56 | });
57 | setShowEditor(true);
58 | } catch (error) {
59 | console.error("Error loading mixup:", error);
60 | toast.error("Failed to load mixup");
61 | } finally {
62 | setIsLoading(false);
63 | }
64 | };
65 |
66 | const handleSaveMixup = async (data: MixupBuilderData) => {
67 | setIsLoading(true);
68 | try {
69 | const result = await saveMixupWithSlots(data);
70 |
71 | if (result.success) {
72 | toast.success(
73 | data.id
74 | ? "Mixup updated successfully!"
75 | : "Mixup created successfully!",
76 | );
77 |
78 | // Refresh the page to get updated data
79 | window.location.reload();
80 | } else {
81 | toast.error(result.error || "Failed to save mixup");
82 | }
83 | } catch (error) {
84 | console.error("Error saving mixup:", error);
85 | toast.error("An unexpected error occurred");
86 | } finally {
87 | setIsLoading(false);
88 | }
89 | };
90 |
91 | const handleDeleteMixup = async (mixupId: string) => {
92 | if (!confirm("Are you sure you want to delete this mixup?")) {
93 | return;
94 | }
95 |
96 | setIsLoading(true);
97 | try {
98 | const result = await deleteMixup(mixupId);
99 |
100 | if (result.success) {
101 | toast.success("Mixup deleted successfully!");
102 | // Update local state
103 | setMixups((prev) => prev.filter((m) => m.id !== mixupId));
104 | } else {
105 | toast.error(result.error || "Failed to delete mixup");
106 | }
107 | } catch (error) {
108 | console.error("Error deleting mixup:", error);
109 | toast.error("An unexpected error occurred");
110 | } finally {
111 | setIsLoading(false);
112 | }
113 | };
114 |
115 | const handleCancel = () => {
116 | setShowEditor(false);
117 | setEditingData(undefined);
118 | };
119 |
120 | if (showEditor) {
121 | return (
122 |
123 |
124 |
125 | {editingData?.id ? "Edit Mixup" : "New Mixup"}
126 |
127 |
128 | {editingData?.id
129 | ? "Modify your mixup layout and recipe assignments."
130 | : "Blend up to four recipes on the same screen. Choose a layout, drop recipes into each quarter, and preview how they will share space."}
131 |
132 |
133 |
134 |
141 |
142 | );
143 | }
144 |
145 | return (
146 |
147 |
148 |
Mixup
149 |
150 | Blend up to four recipes on the same screen. Choose a layout, drop
151 | recipes into each quarter, and preview how they will share space.
152 |
153 |
154 |
155 |
156 |
157 |
158 | New Mixup
159 |
160 |
161 |
162 |
168 |
169 | );
170 | }
171 |
--------------------------------------------------------------------------------
/app/recipes/page.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 | import { Suspense } from "react";
3 | import screens from "@/app/recipes/screens.json";
4 | import { AspectRatio } from "@/components/ui/aspect-ratio";
5 | import { Badge } from "@/components/ui/badge";
6 | import {
7 | DEFAULT_IMAGE_HEIGHT,
8 | DEFAULT_IMAGE_WIDTH,
9 | } from "@/lib/recipes/constants";
10 |
11 | // Get published components
12 | const getPublishedComponents = () => {
13 | const componentEntries = Object.entries(screens);
14 |
15 | // Filter out unpublished components in production
16 | return process.env.NODE_ENV === "production"
17 | ? componentEntries.filter(([, config]) => config.published)
18 | : componentEntries;
19 | };
20 |
21 | // Component to display a preview with Suspense
22 | const ComponentPreview = ({
23 | slug,
24 | config,
25 | }: {
26 | slug: string;
27 | config: (typeof screens)[keyof typeof screens];
28 | }) => {
29 | return (
30 |
34 |
35 |
36 |
46 |
47 |
48 | );
49 | };
50 |
51 | // Component for a single card
52 | const RecipeCard = ({
53 | slug,
54 | config,
55 | }: {
56 | slug: string;
57 | config: (typeof screens)[keyof typeof screens];
58 | }) => {
59 | return (
60 |
65 |
66 |
67 |
68 |
69 | {config.title}
70 |
71 |
72 | {config.description}
73 |
74 |
75 |
76 | {config.tags.slice(0, 3).map((tag: string) => (
77 |
78 | {tag}
79 |
80 | ))}
81 | {config.tags.length > 3 && (
82 | +{config.tags.length - 3} more
83 | )}
84 |
85 |
86 | v{config.version}
87 | {new Date(config.updatedAt).toLocaleDateString()}
88 |
89 |
90 |
91 | );
92 | };
93 |
94 | // Component for a category section
95 | const CategorySection = ({
96 | category,
97 | components,
98 | }: {
99 | category: string;
100 | components: Array<[string, (typeof screens)[keyof typeof screens]]>;
101 | }) => {
102 | return (
103 |
104 |
105 | {category.replace(/-/g, " ")}
106 |
107 |
108 | {components.map(([slug, config]) => (
109 |
110 | ))}
111 |
112 |
113 | );
114 | };
115 |
116 | // Main component that organizes recipes by category
117 | const RecipesGrid = () => {
118 | const publishedComponents = getPublishedComponents();
119 |
120 | // Group components by category
121 | const componentsByCategory = publishedComponents.reduce(
122 | (acc, [slug, config]) => {
123 | const category = config.category || "uncategorized";
124 | if (!acc[category]) {
125 | acc[category] = [];
126 | }
127 | acc[category].push([slug, config]);
128 | return acc;
129 | },
130 | {} as Record<
131 | string,
132 | Array<[string, (typeof screens)[keyof typeof screens]]>
133 | >,
134 | );
135 |
136 | // Sort categories alphabetically
137 | const sortedCategories = Object.keys(componentsByCategory).sort();
138 |
139 | return (
140 |
141 | {sortedCategories.map((category) => (
142 |
147 | ))}
148 |
149 | );
150 | };
151 |
152 | export default function RecipesIndex() {
153 | return (
154 |
155 |
156 |
Recipes
157 |
158 | Browse and customize ready-to-use recipes for your TRMNL device.
159 |
160 |
161 |
Loading recipes... }>
162 |
163 |
164 |
165 | );
166 | }
167 |
--------------------------------------------------------------------------------
/components/playlists/playlist-page-client.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Plus } from "lucide-react";
4 | import { useState } from "react";
5 | import { toast } from "sonner";
6 | import { deletePlaylist, savePlaylistWithItems } from "@/app/actions/playlist";
7 | import { Button } from "@/components/ui/button";
8 | import { Playlist, PlaylistItem } from "@/lib/types";
9 | import { PlaylistEditor } from "./playlist-editor";
10 | import { PlaylistList } from "./playlist-list";
11 |
12 | interface PlaylistPageClientProps {
13 | initialPlaylists: Playlist[];
14 | initialPlaylistItems: PlaylistItem[];
15 | }
16 |
17 | export function PlaylistPageClient({
18 | initialPlaylists,
19 | initialPlaylistItems,
20 | }: PlaylistPageClientProps) {
21 | // eslint-disable-next-line @typescript-eslint/no-unused-vars
22 | const [playlists, _setPlaylists] = useState(initialPlaylists);
23 | const [playlistItems] = useState(initialPlaylistItems);
24 | const [showEditor, setShowEditor] = useState(false);
25 | const [editingPlaylist, setEditingPlaylist] = useState<
26 | (Playlist & { items?: PlaylistItem[] }) | null
27 | >(null);
28 | const [isLoading, setIsLoading] = useState(false);
29 |
30 | const handleCreatePlaylist = () => {
31 | setEditingPlaylist(null);
32 | setShowEditor(true);
33 | };
34 |
35 | const handleEditPlaylist = (playlist: Playlist) => {
36 | const itemsForPlaylist = playlistItems
37 | .filter((item) => item.playlist_id === playlist.id)
38 | .sort(
39 | (a, b) =>
40 | (a.order_index ?? Number.MAX_SAFE_INTEGER) -
41 | (b.order_index ?? Number.MAX_SAFE_INTEGER),
42 | );
43 |
44 | setEditingPlaylist({
45 | ...playlist,
46 | items: itemsForPlaylist,
47 | });
48 | setShowEditor(true);
49 | };
50 |
51 | const handleSavePlaylist = async (data: {
52 | id?: string;
53 | name: string;
54 | items: Array<{
55 | id: string;
56 | screen_id: string;
57 | duration: number;
58 | order_index: number;
59 | start_time?: string;
60 | end_time?: string;
61 | days_of_week?: string[];
62 | }>;
63 | }) => {
64 | setIsLoading(true);
65 | try {
66 | const result = await savePlaylistWithItems(data);
67 |
68 | if (result.success) {
69 | toast.success(
70 | data.id
71 | ? "Playlist updated successfully!"
72 | : "Playlist created successfully!",
73 | );
74 |
75 | // Refresh the page to get updated data
76 | window.location.reload();
77 | } else {
78 | toast.error(result.error || "Failed to save playlist");
79 | }
80 | } catch (error) {
81 | console.error("Error saving playlist:", error);
82 | toast.error("An unexpected error occurred");
83 | } finally {
84 | setIsLoading(false);
85 | }
86 | };
87 |
88 | const handleDeletePlaylist = async (playlistId: string) => {
89 | if (!confirm("Are you sure you want to delete this playlist?")) {
90 | return;
91 | }
92 |
93 | setIsLoading(true);
94 | try {
95 | const result = await deletePlaylist(playlistId);
96 |
97 | if (result.success) {
98 | toast.success("Playlist deleted successfully!");
99 | // Refresh the page to get updated data
100 | window.location.reload();
101 | } else {
102 | toast.error(result.error || "Failed to delete playlist");
103 | }
104 | } catch (error) {
105 | console.error("Error deleting playlist:", error);
106 | toast.error("An unexpected error occurred");
107 | } finally {
108 | setIsLoading(false);
109 | }
110 | };
111 |
112 | const handleCancel = () => {
113 | setShowEditor(false);
114 | setEditingPlaylist(null);
115 | };
116 |
117 | if (showEditor) {
118 | return (
119 |
120 |
121 |
122 | {editingPlaylist ? "Edit Playlist" : "New Playlist"}
123 |
124 |
125 | {editingPlaylist
126 | ? "Modify your playlist settings and items."
127 | : "Create a new playlist for your TRMNL devices."}
128 |
129 |
130 |
131 |
({
138 | ...item,
139 | start_time: item.start_time ?? undefined,
140 | end_time: item.end_time ?? undefined,
141 | days_of_week: item.days_of_week ?? undefined,
142 | })),
143 | }
144 | : undefined
145 | }
146 | onSave={handleSavePlaylist}
147 | onCancel={handleCancel}
148 | />
149 |
150 | );
151 | }
152 |
153 | return (
154 |
155 |
156 |
157 |
158 | New Playlist
159 |
160 |
161 |
162 |
169 |
170 | );
171 | }
172 |
--------------------------------------------------------------------------------
/lib/getInitData.ts:
--------------------------------------------------------------------------------
1 | import { cache } from "react";
2 | import { db } from "@/lib/database/db";
3 | import { getDbStatus } from "@/lib/database/utils";
4 | import type {
5 | Device,
6 | Mixup,
7 | Playlist,
8 | PlaylistItem,
9 | SystemLog,
10 | } from "@/lib/types";
11 | import "server-only";
12 |
13 | export type InitialData = {
14 | devices: Device[];
15 | playlists: Playlist[];
16 | playlistItems: PlaylistItem[];
17 | mixups: Mixup[];
18 | systemLogs: SystemLog[];
19 | uniqueSources: string[];
20 | totalLogs: number;
21 | dbStatus: {
22 | ready: boolean;
23 | error?: string;
24 | PostgresUrl?: string;
25 | };
26 | };
27 |
28 | /**
29 | * Centralized cached function to get all initial application data.
30 | *
31 | * This function fetches and returns all necessary data for the application:
32 | * - Devices, logs, and other data from the database
33 | * - Status of the database connection
34 | * - Host URL and other environmental data
35 | *
36 | * The data is cached using React's cache() function, which means:
37 | * - Multiple calls to this function during the same request will be deduplicated
38 | * - The data can be shared across different components and pages
39 | * - Navigation between pages will not cause duplicate data fetching
40 | *
41 | * @returns Promise All the application's data
42 | */
43 | export const getInitData = cache(async (): Promise => {
44 | const dbStatus = await getDbStatus();
45 |
46 | // Default empty values if DB is not ready
47 | let devices: Device[] = [];
48 | let systemLogs: SystemLog[] = [];
49 | let uniqueSources: string[] = [];
50 | let totalLogs = 0;
51 | let playlists: Playlist[] = [];
52 | let playlistItems: PlaylistItem[] = [];
53 | let mixups: Mixup[] = [];
54 |
55 | // Fetch data only if DB is ready
56 | if (dbStatus.ready) {
57 | try {
58 | const [
59 | devicesResult,
60 | playlistsResult,
61 | playlistItemsResult,
62 | mixupsResult,
63 | logsResult,
64 | sourcesResult,
65 | logsCountResult,
66 | ] = await Promise.all([
67 | // Fetch devices
68 | db
69 | .selectFrom("devices")
70 | .selectAll()
71 | .execute(),
72 | // Fetch playlists
73 | db
74 | .selectFrom("playlists")
75 | .selectAll()
76 | .execute(),
77 | // Fetch playlist items
78 | db
79 | .selectFrom("playlist_items")
80 | .selectAll()
81 | .execute(),
82 | // Fetch mixups
83 | db
84 | .selectFrom("mixups")
85 | .selectAll()
86 | .orderBy("created_at", "desc")
87 | .execute(),
88 | // Fetch recent logs
89 | db
90 | .selectFrom("system_logs")
91 | .selectAll()
92 | .orderBy("created_at", "desc")
93 | .limit(50)
94 | .execute(),
95 | // Fetch unique sources for filters
96 | db
97 | .selectFrom("system_logs")
98 | .select("source")
99 | .distinct()
100 | .orderBy("source")
101 | .execute(),
102 | // Get total logs count
103 | db
104 | .selectFrom("system_logs")
105 | .select((eb) => eb.fn.countAll().as("count"))
106 | .executeTakeFirst(),
107 | ]);
108 |
109 | devices = devicesResult as unknown as Device[];
110 | playlists = playlistsResult as unknown as Playlist[];
111 | playlistItems = playlistItemsResult as unknown as PlaylistItem[];
112 | mixups = mixupsResult as unknown as Mixup[];
113 | systemLogs = logsResult as unknown as SystemLog[];
114 | uniqueSources = Array.from(
115 | new Set(
116 | sourcesResult.map((item) => item.source).filter(Boolean) as string[],
117 | ),
118 | );
119 | totalLogs = Number(logsCountResult?.count || 0);
120 | } catch (error) {
121 | console.error("Error fetching initial data:", error);
122 | }
123 | }
124 |
125 | return {
126 | devices,
127 | playlists,
128 | playlistItems,
129 | mixups,
130 | systemLogs,
131 | uniqueSources,
132 | totalLogs,
133 | dbStatus,
134 | };
135 | });
136 |
137 | /**
138 | * Cached function to get just devices data.
139 | * This is an optimized subset of getInitData() that only returns device information.
140 | *
141 | * @returns Promise Array of devices
142 | */
143 | export const getDevices = cache(async (): Promise => {
144 | // Re-use the full data fetch to maintain cache coherence
145 | const data = await getInitData();
146 | return data.devices;
147 | });
148 |
149 | /**
150 | * Preload function for the dashboard data.
151 | * Call this function in server components to start loading data
152 | * before it's actually needed, improving perceived performance.
153 | */
154 | export function preloadDashboard() {
155 | void getInitData();
156 | }
157 |
158 | /**
159 | * Preload function for system logs data.
160 | * Call this function in server components to start loading data
161 | * before it's actually needed, improving perceived performance.
162 | */
163 | export function preloadSystemLogs() {
164 | void getInitData();
165 | }
166 |
167 | /**
168 | * Preload function for devices data.
169 | * Call this function in server components to start loading data
170 | * before it's actually needed, improving perceived performance.
171 | */
172 | export function preloadDevices() {
173 | void getDevices();
174 | }
175 |
--------------------------------------------------------------------------------
/components/playlists/playlist-item.tsx:
--------------------------------------------------------------------------------
1 | import { GripVertical, Trash2 } from "lucide-react";
2 | import { Button } from "@/components/ui/button";
3 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
4 | import { Input } from "@/components/ui/input";
5 | import { Label } from "@/components/ui/label";
6 | import {
7 | Select,
8 | SelectContent,
9 | SelectItem,
10 | SelectTrigger,
11 | SelectValue,
12 | } from "@/components/ui/select";
13 | import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
14 |
15 | interface PlaylistItemProps {
16 | item: {
17 | id: string;
18 | screen_id: string;
19 | duration: number;
20 | order_index: number;
21 | start_time?: string;
22 | end_time?: string;
23 | days_of_week?: string[];
24 | };
25 | onUpdate: (id: string, data: Partial) => void;
26 | onDelete: (id: string) => void;
27 | screenOptions: { id: string; name: string }[];
28 | }
29 |
30 | const daysOfWeek = [
31 | { value: "monday", label: "Mon" },
32 | { value: "tuesday", label: "Tue" },
33 | { value: "wednesday", label: "Wed" },
34 | { value: "thursday", label: "Thu" },
35 | { value: "friday", label: "Fri" },
36 | { value: "saturday", label: "Sat" },
37 | { value: "sunday", label: "Sun" },
38 | ];
39 |
40 | export function PlaylistItem({
41 | item,
42 | onUpdate,
43 | onDelete,
44 | screenOptions,
45 | }: PlaylistItemProps) {
46 | return (
47 |
48 |
49 |
50 |
51 |
52 |
53 | Item {item.order_index + 1}
54 |
55 |
56 |
onDelete(item.id)}
60 | className="text-destructive hover:text-destructive"
61 | >
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 | Screen
70 | onUpdate(item.id, { screen_id: value })}
73 | >
74 |
75 |
76 |
77 |
78 | {screenOptions.map((screen) => (
79 |
80 | {screen.name}
81 |
82 | ))}
83 |
84 |
85 |
86 |
87 |
88 | Duration (seconds)
89 |
96 | onUpdate(item.id, {
97 | duration: parseInt(e.target.value, 10) || 30,
98 | })
99 | }
100 | />
101 |
102 |
103 |
104 |
105 |
106 |
Days of Week (optional)
107 |
108 |
114 | onUpdate(item.id, { days_of_week: value })
115 | }
116 | className="flex-wrap w-full sm:w-fit gap-y-1.5"
117 | >
118 | {daysOfWeek.map((day) => {
119 | const _isSelected = (item.days_of_week || []).includes(
120 | day.value,
121 | );
122 | return (
123 |
128 | {day.label}
129 |
130 | );
131 | })}
132 |
133 |
134 |
135 |
160 |
161 |
162 |
163 | );
164 | }
165 |
--------------------------------------------------------------------------------
/docs/recipes.md:
--------------------------------------------------------------------------------
1 | # Recipe Collection
2 |
3 | This recipes page allows you to visualize and test components in both their direct rendering and bitmap (BMP) rendering forms. It's designed to help develop and test components for e-ink displays.
4 |
5 | ## How It Works
6 |
7 | The recipes page provides two main views:
8 |
9 | 1. **Index View** (`/recipes`) - Shows a list of all available recipes grouped by category
10 | 2. **Recipe View** (`/recipes/[slug]`) - Shows a specific recipe with both direct and BMP rendering
11 |
12 | Each recipe is defined in `app/recipes/screens.json` and can be accessed via its slug.
13 |
14 | ## Adding New Recipe
15 |
16 | To add a new recipe to the collection:
17 |
18 | 1. Create your recipe folder in the `app/recipes/screens` directory
19 | 2. Add your my-component.tsx and get-data.ts to the recipe folder
20 | 3. Add an entry to `app/recipes/screens.json` with the following structure:
21 |
22 | ```json
23 | {
24 | "component-slug": {
25 | "title": "Component Title",
26 | "published": true,
27 | "createdAt": "YYYY-MM-DDT00:00:00Z",
28 | "updatedAt": "YYYY-MM-DDT00:00:00Z",
29 | "description": "A description of your component",
30 | "componentPath": "../screens/YourComponent",
31 | "hasDataFetch": false,
32 | "props": {
33 | // Default props for your component
34 | "propName": "propValue"
35 | },
36 | "tags": ["tag1", "tag2"],
37 | "author": {
38 | "name": "Your Name",
39 | "github": "yourgithubusername"
40 | },
41 | "version": "0.1.0",
42 | "category": "component-category"
43 | }
44 | }
45 | ```
46 |
47 | The component will automatically appear in the sidebar navigation and on the index page.
48 |
49 | ## Data Fetching
50 |
51 | If your component requires dynamic data, you can create a data fetch function:
52 |
53 | 1. Create a file in `app/data` (e.g., `app/data/your-component-data.ts`) that exports a named function:
54 |
55 | ```typescript
56 | export async function fetchYourComponentData() {
57 | // Fetch or generate your data here
58 | return {
59 | propName: "propValue",
60 | // Other props
61 | };
62 | }
63 |
64 | export default fetchYourComponentData;
65 | ```
66 |
67 | 2. Add the function to the `dataFetchFunctions` map in `utils/component-data.ts`:
68 |
69 | ```typescript
70 | const dataFetchFunctions: Record = {
71 | "tailwind-test": fetchTailwindTestData,
72 | "your-component-slug": fetchYourComponentData,
73 | // Add more data fetch functions here as needed
74 | };
75 | ```
76 |
77 | 3. Set `hasDataFetch` to `true` in your component's entry in `components.json`
78 |
79 | The recipes system will automatically fetch the data and pass it as props to your component.
80 |
81 | ## Bitmap Rendering
82 |
83 | The recipes system uses the `renderBmp` utility to convert components to bitmap images suitable for e-ink displays. The rendering process:
84 |
85 | 1. Uses Next.js's `ImageResponse` to render the component to a PNG
86 | 2. Converts the PNG to a 1-bit BMP using the `renderBmp` utility
87 | 3. Displays the BMP image alongside the direct component rendering
88 |
89 | This allows you to see exactly how your component will look on an e-ink display.
90 |
91 | ## Responsive Design and Tailwind Markers
92 |
93 | Recipe components support responsive Tailwind classes and special markers that are processed during rendering. This allows you to create layouts that adapt to different screen sizes and orientations.
94 |
95 | ### Responsive Breakpoints
96 |
97 | The rendering system supports standard Tailwind responsive breakpoints:
98 |
99 | - `sm:` - 640px and above
100 | - `md:` - 768px and above
101 | - `lg:` - 1024px and above
102 | - `xl:` - 1280px and above
103 | - `2xl:` - 1536px and above
104 |
105 | You can also use `max-` prefix for maximum width queries:
106 | - `max-sm:` - below 640px
107 | - `max-md:` - below 768px
108 | - `max-lg:` - below 1024px
109 | - `max-xl:` - below 1280px
110 | - `max-2xl:` - below 1536px
111 |
112 | **Example:**
113 | ```tsx
114 |
115 |
Responsive Text
116 |
Visible on medium screens and up
117 |
118 | ```
119 |
120 | ### Dither Patterns
121 |
122 | Special dither pattern classes are available for creating visual effects on e-ink displays:
123 | *Compatible only with Satori rendering.*
124 |
125 | ```tsx
126 |
127 | {/* Applies dither pattern for visual effect */}
128 |
129 | ```
130 |
131 | These patterns help create gradients and visual depth on 1-bit displays.
132 |
133 | ### Best Practices
134 |
135 | - Use responsive classes to adapt layouts for portrait vs landscape orientations
136 | - Test your components at different viewport sizes using the recipe preview
137 | - Combine responsive classes with conditional rendering for maximum flexibility
138 |
139 | ## Routing
140 |
141 | The recipes system uses Next.js's dynamic routing to provide two main views:
142 |
143 | - `/recipes` - Index view showing all recipes
144 | - `/recipes/[slug]` - Detailed view of a specific recipe
145 |
146 | The `generateStaticParams` function in `app/recipes/[slug]/page.tsx` ensures that all recipe pages are pre-rendered at build time.
--------------------------------------------------------------------------------
/components/recipes/screen-params-form.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { AlertCircle, Check } from "lucide-react";
4 | import type { ChangeEvent } from "react";
5 | import { useMemo, useState, useTransition } from "react";
6 | import { Button } from "@/components/ui/button";
7 | import { Input } from "@/components/ui/input";
8 | import { Label } from "@/components/ui/label";
9 | import type {
10 | RecipeParamDefinition,
11 | RecipeParamDefinitions,
12 | } from "@/lib/recipes/recipe-renderer";
13 |
14 | type Props = {
15 | slug: string;
16 | paramsSchema: RecipeParamDefinitions;
17 | initialValues: Record;
18 | updateAction: (
19 | slug: string,
20 | params: Record,
21 | definitions?: RecipeParamDefinitions,
22 | ) => Promise<{ success: boolean; error?: string }>;
23 | };
24 |
25 | type FormStatus = "idle" | "success" | "error";
26 |
27 | const buildInitialState = (
28 | schema: RecipeParamDefinitions,
29 | initialValues: Record,
30 | ) => {
31 | const state: Record = {};
32 | for (const [key, definition] of Object.entries(schema)) {
33 | const value = initialValues[key];
34 | if (value !== undefined && value !== null && value !== "") {
35 | state[key] = value;
36 | continue;
37 | }
38 |
39 | if (definition.default !== undefined) {
40 | state[key] = definition.default;
41 | } else {
42 | state[key] = "";
43 | }
44 | }
45 | return state;
46 | };
47 |
48 | const renderField = (
49 | key: string,
50 | definition: RecipeParamDefinition,
51 | value: unknown,
52 | onChange: (key: string, value: unknown) => void,
53 | ) => {
54 | const commonProps = {
55 | id: key,
56 | name: key,
57 | value: typeof value === "string" || typeof value === "number" ? value : "",
58 | className: "max-w-lg",
59 | onChange: (event: ChangeEvent) => {
60 | const nextValue =
61 | definition.type === "number"
62 | ? Number.isNaN(Number(event.target.value))
63 | ? ""
64 | : Number(event.target.value)
65 | : event.target.value;
66 | onChange(key, nextValue);
67 | },
68 | placeholder: definition.placeholder,
69 | };
70 |
71 | switch (definition.type) {
72 | case "number":
73 | return ;
74 | default:
75 | return ;
76 | }
77 | };
78 |
79 | export function ScreenParamsForm({
80 | slug,
81 | paramsSchema,
82 | initialValues,
83 | updateAction,
84 | }: Props) {
85 | const [formStatus, setFormStatus] = useState("idle");
86 | const [statusMessage, setStatusMessage] = useState("");
87 | const [isPending, startTransition] = useTransition();
88 | const [values, setValues] = useState>(() =>
89 | buildInitialState(paramsSchema, initialValues),
90 | );
91 |
92 | const hasParams = useMemo(
93 | () => Object.keys(paramsSchema || {}).length > 0,
94 | [paramsSchema],
95 | );
96 |
97 | const handleSubmit = (event: React.FormEvent) => {
98 | event.preventDefault();
99 | setFormStatus("idle");
100 | setStatusMessage("");
101 |
102 | startTransition(async () => {
103 | const result = await updateAction(slug, values, paramsSchema);
104 | if (!result.success) {
105 | setFormStatus("error");
106 | setStatusMessage(result.error ?? "Unable to save configuration");
107 | return;
108 | }
109 |
110 | setFormStatus("success");
111 | setStatusMessage("Configuration saved");
112 | });
113 | };
114 |
115 | if (!hasParams) return null;
116 |
117 | return (
118 |
119 |
120 |
Screen parameters
121 | {formStatus === "success" && (
122 |
123 |
124 | Saved
125 |
126 | )}
127 | {formStatus === "error" && (
128 |
129 |
130 | {statusMessage || "Error saving"}
131 |
132 | )}
133 |
134 |
135 |
167 |
168 | );
169 | }
170 |
--------------------------------------------------------------------------------