detached 39c9c53

feat(convex): add user text preferences API and storage

commit
39c9c538278895a10f8e156b5e6ae66a1e6d27e0
author
bnema · · 2026-01-18 18:56 UTC
parent
1e38826
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +308 -19
M src/convex/_generated/api.d.ts +2 -0
@@ -15,6 +15,7 @@ import type * as messagesInternal from "../messagesInternal.js";
1515 import type * as mutations from "../mutations.js";
1616 import type * as queries from "../queries.js";
1717 import type * as sessionsInternal from "../sessionsInternal.js";
18+import type * as textPreferencesInternal from "../textPreferencesInternal.js";
1819 import type * as usersInternal from "../usersInternal.js";
1920
2021 import type {
@@ -31,6 +32,7 @@ declare const fullApi: ApiFromModules<{
3132 mutations: typeof mutations;
3233 queries: typeof queries;
3334 sessionsInternal: typeof sessionsInternal;
35+ textPreferencesInternal: typeof textPreferencesInternal;
3436 usersInternal: typeof usersInternal;
3537 }>;
3638
@@ -15,6 +15,7 @@ import type * as messagesInternal from "../messagesInternal.js";
15 import type * as mutations from "../mutations.js";15 import type * as mutations from "../mutations.js";
16 import type * as queries from "../queries.js";16 import type * as queries from "../queries.js";
17 import type * as sessionsInternal from "../sessionsInternal.js";17 import type * as sessionsInternal from "../sessionsInternal.js";
18+import type * as textPreferencesInternal from "../textPreferencesInternal.js";
18 import type * as usersInternal from "../usersInternal.js";19 import type * as usersInternal from "../usersInternal.js";
19 20
20 import type {21 import type {
@@ -31,6 +32,7 @@ declare const fullApi: ApiFromModules<{
31 mutations: typeof mutations;32 mutations: typeof mutations;
32 queries: typeof queries;33 queries: typeof queries;
33 sessionsInternal: typeof sessionsInternal;34 sessionsInternal: typeof sessionsInternal;
35+ textPreferencesInternal: typeof textPreferencesInternal;
34 usersInternal: typeof usersInternal;36 usersInternal: typeof usersInternal;
35 }>;37 }>;
36 38
M src/convex/http.ts +44 -0
@@ -275,4 +275,48 @@ http.route({
275275 })
276276 });
277277
278+// ============ TEXT PREFERENCES (server-side only) ============
279+
280+// Get user text preferences
281+http.route({
282+ path: '/api/textPreferences/get',
283+ method: 'POST',
284+ handler: httpAction(async (ctx, request) => {
285+ if (!validateSecret(request)) return unauthorizedResponse();
286+
287+ const { userId } = await request.json();
288+ const preferences = await ctx.runQuery(internal.textPreferencesInternal.getByUserId, {
289+ userId
290+ });
291+
292+ return new Response(JSON.stringify(preferences), {
293+ headers: { 'Content-Type': 'application/json' }
294+ });
295+ })
296+});
297+
298+// Save user text preferences
299+http.route({
300+ path: '/api/textPreferences/save',
301+ method: 'POST',
302+ handler: httpAction(async (ctx, request) => {
303+ if (!validateSecret(request)) return unauthorizedResponse();
304+
305+ const { userId, defaultStyle, allowFormatting, maxMessageLength } = await request.json();
306+ try {
307+ const result = await ctx.runMutation(internal.textPreferencesInternal.upsert, {
308+ userId,
309+ defaultStyle,
310+ allowFormatting,
311+ maxMessageLength
312+ });
313+ return new Response(JSON.stringify({ success: true, id: result }), {
314+ headers: { 'Content-Type': 'application/json' }
315+ });
316+ } catch (e) {
317+ return errorResponse(e instanceof Error ? e.message : 'Failed to save preferences');
318+ }
319+ })
320+});
321+
278322 export default http;
@@ -275,4 +275,48 @@ http.route({
275 })275 })
276 });276 });
277 277
278+// ============ TEXT PREFERENCES (server-side only) ============
279+
280+// Get user text preferences
281+http.route({
282+ path: '/api/textPreferences/get',
283+ method: 'POST',
284+ handler: httpAction(async (ctx, request) => {
285+ if (!validateSecret(request)) return unauthorizedResponse();
286+
287+ const { userId } = await request.json();
288+ const preferences = await ctx.runQuery(internal.textPreferencesInternal.getByUserId, {
289+ userId
290+ });
291+
292+ return new Response(JSON.stringify(preferences), {
293+ headers: { 'Content-Type': 'application/json' }
294+ });
295+ })
296+});
297+
298+// Save user text preferences
299+http.route({
300+ path: '/api/textPreferences/save',
301+ method: 'POST',
302+ handler: httpAction(async (ctx, request) => {
303+ if (!validateSecret(request)) return unauthorizedResponse();
304+
305+ const { userId, defaultStyle, allowFormatting, maxMessageLength } = await request.json();
306+ try {
307+ const result = await ctx.runMutation(internal.textPreferencesInternal.upsert, {
308+ userId,
309+ defaultStyle,
310+ allowFormatting,
311+ maxMessageLength
312+ });
313+ return new Response(JSON.stringify({ success: true, id: result }), {
314+ headers: { 'Content-Type': 'application/json' }
315+ });
316+ } catch (e) {
317+ return errorResponse(e instanceof Error ? e.message : 'Failed to save preferences');
318+ }
319+ })
320+});
321+
278 export default http;322 export default http;
M src/convex/schema.ts +12 -6
@@ -40,12 +40,18 @@ export default defineSchema({
4040
4141 userTextPreferences: defineTable({
4242 userId: v.id('users'),
43- defaultFontFamily: v.optional(v.string()),
44- defaultFontSize: v.optional(v.number()),
45- defaultColor: v.optional(v.string()),
46- allowFormatting: v.optional(v.boolean()),
47- maxMessageLength: v.optional(v.number()),
48- stylePresets: v.optional(v.string()),
43+ defaultStyle: v.object({
44+ fontFamily: v.string(),
45+ fontSize: v.number(),
46+ color: v.optional(v.string()),
47+ gradient: v.optional(v.array(v.string())),
48+ bold: v.boolean(),
49+ italic: v.boolean(),
50+ underline: v.boolean(),
51+ strikethrough: v.boolean()
52+ }),
53+ allowFormatting: v.boolean(),
54+ maxMessageLength: v.number(),
4955 createdAt: v.number(),
5056 updatedAt: v.number()
5157 }).index('by_userId', ['userId'])
@@ -40,12 +40,18 @@ export default defineSchema({
40 40
41 userTextPreferences: defineTable({41 userTextPreferences: defineTable({
42 userId: v.id('users'),42 userId: v.id('users'),
43- defaultFontFamily: v.optional(v.string()),43+ defaultStyle: v.object({
44- defaultFontSize: v.optional(v.number()),44+ fontFamily: v.string(),
45- defaultColor: v.optional(v.string()),45+ fontSize: v.number(),
46- allowFormatting: v.optional(v.boolean()),46+ color: v.optional(v.string()),
47- maxMessageLength: v.optional(v.number()),47+ gradient: v.optional(v.array(v.string())),
48- stylePresets: v.optional(v.string()),48+ bold: v.boolean(),
49+ italic: v.boolean(),
50+ underline: v.boolean(),
51+ strikethrough: v.boolean()
52+ }),
53+ allowFormatting: v.boolean(),
54+ maxMessageLength: v.number(),
49 createdAt: v.number(),55 createdAt: v.number(),
50 updatedAt: v.number()56 updatedAt: v.number()
51 }).index('by_userId', ['userId'])57 }).index('by_userId', ['userId'])
A src/convex/textPreferencesInternal.ts +63 -0
@@ -0,0 +1,63 @@
1+import { internalQuery, internalMutation } from './_generated/server';
2+import { v } from 'convex/values';
3+
4+// Get user text preferences by userId
5+export const getByUserId = internalQuery({
6+ args: { userId: v.id('users') },
7+ handler: async (ctx, { userId }) => {
8+ const preferences = await ctx.db
9+ .query('userTextPreferences')
10+ .withIndex('by_userId', (q) => q.eq('userId', userId))
11+ .first();
12+
13+ return preferences;
14+ }
15+});
16+
17+// Upsert user text preferences (create or update)
18+export const upsert = internalMutation({
19+ args: {
20+ userId: v.id('users'),
21+ defaultStyle: v.object({
22+ fontFamily: v.string(),
23+ fontSize: v.number(),
24+ color: v.optional(v.string()),
25+ gradient: v.optional(v.array(v.string())),
26+ bold: v.boolean(),
27+ italic: v.boolean(),
28+ underline: v.boolean(),
29+ strikethrough: v.boolean()
30+ }),
31+ allowFormatting: v.boolean(),
32+ maxMessageLength: v.number()
33+ },
34+ handler: async (ctx, args) => {
35+ const now = Date.now();
36+
37+ // Check if preferences already exist
38+ const existing = await ctx.db
39+ .query('userTextPreferences')
40+ .withIndex('by_userId', (q) => q.eq('userId', args.userId))
41+ .first();
42+
43+ if (existing) {
44+ // Update existing preferences
45+ return await ctx.db.patch(existing._id, {
46+ defaultStyle: args.defaultStyle,
47+ allowFormatting: args.allowFormatting,
48+ maxMessageLength: args.maxMessageLength,
49+ updatedAt: now
50+ });
51+ } else {
52+ // Create new preferences
53+ return await ctx.db.insert('userTextPreferences', {
54+ userId: args.userId,
55+ defaultStyle: args.defaultStyle,
56+ allowFormatting: args.allowFormatting,
57+ maxMessageLength: args.maxMessageLength,
58+ createdAt: now,
59+ updatedAt: now
60+ });
61+ }
62+ }
63+});
@@ -0,0 +1,63 @@
1+import { internalQuery, internalMutation } from './_generated/server';
2+import { v } from 'convex/values';
3+
4+// Get user text preferences by userId
5+export const getByUserId = internalQuery({
6+ args: { userId: v.id('users') },
7+ handler: async (ctx, { userId }) => {
8+ const preferences = await ctx.db
9+ .query('userTextPreferences')
10+ .withIndex('by_userId', (q) => q.eq('userId', userId))
11+ .first();
12+
13+ return preferences;
14+ }
15+});
16+
17+// Upsert user text preferences (create or update)
18+export const upsert = internalMutation({
19+ args: {
20+ userId: v.id('users'),
21+ defaultStyle: v.object({
22+ fontFamily: v.string(),
23+ fontSize: v.number(),
24+ color: v.optional(v.string()),
25+ gradient: v.optional(v.array(v.string())),
26+ bold: v.boolean(),
27+ italic: v.boolean(),
28+ underline: v.boolean(),
29+ strikethrough: v.boolean()
30+ }),
31+ allowFormatting: v.boolean(),
32+ maxMessageLength: v.number()
33+ },
34+ handler: async (ctx, args) => {
35+ const now = Date.now();
36+
37+ // Check if preferences already exist
38+ const existing = await ctx.db
39+ .query('userTextPreferences')
40+ .withIndex('by_userId', (q) => q.eq('userId', args.userId))
41+ .first();
42+
43+ if (existing) {
44+ // Update existing preferences
45+ return await ctx.db.patch(existing._id, {
46+ defaultStyle: args.defaultStyle,
47+ allowFormatting: args.allowFormatting,
48+ maxMessageLength: args.maxMessageLength,
49+ updatedAt: now
50+ });
51+ } else {
52+ // Create new preferences
53+ return await ctx.db.insert('userTextPreferences', {
54+ userId: args.userId,
55+ defaultStyle: args.defaultStyle,
56+ allowFormatting: args.allowFormatting,
57+ maxMessageLength: args.maxMessageLength,
58+ createdAt: now,
59+ updatedAt: now
60+ });
61+ }
62+ }
63+});
M src/lib/components/user-preferences-modal.svelte +11 -3
@@ -45,11 +45,19 @@
4545
4646 // Save preferences
4747 async function savePreferences() {
48- if (!onSave) return;
49-
5048 isSaving = true;
5149 try {
52- await onSave(preferences);
50+ const response = await fetch('/api/user/text-preferences', {
51+ method: 'POST',
52+ headers: { 'Content-Type': 'application/json' },
53+ body: JSON.stringify(preferences)
54+ });
55+
56+ if (!response.ok) {
57+ throw new Error('Failed to save preferences');
58+ }
59+
60+ await onSave?.(preferences);
5361 showModal = false;
5462 } catch (error) {
5563 console.error('Failed to save preferences:', error);
@@ -45,11 +45,19 @@
45 45
46 // Save preferences46 // Save preferences
47 async function savePreferences() {47 async function savePreferences() {
48- if (!onSave) return;
49-
50 isSaving = true;48 isSaving = true;
51 try {49 try {
52- await onSave(preferences);50+ const response = await fetch('/api/user/text-preferences', {
51+ method: 'POST',
52+ headers: { 'Content-Type': 'application/json' },
53+ body: JSON.stringify(preferences)
54+ });
55+
56+ if (!response.ok) {
57+ throw new Error('Failed to save preferences');
58+ }
59+
60+ await onSave?.(preferences);
53 showModal = false;61 showModal = false;
54 } catch (error) {62 } catch (error) {
55 console.error('Failed to save preferences:', error);63 console.error('Failed to save preferences:', error);
M src/lib/db/convex.server.ts +50 -1
@@ -144,4 +144,53 @@ export interface ConvexChatRoom {
144144 createdAt: number;
145145 }
146146
147-export default { users, sessions, messages, chatRooms };
147+export interface ConvexTextPreferences {
148+ _id: string;
149+ _creationTime: number;
150+ userId: string;
151+ defaultStyle: {
152+ fontFamily: string;
153+ fontSize: number;
154+ color?: string;
155+ gradient?: string[];
156+ bold: boolean;
157+ italic: boolean;
158+ underline: boolean;
159+ strikethrough: boolean;
160+ };
161+ allowFormatting: boolean;
162+ maxMessageLength: number;
163+ createdAt: number;
164+ updatedAt: number;
165+}
166+
167+// ============ TEXT PREFERENCES ============
168+
169+export const textPreferences = {
170+ get: (userId: string) =>
171+ convexFetch<ConvexTextPreferences | null>('/api/textPreferences/get', { userId }),
172+
173+ save: (
174+ userId: string,
175+ defaultStyle: {
176+ fontFamily: string;
177+ fontSize: number;
178+ color?: string;
179+ gradient?: string[];
180+ bold: boolean;
181+ italic: boolean;
182+ underline: boolean;
183+ strikethrough: boolean;
184+ },
185+ allowFormatting: boolean,
186+ maxMessageLength: number
187+ ) =>
188+ convexFetch<{ success: boolean; id: string }>('/api/textPreferences/save', {
189+ userId,
190+ defaultStyle,
191+ allowFormatting,
192+ maxMessageLength
193+ })
194+};
195+
196+export default { users, sessions, messages, chatRooms, textPreferences };
@@ -144,4 +144,53 @@ export interface ConvexChatRoom {
144 createdAt: number;144 createdAt: number;
145 }145 }
146 146
147-export default { users, sessions, messages, chatRooms };147+export interface ConvexTextPreferences {
148+ _id: string;
149+ _creationTime: number;
150+ userId: string;
151+ defaultStyle: {
152+ fontFamily: string;
153+ fontSize: number;
154+ color?: string;
155+ gradient?: string[];
156+ bold: boolean;
157+ italic: boolean;
158+ underline: boolean;
159+ strikethrough: boolean;
160+ };
161+ allowFormatting: boolean;
162+ maxMessageLength: number;
163+ createdAt: number;
164+ updatedAt: number;
165+}
166+
167+// ============ TEXT PREFERENCES ============
168+
169+export const textPreferences = {
170+ get: (userId: string) =>
171+ convexFetch<ConvexTextPreferences | null>('/api/textPreferences/get', { userId }),
172+
173+ save: (
174+ userId: string,
175+ defaultStyle: {
176+ fontFamily: string;
177+ fontSize: number;
178+ color?: string;
179+ gradient?: string[];
180+ bold: boolean;
181+ italic: boolean;
182+ underline: boolean;
183+ strikethrough: boolean;
184+ },
185+ allowFormatting: boolean,
186+ maxMessageLength: number
187+ ) =>
188+ convexFetch<{ success: boolean; id: string }>('/api/textPreferences/save', {
189+ userId,
190+ defaultStyle,
191+ allowFormatting,
192+ maxMessageLength
193+ })
194+};
195+
196+export default { users, sessions, messages, chatRooms, textPreferences };
M src/routes/+page.server.ts +23 -8
@@ -1,6 +1,12 @@
11 import type { PageServerLoad } from './$types';
22 import { validateSessionToken } from '$lib/api/session.server';
3-import { DEFAULT_TEXT_STYLE, type TextStyle } from '$lib/types/text-formatting';
3+import {
4+ DEFAULT_TEXT_STYLE,
5+ DEFAULT_USER_PREFERENCES,
6+ type TextStyle,
7+ type UserTextPreferences
8+} from '$lib/types/text-formatting';
9+import convex from '$lib/db/convex.server';
410
511 export const load: PageServerLoad = async ({ cookies }) => {
612 const token = cookies.get('session');
@@ -8,7 +14,8 @@ export const load: PageServerLoad = async ({ cookies }) => {
814 if (!token) {
915 return {
1016 user: null,
11- lastTextStyle: DEFAULT_TEXT_STYLE
17+ textStyle: DEFAULT_TEXT_STYLE,
18+ userPreferences: DEFAULT_USER_PREFERENCES
1219 };
1320 }
1421
@@ -18,23 +25,31 @@ export const load: PageServerLoad = async ({ cookies }) => {
1825 if (!user) {
1926 return {
2027 user: null,
21- lastTextStyle: DEFAULT_TEXT_STYLE
28+ textStyle: DEFAULT_TEXT_STYLE,
29+ userPreferences: DEFAULT_USER_PREFERENCES
2230 };
2331 }
2432
25- // For now, return default text style
26- // TODO: Fetch user's last text style from Convex if needed
27- const lastTextStyle: TextStyle = DEFAULT_TEXT_STYLE;
33+ // Fetch user's text preferences from Convex
34+ const prefs = await convex.textPreferences.get(user.id);
2835
2936 return {
3037 user,
31- lastTextStyle
38+ textStyle: (prefs?.defaultStyle as TextStyle) ?? DEFAULT_TEXT_STYLE,
39+ userPreferences: prefs
40+ ? {
41+ defaultStyle: prefs.defaultStyle as TextStyle,
42+ allowFormatting: prefs.allowFormatting,
43+ maxMessageLength: prefs.maxMessageLength
44+ }
45+ : DEFAULT_USER_PREFERENCES
3246 };
3347 } catch (error) {
3448 console.error('Error loading page data:', error);
3549 return {
3650 user: null,
37- lastTextStyle: DEFAULT_TEXT_STYLE
51+ textStyle: DEFAULT_TEXT_STYLE,
52+ userPreferences: DEFAULT_USER_PREFERENCES
3853 };
3954 }
4055 };
@@ -1,6 +1,12 @@
1 import type { PageServerLoad } from './$types';1 import type { PageServerLoad } from './$types';
2 import { validateSessionToken } from '$lib/api/session.server';2 import { validateSessionToken } from '$lib/api/session.server';
3-import { DEFAULT_TEXT_STYLE, type TextStyle } from '$lib/types/text-formatting';3+import {
4+ DEFAULT_TEXT_STYLE,
5+ DEFAULT_USER_PREFERENCES,
6+ type TextStyle,
7+ type UserTextPreferences
8+} from '$lib/types/text-formatting';
9+import convex from '$lib/db/convex.server';
4 10
5 export const load: PageServerLoad = async ({ cookies }) => {11 export const load: PageServerLoad = async ({ cookies }) => {
6 const token = cookies.get('session');12 const token = cookies.get('session');
@@ -8,7 +14,8 @@ export const load: PageServerLoad = async ({ cookies }) => {
8 if (!token) {14 if (!token) {
9 return {15 return {
10 user: null,16 user: null,
11- lastTextStyle: DEFAULT_TEXT_STYLE17+ textStyle: DEFAULT_TEXT_STYLE,
18+ userPreferences: DEFAULT_USER_PREFERENCES
12 };19 };
13 }20 }
14 21
@@ -18,23 +25,31 @@ export const load: PageServerLoad = async ({ cookies }) => {
18 if (!user) {25 if (!user) {
19 return {26 return {
20 user: null,27 user: null,
21- lastTextStyle: DEFAULT_TEXT_STYLE28+ textStyle: DEFAULT_TEXT_STYLE,
29+ userPreferences: DEFAULT_USER_PREFERENCES
22 };30 };
23 }31 }
24 32
25- // For now, return default text style33+ // Fetch user's text preferences from Convex
26- // TODO: Fetch user's last text style from Convex if needed34+ const prefs = await convex.textPreferences.get(user.id);
27- const lastTextStyle: TextStyle = DEFAULT_TEXT_STYLE;
28 35
29 return {36 return {
30 user,37 user,
31- lastTextStyle38+ textStyle: (prefs?.defaultStyle as TextStyle) ?? DEFAULT_TEXT_STYLE,
39+ userPreferences: prefs
40+ ? {
41+ defaultStyle: prefs.defaultStyle as TextStyle,
42+ allowFormatting: prefs.allowFormatting,
43+ maxMessageLength: prefs.maxMessageLength
44+ }
45+ : DEFAULT_USER_PREFERENCES
32 };46 };
33 } catch (error) {47 } catch (error) {
34 console.error('Error loading page data:', error);48 console.error('Error loading page data:', error);
35 return {49 return {
36 user: null,50 user: null,
37- lastTextStyle: DEFAULT_TEXT_STYLE51+ textStyle: DEFAULT_TEXT_STYLE,
52+ userPreferences: DEFAULT_USER_PREFERENCES
38 };53 };
39 }54 }
40 };55 };
M src/routes/+page.svelte +1 -1
@@ -18,7 +18,7 @@
1818 <DesktopIcons bind:showChatRoom bind:showAuth />
1919
2020 {#if showChatRoom}
21- <ChatRoom bind:showChatRoom initialTextStyle={data.lastTextStyle} />
21+ <ChatRoom bind:showChatRoom initialTextStyle={data.textStyle} />
2222 {/if}
2323
2424 {#if showAuth}
@@ -18,7 +18,7 @@
18 <DesktopIcons bind:showChatRoom bind:showAuth />18 <DesktopIcons bind:showChatRoom bind:showAuth />
19 19
20 {#if showChatRoom}20 {#if showChatRoom}
21- <ChatRoom bind:showChatRoom initialTextStyle={data.lastTextStyle} />21+ <ChatRoom bind:showChatRoom initialTextStyle={data.textStyle} />
22 {/if}22 {/if}
23 23
24 {#if showAuth}24 {#if showAuth}
A src/routes/api/user/text-preferences/+server.ts +102 -0
@@ -0,0 +1,102 @@
1+import { json, error } from '@sveltejs/kit';
2+import type { RequestHandler } from './$types';
3+import convex from '$lib/db/convex.server';
4+import { DEFAULT_USER_PREFERENCES, type UserTextPreferences } from '$lib/types/text-formatting';
5+import { validateSessionToken } from '$lib/api/session.server';
6+
7+// GET - Fetch current user's text preferences
8+export const GET: RequestHandler = async ({ cookies }) => {
9+ try {
10+ // Get session token from cookie
11+ const sessionToken = cookies.get('pdraim_session');
12+ if (!sessionToken) {
13+ throw error(401, 'No session found');
14+ }
15+
16+ // Validate session token
17+ const { user } = await validateSessionToken(sessionToken);
18+
19+ if (!user) {
20+ throw error(401, 'Invalid session');
21+ }
22+
23+ // Get user's text preferences
24+ const preferences = await convex.textPreferences.get(user.id);
25+
26+ if (!preferences) {
27+ // Return defaults if no preferences exist
28+ return json({
29+ success: true,
30+ preferences: DEFAULT_USER_PREFERENCES
31+ });
32+ }
33+
34+ // Convert Convex format to frontend format
35+ const frontendPreferences: UserTextPreferences = {
36+ defaultStyle: preferences.defaultStyle as UserTextPreferences['defaultStyle'],
37+ allowFormatting: preferences.allowFormatting,
38+ maxMessageLength: preferences.maxMessageLength
39+ };
40+
41+ return json({
42+ success: true,
43+ preferences: frontendPreferences
44+ });
45+ } catch (err) {
46+ console.error('Failed to fetch text preferences:', err);
47+ if (err instanceof Response) {
48+ throw err;
49+ }
50+ throw error(500, 'Failed to fetch preferences');
51+ }
52+};
53+
54+// POST - Save current user's text preferences
55+export const POST: RequestHandler = async ({ cookies, request }) => {
56+ try {
57+ // Get session token from cookie
58+ const sessionToken = cookies.get('pdraim_session');
59+ if (!sessionToken) {
60+ throw error(401, 'No session found');
61+ }
62+
63+ // Validate session token
64+ const { user } = await validateSessionToken(sessionToken);
65+
66+ if (!user) {
67+ throw error(401, 'Invalid session');
68+ }
69+
70+ // Parse request body
71+ const body = await request.json();
72+ const { defaultStyle, allowFormatting, maxMessageLength } = body as UserTextPreferences;
73+
74+ // Validate required fields
75+ if (
76+ !defaultStyle ||
77+ typeof allowFormatting !== 'boolean' ||
78+ typeof maxMessageLength !== 'number'
79+ ) {
80+ throw error(400, 'Invalid preferences data');
81+ }
82+
83+ // Save preferences via Convex
84+ const result = await convex.textPreferences.save(
85+ user.id,
86+ defaultStyle,
87+ allowFormatting,
88+ maxMessageLength
89+ );
90+
91+ return json({
92+ success: true,
93+ id: result.id
94+ });
95+ } catch (err) {
96+ console.error('Failed to save text preferences:', err);
97+ if (err instanceof Response) {
98+ throw err;
99+ }
100+ throw error(500, 'Failed to save preferences');
101+ }
102+};
@@ -0,0 +1,102 @@
1+import { json, error } from '@sveltejs/kit';
2+import type { RequestHandler } from './$types';
3+import convex from '$lib/db/convex.server';
4+import { DEFAULT_USER_PREFERENCES, type UserTextPreferences } from '$lib/types/text-formatting';
5+import { validateSessionToken } from '$lib/api/session.server';
6+
7+// GET - Fetch current user's text preferences
8+export const GET: RequestHandler = async ({ cookies }) => {
9+ try {
10+ // Get session token from cookie
11+ const sessionToken = cookies.get('pdraim_session');
12+ if (!sessionToken) {
13+ throw error(401, 'No session found');
14+ }
15+
16+ // Validate session token
17+ const { user } = await validateSessionToken(sessionToken);
18+
19+ if (!user) {
20+ throw error(401, 'Invalid session');
21+ }
22+
23+ // Get user's text preferences
24+ const preferences = await convex.textPreferences.get(user.id);
25+
26+ if (!preferences) {
27+ // Return defaults if no preferences exist
28+ return json({
29+ success: true,
30+ preferences: DEFAULT_USER_PREFERENCES
31+ });
32+ }
33+
34+ // Convert Convex format to frontend format
35+ const frontendPreferences: UserTextPreferences = {
36+ defaultStyle: preferences.defaultStyle as UserTextPreferences['defaultStyle'],
37+ allowFormatting: preferences.allowFormatting,
38+ maxMessageLength: preferences.maxMessageLength
39+ };
40+
41+ return json({
42+ success: true,
43+ preferences: frontendPreferences
44+ });
45+ } catch (err) {
46+ console.error('Failed to fetch text preferences:', err);
47+ if (err instanceof Response) {
48+ throw err;
49+ }
50+ throw error(500, 'Failed to fetch preferences');
51+ }
52+};
53+
54+// POST - Save current user's text preferences
55+export const POST: RequestHandler = async ({ cookies, request }) => {
56+ try {
57+ // Get session token from cookie
58+ const sessionToken = cookies.get('pdraim_session');
59+ if (!sessionToken) {
60+ throw error(401, 'No session found');
61+ }
62+
63+ // Validate session token
64+ const { user } = await validateSessionToken(sessionToken);
65+
66+ if (!user) {
67+ throw error(401, 'Invalid session');
68+ }
69+
70+ // Parse request body
71+ const body = await request.json();
72+ const { defaultStyle, allowFormatting, maxMessageLength } = body as UserTextPreferences;
73+
74+ // Validate required fields
75+ if (
76+ !defaultStyle ||
77+ typeof allowFormatting !== 'boolean' ||
78+ typeof maxMessageLength !== 'number'
79+ ) {
80+ throw error(400, 'Invalid preferences data');
81+ }
82+
83+ // Save preferences via Convex
84+ const result = await convex.textPreferences.save(
85+ user.id,
86+ defaultStyle,
87+ allowFormatting,
88+ maxMessageLength
89+ );
90+
91+ return json({
92+ success: true,
93+ id: result.id
94+ });
95+ } catch (err) {
96+ console.error('Failed to save text preferences:', err);
97+ if (err instanceof Response) {
98+ throw err;
99+ }
100+ throw error(500, 'Failed to save preferences');
101+ }
102+};

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 3

Tags 8

↑↓ moveenter openesc close