an app to share curated trails
sidetrail.app
1"use server";
2
3import "server-only";
4import { getDb, drafts, type DraftRecord } from "@/data/db";
5import { refresh, revalidatePath } from "next/cache";
6import { eq, and, sql } from "drizzle-orm";
7import { getCurrentDid } from "@/auth";
8import { generateTid } from "../tid";
9
10// ============================================================================
11// Types
12// ============================================================================
13
14export type DraftInput = {
15 title: string;
16 description: string;
17 stops: Array<{
18 tid: string;
19 title: string;
20 content: string;
21 buttonText?: string;
22 external?: {
23 uri: string;
24 title?: string;
25 description?: string;
26 thumb?: string;
27 };
28 }>;
29 accentColor: string;
30 backgroundColor: string;
31};
32
33export type SaveDraftResult = {
34 success: true;
35 version: number;
36 warning?: "overwrote_newer";
37};
38
39// ============================================================================
40// Auth Helper
41// ============================================================================
42
43async function requireAuth(): Promise<string> {
44 const did = await getCurrentDid();
45 if (!did) {
46 throw new Error("Authentication required");
47 }
48 return did;
49}
50
51// ============================================================================
52// Color Presets (same as client-side)
53// ============================================================================
54
55const COLOR_PRESETS = [
56 { accent: "#e8a87c", background: "#fdf8f3" },
57 { accent: "#c38d9e", background: "#fdf5f7" },
58 { accent: "#41b3a3", background: "#f0faf9" },
59 { accent: "#659dbd", background: "#f3f8fb" },
60 { accent: "#e27d60", background: "#fdf6f4" },
61 { accent: "#8d8741", background: "#f9f9f3" },
62 { accent: "#5d5c61", background: "#f5f5f6" },
63 { accent: "#7395ae", background: "#f4f7f9" },
64];
65
66function randomColorPreset() {
67 return COLOR_PRESETS[Math.floor(Math.random() * COLOR_PRESETS.length)];
68}
69
70// ============================================================================
71// Actions
72// ============================================================================
73
74export async function createDraft(): Promise<string> {
75 const did = await requireAuth();
76 const db = getDb();
77 const rkey = generateTid();
78 const id = `${did}:${rkey}`;
79 const now = new Date();
80 const colors = randomColorPreset();
81
82 const record: DraftRecord = {
83 $type: "app.sidetrail.draft",
84 title: "",
85 description: "",
86 stops: [
87 { tid: generateTid(), title: "", content: "" },
88 { tid: generateTid(), title: "", content: "" },
89 ],
90 accentColor: colors.accent,
91 backgroundColor: colors.background,
92 createdAt: now.toISOString(),
93 updatedAt: now.toISOString(),
94 };
95
96 await db.insert(drafts).values({
97 id,
98 authorDid: did,
99 rkey,
100 record,
101 createdAt: now,
102 updatedAt: now,
103 version: 1,
104 });
105
106 revalidatePath("/drafts");
107 return rkey;
108}
109
110export async function saveDraft(
111 rkey: string,
112 input: DraftInput,
113 expectedVersion?: number,
114): Promise<SaveDraftResult> {
115 const did = await requireAuth();
116 const db = getDb();
117 const id = `${did}:${rkey}`;
118 const now = new Date();
119
120 // Check for conflict if expectedVersion provided
121 let warning: "overwrote_newer" | undefined;
122 if (expectedVersion !== undefined) {
123 const [existing] = await db
124 .select({ version: drafts.version })
125 .from(drafts)
126 .where(eq(drafts.id, id))
127 .limit(1);
128
129 if (existing && existing.version > expectedVersion) {
130 warning = "overwrote_newer";
131 }
132 }
133
134 const record: DraftRecord = {
135 $type: "app.sidetrail.draft",
136 title: input.title,
137 description: input.description,
138 stops: input.stops,
139 accentColor: input.accentColor,
140 backgroundColor: input.backgroundColor,
141 createdAt: now.toISOString(),
142 updatedAt: now.toISOString(),
143 };
144
145 // Upsert the draft
146 const result = await db
147 .insert(drafts)
148 .values({
149 id,
150 authorDid: did,
151 rkey,
152 record,
153 createdAt: now,
154 updatedAt: now,
155 version: 1,
156 })
157 .onConflictDoUpdate({
158 target: drafts.id,
159 set: {
160 record,
161 updatedAt: now,
162 version: sql`${drafts.version} + 1`,
163 },
164 })
165 .returning({ version: drafts.version });
166
167 revalidatePath("/drafts");
168
169 return warning
170 ? { success: true, version: result[0].version, warning }
171 : { success: true, version: result[0].version };
172}
173
174export async function deleteDraft(rkey: string): Promise<void> {
175 const did = await requireAuth();
176 const db = getDb();
177
178 await db.delete(drafts).where(and(eq(drafts.authorDid, did), eq(drafts.rkey, rkey)));
179 refresh();
180}