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