detached 35d8b67

feat: initial auth setup and db configuration

- Add 'docs/' to .gitignore
- Add database dependencies and scripts
- Update devDependencies with necessary packages
- Add initial User interface with auth fields
- Create basic AimLogin component
- Update chat room styling and types
commit
35d8b67fc4723e3578a46322f5a82631cba69965
author
Kit · · 2025-02-10 18:02 UTC
parent
2c4b9db
signature
SSH UnverifiedSHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6Tk The key is unknown
25 files · +2559 -78
M .gitignore +2 -0
@@ -21,3 +21,5 @@ Thumbs.db
2121 # Vite
2222 vite.config.js.timestamp-*
2323 vite.config.ts.timestamp-*
24+
25+docs/⏎̸
@@ -21,3 +21,5 @@ Thumbs.db
21 # Vite21 # Vite
22 vite.config.js.timestamp-*22 vite.config.js.timestamp-*
23 vite.config.ts.timestamp-*23 vite.config.ts.timestamp-*
24+
25+docs/⏎̸
A drizzle.config.ts +12 -0
@@ -0,0 +1,12 @@
1+import 'dotenv/config';
2+import { defineConfig } from 'drizzle-kit';
3+
4+export default defineConfig({
5+ out: './drizzle',
6+ schema: './src/lib/db/schema.ts',
7+ dialect: 'turso',
8+ dbCredentials: {
9+ url: process.env.TURSO_DATABASE_URL as string,
10+ authToken: process.env.TURSO_AUTH_TOKEN as string,
11+ },
12+});⏎̸
@@ -0,0 +1,12 @@
1+import 'dotenv/config';
2+import { defineConfig } from 'drizzle-kit';
3+
4+export default defineConfig({
5+ out: './drizzle',
6+ schema: './src/lib/db/schema.ts',
7+ dialect: 'turso',
8+ dbCredentials: {
9+ url: process.env.TURSO_DATABASE_URL as string,
10+ authToken: process.env.TURSO_AUTH_TOKEN as string,
11+ },
12+});⏎̸
A drizzle/0000_shallow_ghost_rider.sql +33 -0
@@ -0,0 +1,33 @@
1+CREATE TABLE `chat_rooms` (
2+ `id` text PRIMARY KEY NOT NULL,
3+ `name` text,
4+ `type` text DEFAULT 'direct' NOT NULL,
5+ `created_at` integer NOT NULL
6+);
7+--> statement-breakpoint
8+CREATE TABLE `messages` (
9+ `id` text PRIMARY KEY NOT NULL,
10+ `chat_room_id` text NOT NULL,
11+ `sender_id` text NOT NULL,
12+ `content` text NOT NULL,
13+ `type` text DEFAULT 'chat' NOT NULL,
14+ `timestamp` integer NOT NULL,
15+ FOREIGN KEY (`chat_room_id`) REFERENCES `chat_rooms`(`id`) ON UPDATE no action ON DELETE no action,
16+ FOREIGN KEY (`sender_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
17+);
18+--> statement-breakpoint
19+CREATE TABLE `users` (
20+ `id` text PRIMARY KEY NOT NULL,
21+ `email` text NOT NULL,
22+ `password` text NOT NULL,
23+ `nickname` text NOT NULL,
24+ `status` text DEFAULT 'offline',
25+ `avatar_url` text,
26+ `created_at` integer NOT NULL,
27+ `last_seen` integer
28+);
29+--> statement-breakpoint
30+CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);--> statement-breakpoint
31+CREATE VIEW `chat_room_view` AS select "id", "name", "type", "created_at" from "chat_rooms";--> statement-breakpoint
32+CREATE VIEW `message_view` AS select "messages"."id", "messages"."content", "messages"."type", "messages"."timestamp", "users"."nickname", "chat_rooms"."name" from "messages" left join "users" on "messages"."sender_id" = "users"."id" left join "chat_rooms" on "messages"."chat_room_id" = "chat_rooms"."id";--> statement-breakpoint
33+CREATE VIEW `user_view` AS select "id", "nickname", "status", "avatar_url", "last_seen" from "users";⏎̸
@@ -0,0 +1,33 @@
1+CREATE TABLE `chat_rooms` (
2+ `id` text PRIMARY KEY NOT NULL,
3+ `name` text,
4+ `type` text DEFAULT 'direct' NOT NULL,
5+ `created_at` integer NOT NULL
6+);
7+--> statement-breakpoint
8+CREATE TABLE `messages` (
9+ `id` text PRIMARY KEY NOT NULL,
10+ `chat_room_id` text NOT NULL,
11+ `sender_id` text NOT NULL,
12+ `content` text NOT NULL,
13+ `type` text DEFAULT 'chat' NOT NULL,
14+ `timestamp` integer NOT NULL,
15+ FOREIGN KEY (`chat_room_id`) REFERENCES `chat_rooms`(`id`) ON UPDATE no action ON DELETE no action,
16+ FOREIGN KEY (`sender_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE no action
17+);
18+--> statement-breakpoint
19+CREATE TABLE `users` (
20+ `id` text PRIMARY KEY NOT NULL,
21+ `email` text NOT NULL,
22+ `password` text NOT NULL,
23+ `nickname` text NOT NULL,
24+ `status` text DEFAULT 'offline',
25+ `avatar_url` text,
26+ `created_at` integer NOT NULL,
27+ `last_seen` integer
28+);
29+--> statement-breakpoint
30+CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`);--> statement-breakpoint
31+CREATE VIEW `chat_room_view` AS select "id", "name", "type", "created_at" from "chat_rooms";--> statement-breakpoint
32+CREATE VIEW `message_view` AS select "messages"."id", "messages"."content", "messages"."type", "messages"."timestamp", "users"."nickname", "chat_rooms"."name" from "messages" left join "users" on "messages"."sender_id" = "users"."id" left join "chat_rooms" on "messages"."chat_room_id" = "chat_rooms"."id";--> statement-breakpoint
33+CREATE VIEW `user_view` AS select "id", "nickname", "status", "avatar_url", "last_seen" from "users";⏎̸
A drizzle/meta/0000_snapshot.json +342 -0
@@ -0,0 +1,342 @@
1+{
2+ "version": "6",
3+ "dialect": "sqlite",
4+ "id": "16b13426-2b9d-4084-8e17-71f9de0f72d0",
5+ "prevId": "00000000-0000-0000-0000-000000000000",
6+ "tables": {
7+ "chat_rooms": {
8+ "name": "chat_rooms",
9+ "columns": {
10+ "id": {
11+ "name": "id",
12+ "type": "text",
13+ "primaryKey": true,
14+ "notNull": true,
15+ "autoincrement": false
16+ },
17+ "name": {
18+ "name": "name",
19+ "type": "text",
20+ "primaryKey": false,
21+ "notNull": false,
22+ "autoincrement": false
23+ },
24+ "type": {
25+ "name": "type",
26+ "type": "text",
27+ "primaryKey": false,
28+ "notNull": true,
29+ "autoincrement": false,
30+ "default": "'direct'"
31+ },
32+ "created_at": {
33+ "name": "created_at",
34+ "type": "integer",
35+ "primaryKey": false,
36+ "notNull": true,
37+ "autoincrement": false
38+ }
39+ },
40+ "indexes": {},
41+ "foreignKeys": {},
42+ "compositePrimaryKeys": {},
43+ "uniqueConstraints": {},
44+ "checkConstraints": {}
45+ },
46+ "messages": {
47+ "name": "messages",
48+ "columns": {
49+ "id": {
50+ "name": "id",
51+ "type": "text",
52+ "primaryKey": true,
53+ "notNull": true,
54+ "autoincrement": false
55+ },
56+ "chat_room_id": {
57+ "name": "chat_room_id",
58+ "type": "text",
59+ "primaryKey": false,
60+ "notNull": true,
61+ "autoincrement": false
62+ },
63+ "sender_id": {
64+ "name": "sender_id",
65+ "type": "text",
66+ "primaryKey": false,
67+ "notNull": true,
68+ "autoincrement": false
69+ },
70+ "content": {
71+ "name": "content",
72+ "type": "text",
73+ "primaryKey": false,
74+ "notNull": true,
75+ "autoincrement": false
76+ },
77+ "type": {
78+ "name": "type",
79+ "type": "text",
80+ "primaryKey": false,
81+ "notNull": true,
82+ "autoincrement": false,
83+ "default": "'chat'"
84+ },
85+ "timestamp": {
86+ "name": "timestamp",
87+ "type": "integer",
88+ "primaryKey": false,
89+ "notNull": true,
90+ "autoincrement": false
91+ }
92+ },
93+ "indexes": {},
94+ "foreignKeys": {
95+ "messages_chat_room_id_chat_rooms_id_fk": {
96+ "name": "messages_chat_room_id_chat_rooms_id_fk",
97+ "tableFrom": "messages",
98+ "tableTo": "chat_rooms",
99+ "columnsFrom": [
100+ "chat_room_id"
101+ ],
102+ "columnsTo": [
103+ "id"
104+ ],
105+ "onDelete": "no action",
106+ "onUpdate": "no action"
107+ },
108+ "messages_sender_id_users_id_fk": {
109+ "name": "messages_sender_id_users_id_fk",
110+ "tableFrom": "messages",
111+ "tableTo": "users",
112+ "columnsFrom": [
113+ "sender_id"
114+ ],
115+ "columnsTo": [
116+ "id"
117+ ],
118+ "onDelete": "no action",
119+ "onUpdate": "no action"
120+ }
121+ },
122+ "compositePrimaryKeys": {},
123+ "uniqueConstraints": {},
124+ "checkConstraints": {}
125+ },
126+ "users": {
127+ "name": "users",
128+ "columns": {
129+ "id": {
130+ "name": "id",
131+ "type": "text",
132+ "primaryKey": true,
133+ "notNull": true,
134+ "autoincrement": false
135+ },
136+ "email": {
137+ "name": "email",
138+ "type": "text",
139+ "primaryKey": false,
140+ "notNull": true,
141+ "autoincrement": false
142+ },
143+ "password": {
144+ "name": "password",
145+ "type": "text",
146+ "primaryKey": false,
147+ "notNull": true,
148+ "autoincrement": false
149+ },
150+ "nickname": {
151+ "name": "nickname",
152+ "type": "text",
153+ "primaryKey": false,
154+ "notNull": true,
155+ "autoincrement": false
156+ },
157+ "status": {
158+ "name": "status",
159+ "type": "text",
160+ "primaryKey": false,
161+ "notNull": false,
162+ "autoincrement": false,
163+ "default": "'offline'"
164+ },
165+ "avatar_url": {
166+ "name": "avatar_url",
167+ "type": "text",
168+ "primaryKey": false,
169+ "notNull": false,
170+ "autoincrement": false
171+ },
172+ "created_at": {
173+ "name": "created_at",
174+ "type": "integer",
175+ "primaryKey": false,
176+ "notNull": true,
177+ "autoincrement": false
178+ },
179+ "last_seen": {
180+ "name": "last_seen",
181+ "type": "integer",
182+ "primaryKey": false,
183+ "notNull": false,
184+ "autoincrement": false
185+ }
186+ },
187+ "indexes": {
188+ "users_email_unique": {
189+ "name": "users_email_unique",
190+ "columns": [
191+ "email"
192+ ],
193+ "isUnique": true
194+ }
195+ },
196+ "foreignKeys": {},
197+ "compositePrimaryKeys": {},
198+ "uniqueConstraints": {},
199+ "checkConstraints": {}
200+ }
201+ },
202+ "views": {
203+ "chat_room_view": {
204+ "columns": {
205+ "id": {
206+ "name": "id",
207+ "type": "text",
208+ "primaryKey": true,
209+ "notNull": true,
210+ "autoincrement": false
211+ },
212+ "name": {
213+ "name": "name",
214+ "type": "text",
215+ "primaryKey": false,
216+ "notNull": false,
217+ "autoincrement": false
218+ },
219+ "type": {
220+ "name": "type",
221+ "type": "text",
222+ "primaryKey": false,
223+ "notNull": true,
224+ "autoincrement": false,
225+ "default": "'direct'"
226+ },
227+ "created_at": {
228+ "name": "created_at",
229+ "type": "integer",
230+ "primaryKey": false,
231+ "notNull": true,
232+ "autoincrement": false
233+ }
234+ },
235+ "name": "chat_room_view",
236+ "isExisting": false,
237+ "definition": "select \"id\", \"name\", \"type\", \"created_at\" from \"chat_rooms\""
238+ },
239+ "message_view": {
240+ "columns": {
241+ "id": {
242+ "name": "id",
243+ "type": "text",
244+ "primaryKey": true,
245+ "notNull": true,
246+ "autoincrement": false
247+ },
248+ "content": {
249+ "name": "content",
250+ "type": "text",
251+ "primaryKey": false,
252+ "notNull": true,
253+ "autoincrement": false
254+ },
255+ "type": {
256+ "name": "type",
257+ "type": "text",
258+ "primaryKey": false,
259+ "notNull": true,
260+ "autoincrement": false,
261+ "default": "'chat'"
262+ },
263+ "timestamp": {
264+ "name": "timestamp",
265+ "type": "integer",
266+ "primaryKey": false,
267+ "notNull": true,
268+ "autoincrement": false
269+ },
270+ "nickname": {
271+ "name": "nickname",
272+ "type": "text",
273+ "primaryKey": false,
274+ "notNull": true,
275+ "autoincrement": false
276+ },
277+ "name": {
278+ "name": "name",
279+ "type": "text",
280+ "primaryKey": false,
281+ "notNull": false,
282+ "autoincrement": false
283+ }
284+ },
285+ "name": "message_view",
286+ "isExisting": false,
287+ "definition": "select \"messages\".\"id\", \"messages\".\"content\", \"messages\".\"type\", \"messages\".\"timestamp\", \"users\".\"nickname\", \"chat_rooms\".\"name\" from \"messages\" left join \"users\" on \"messages\".\"sender_id\" = \"users\".\"id\" left join \"chat_rooms\" on \"messages\".\"chat_room_id\" = \"chat_rooms\".\"id\""
288+ },
289+ "user_view": {
290+ "columns": {
291+ "id": {
292+ "name": "id",
293+ "type": "text",
294+ "primaryKey": true,
295+ "notNull": true,
296+ "autoincrement": false
297+ },
298+ "nickname": {
299+ "name": "nickname",
300+ "type": "text",
301+ "primaryKey": false,
302+ "notNull": true,
303+ "autoincrement": false
304+ },
305+ "status": {
306+ "name": "status",
307+ "type": "text",
308+ "primaryKey": false,
309+ "notNull": false,
310+ "autoincrement": false,
311+ "default": "'offline'"
312+ },
313+ "avatar_url": {
314+ "name": "avatar_url",
315+ "type": "text",
316+ "primaryKey": false,
317+ "notNull": false,
318+ "autoincrement": false
319+ },
320+ "last_seen": {
321+ "name": "last_seen",
322+ "type": "integer",
323+ "primaryKey": false,
324+ "notNull": false,
325+ "autoincrement": false
326+ }
327+ },
328+ "name": "user_view",
329+ "isExisting": false,
330+ "definition": "select \"id\", \"nickname\", \"status\", \"avatar_url\", \"last_seen\" from \"users\""
331+ }
332+ },
333+ "enums": {},
334+ "_meta": {
335+ "schemas": {},
336+ "tables": {},
337+ "columns": {}
338+ },
339+ "internal": {
340+ "indexes": {}
341+ }
342+}⏎̸
@@ -0,0 +1,342 @@
1+{
2+ "version": "6",
3+ "dialect": "sqlite",
4+ "id": "16b13426-2b9d-4084-8e17-71f9de0f72d0",
5+ "prevId": "00000000-0000-0000-0000-000000000000",
6+ "tables": {
7+ "chat_rooms": {
8+ "name": "chat_rooms",
9+ "columns": {
10+ "id": {
11+ "name": "id",
12+ "type": "text",
13+ "primaryKey": true,
14+ "notNull": true,
15+ "autoincrement": false
16+ },
17+ "name": {
18+ "name": "name",
19+ "type": "text",
20+ "primaryKey": false,
21+ "notNull": false,
22+ "autoincrement": false
23+ },
24+ "type": {
25+ "name": "type",
26+ "type": "text",
27+ "primaryKey": false,
28+ "notNull": true,
29+ "autoincrement": false,
30+ "default": "'direct'"
31+ },
32+ "created_at": {
33+ "name": "created_at",
34+ "type": "integer",
35+ "primaryKey": false,
36+ "notNull": true,
37+ "autoincrement": false
38+ }
39+ },
40+ "indexes": {},
41+ "foreignKeys": {},
42+ "compositePrimaryKeys": {},
43+ "uniqueConstraints": {},
44+ "checkConstraints": {}
45+ },
46+ "messages": {
47+ "name": "messages",
48+ "columns": {
49+ "id": {
50+ "name": "id",
51+ "type": "text",
52+ "primaryKey": true,
53+ "notNull": true,
54+ "autoincrement": false
55+ },
56+ "chat_room_id": {
57+ "name": "chat_room_id",
58+ "type": "text",
59+ "primaryKey": false,
60+ "notNull": true,
61+ "autoincrement": false
62+ },
63+ "sender_id": {
64+ "name": "sender_id",
65+ "type": "text",
66+ "primaryKey": false,
67+ "notNull": true,
68+ "autoincrement": false
69+ },
70+ "content": {
71+ "name": "content",
72+ "type": "text",
73+ "primaryKey": false,
74+ "notNull": true,
75+ "autoincrement": false
76+ },
77+ "type": {
78+ "name": "type",
79+ "type": "text",
80+ "primaryKey": false,
81+ "notNull": true,
82+ "autoincrement": false,
83+ "default": "'chat'"
84+ },
85+ "timestamp": {
86+ "name": "timestamp",
87+ "type": "integer",
88+ "primaryKey": false,
89+ "notNull": true,
90+ "autoincrement": false
91+ }
92+ },
93+ "indexes": {},
94+ "foreignKeys": {
95+ "messages_chat_room_id_chat_rooms_id_fk": {
96+ "name": "messages_chat_room_id_chat_rooms_id_fk",
97+ "tableFrom": "messages",
98+ "tableTo": "chat_rooms",
99+ "columnsFrom": [
100+ "chat_room_id"
101+ ],
102+ "columnsTo": [
103+ "id"
104+ ],
105+ "onDelete": "no action",
106+ "onUpdate": "no action"
107+ },
108+ "messages_sender_id_users_id_fk": {
109+ "name": "messages_sender_id_users_id_fk",
110+ "tableFrom": "messages",
111+ "tableTo": "users",
112+ "columnsFrom": [
113+ "sender_id"
114+ ],
115+ "columnsTo": [
116+ "id"
117+ ],
118+ "onDelete": "no action",
119+ "onUpdate": "no action"
120+ }
121+ },
122+ "compositePrimaryKeys": {},
123+ "uniqueConstraints": {},
124+ "checkConstraints": {}
125+ },
126+ "users": {
127+ "name": "users",
128+ "columns": {
129+ "id": {
130+ "name": "id",
131+ "type": "text",
132+ "primaryKey": true,
133+ "notNull": true,
134+ "autoincrement": false
135+ },
136+ "email": {
137+ "name": "email",
138+ "type": "text",
139+ "primaryKey": false,
140+ "notNull": true,
141+ "autoincrement": false
142+ },
143+ "password": {
144+ "name": "password",
145+ "type": "text",
146+ "primaryKey": false,
147+ "notNull": true,
148+ "autoincrement": false
149+ },
150+ "nickname": {
151+ "name": "nickname",
152+ "type": "text",
153+ "primaryKey": false,
154+ "notNull": true,
155+ "autoincrement": false
156+ },
157+ "status": {
158+ "name": "status",
159+ "type": "text",
160+ "primaryKey": false,
161+ "notNull": false,
162+ "autoincrement": false,
163+ "default": "'offline'"
164+ },
165+ "avatar_url": {
166+ "name": "avatar_url",
167+ "type": "text",
168+ "primaryKey": false,
169+ "notNull": false,
170+ "autoincrement": false
171+ },
172+ "created_at": {
173+ "name": "created_at",
174+ "type": "integer",
175+ "primaryKey": false,
176+ "notNull": true,
177+ "autoincrement": false
178+ },
179+ "last_seen": {
180+ "name": "last_seen",
181+ "type": "integer",
182+ "primaryKey": false,
183+ "notNull": false,
184+ "autoincrement": false
185+ }
186+ },
187+ "indexes": {
188+ "users_email_unique": {
189+ "name": "users_email_unique",
190+ "columns": [
191+ "email"
192+ ],
193+ "isUnique": true
194+ }
195+ },
196+ "foreignKeys": {},
197+ "compositePrimaryKeys": {},
198+ "uniqueConstraints": {},
199+ "checkConstraints": {}
200+ }
201+ },
202+ "views": {
203+ "chat_room_view": {
204+ "columns": {
205+ "id": {
206+ "name": "id",
207+ "type": "text",
208+ "primaryKey": true,
209+ "notNull": true,
210+ "autoincrement": false
211+ },
212+ "name": {
213+ "name": "name",
214+ "type": "text",
215+ "primaryKey": false,
216+ "notNull": false,
217+ "autoincrement": false
218+ },
219+ "type": {
220+ "name": "type",
221+ "type": "text",
222+ "primaryKey": false,
223+ "notNull": true,
224+ "autoincrement": false,
225+ "default": "'direct'"
226+ },
227+ "created_at": {
228+ "name": "created_at",
229+ "type": "integer",
230+ "primaryKey": false,
231+ "notNull": true,
232+ "autoincrement": false
233+ }
234+ },
235+ "name": "chat_room_view",
236+ "isExisting": false,
237+ "definition": "select \"id\", \"name\", \"type\", \"created_at\" from \"chat_rooms\""
238+ },
239+ "message_view": {
240+ "columns": {
241+ "id": {
242+ "name": "id",
243+ "type": "text",
244+ "primaryKey": true,
245+ "notNull": true,
246+ "autoincrement": false
247+ },
248+ "content": {
249+ "name": "content",
250+ "type": "text",
251+ "primaryKey": false,
252+ "notNull": true,
253+ "autoincrement": false
254+ },
255+ "type": {
256+ "name": "type",
257+ "type": "text",
258+ "primaryKey": false,
259+ "notNull": true,
260+ "autoincrement": false,
261+ "default": "'chat'"
262+ },
263+ "timestamp": {
264+ "name": "timestamp",
265+ "type": "integer",
266+ "primaryKey": false,
267+ "notNull": true,
268+ "autoincrement": false
269+ },
270+ "nickname": {
271+ "name": "nickname",
272+ "type": "text",
273+ "primaryKey": false,
274+ "notNull": true,
275+ "autoincrement": false
276+ },
277+ "name": {
278+ "name": "name",
279+ "type": "text",
280+ "primaryKey": false,
281+ "notNull": false,
282+ "autoincrement": false
283+ }
284+ },
285+ "name": "message_view",
286+ "isExisting": false,
287+ "definition": "select \"messages\".\"id\", \"messages\".\"content\", \"messages\".\"type\", \"messages\".\"timestamp\", \"users\".\"nickname\", \"chat_rooms\".\"name\" from \"messages\" left join \"users\" on \"messages\".\"sender_id\" = \"users\".\"id\" left join \"chat_rooms\" on \"messages\".\"chat_room_id\" = \"chat_rooms\".\"id\""
288+ },
289+ "user_view": {
290+ "columns": {
291+ "id": {
292+ "name": "id",
293+ "type": "text",
294+ "primaryKey": true,
295+ "notNull": true,
296+ "autoincrement": false
297+ },
298+ "nickname": {
299+ "name": "nickname",
300+ "type": "text",
301+ "primaryKey": false,
302+ "notNull": true,
303+ "autoincrement": false
304+ },
305+ "status": {
306+ "name": "status",
307+ "type": "text",
308+ "primaryKey": false,
309+ "notNull": false,
310+ "autoincrement": false,
311+ "default": "'offline'"
312+ },
313+ "avatar_url": {
314+ "name": "avatar_url",
315+ "type": "text",
316+ "primaryKey": false,
317+ "notNull": false,
318+ "autoincrement": false
319+ },
320+ "last_seen": {
321+ "name": "last_seen",
322+ "type": "integer",
323+ "primaryKey": false,
324+ "notNull": false,
325+ "autoincrement": false
326+ }
327+ },
328+ "name": "user_view",
329+ "isExisting": false,
330+ "definition": "select \"id\", \"nickname\", \"status\", \"avatar_url\", \"last_seen\" from \"users\""
331+ }
332+ },
333+ "enums": {},
334+ "_meta": {
335+ "schemas": {},
336+ "tables": {},
337+ "columns": {}
338+ },
339+ "internal": {
340+ "indexes": {}
341+ }
342+}⏎̸
A drizzle/meta/_journal.json +13 -0
@@ -0,0 +1,13 @@
1+{
2+ "version": "7",
3+ "dialect": "sqlite",
4+ "entries": [
5+ {
6+ "idx": 0,
7+ "version": "6",
8+ "when": 1739208055141,
9+ "tag": "0000_shallow_ghost_rider",
10+ "breakpoints": true
11+ }
12+ ]
13+}⏎̸
@@ -0,0 +1,13 @@
1+{
2+ "version": "7",
3+ "dialect": "sqlite",
4+ "entries": [
5+ {
6+ "idx": 0,
7+ "version": "6",
8+ "when": 1739208055141,
9+ "tag": "0000_shallow_ghost_rider",
10+ "breakpoints": true
11+ }
12+ ]
13+}⏎̸
M package.json +10 -1
@@ -16,7 +16,9 @@
1616 "format": "prettier --write .",
1717 "lint": "prettier --check . && eslint .",
1818 "test:unit": "vitest",
19- "test": "npm run test:unit -- --run"
19+ "test": "npm run test:unit -- --run",
20+ "db:generate": "pnpx drizzle-kit generate",
21+ "db:push": "pnpx drizzle-kit push"
2022 },
2123 "devDependencies": {
2224 "@eslint/compat": "^1.2.5",
@@ -27,7 +29,9 @@
2729 "@tailwindcss/container-queries": "^0.1.1",
2830 "@tailwindcss/forms": "^0.5.10",
2931 "@tailwindcss/typography": "^0.5.16",
32+ "@types/node": "^22.13.1",
3033 "autoprefixer": "^10.4.20",
34+ "drizzle-kit": "^0.30.4",
3135 "eslint": "^9.18.0",
3236 "eslint-config-prettier": "^10.0.1",
3337 "eslint-plugin-svelte": "^2.46.1",
@@ -38,12 +42,17 @@
3842 "svelte": "^5.0.0",
3943 "svelte-check": "^4.0.0",
4044 "tailwindcss": "^3.4.17",
45+ "tsx": "^4.19.2",
4146 "typescript": "^5.0.0",
4247 "typescript-eslint": "^8.20.0",
4348 "vite": "^6.0.0",
4449 "vitest": "^3.0.0"
4550 },
4651 "dependencies": {
52+ "@libsql/client": "^0.14.0",
53+ "dotenv": "^16.4.7",
54+ "drizzle-orm": "^0.39.3",
55+ "uuid": "^11.0.5",
4756 "xp.css": "^0.2.6"
4857 }
4958 }
@@ -16,7 +16,9 @@
16 "format": "prettier --write .",16 "format": "prettier --write .",
17 "lint": "prettier --check . && eslint .",17 "lint": "prettier --check . && eslint .",
18 "test:unit": "vitest",18 "test:unit": "vitest",
19- "test": "npm run test:unit -- --run"19+ "test": "npm run test:unit -- --run",
20+ "db:generate": "pnpx drizzle-kit generate",
21+ "db:push": "pnpx drizzle-kit push"
20 },22 },
21 "devDependencies": {23 "devDependencies": {
22 "@eslint/compat": "^1.2.5",24 "@eslint/compat": "^1.2.5",
@@ -27,7 +29,9 @@
27 "@tailwindcss/container-queries": "^0.1.1",29 "@tailwindcss/container-queries": "^0.1.1",
28 "@tailwindcss/forms": "^0.5.10",30 "@tailwindcss/forms": "^0.5.10",
29 "@tailwindcss/typography": "^0.5.16",31 "@tailwindcss/typography": "^0.5.16",
32+ "@types/node": "^22.13.1",
30 "autoprefixer": "^10.4.20",33 "autoprefixer": "^10.4.20",
34+ "drizzle-kit": "^0.30.4",
31 "eslint": "^9.18.0",35 "eslint": "^9.18.0",
32 "eslint-config-prettier": "^10.0.1",36 "eslint-config-prettier": "^10.0.1",
33 "eslint-plugin-svelte": "^2.46.1",37 "eslint-plugin-svelte": "^2.46.1",
@@ -38,12 +42,17 @@
38 "svelte": "^5.0.0",42 "svelte": "^5.0.0",
39 "svelte-check": "^4.0.0",43 "svelte-check": "^4.0.0",
40 "tailwindcss": "^3.4.17",44 "tailwindcss": "^3.4.17",
45+ "tsx": "^4.19.2",
41 "typescript": "^5.0.0",46 "typescript": "^5.0.0",
42 "typescript-eslint": "^8.20.0",47 "typescript-eslint": "^8.20.0",
43 "vite": "^6.0.0",48 "vite": "^6.0.0",
44 "vitest": "^3.0.0"49 "vitest": "^3.0.0"
45 },50 },
46 "dependencies": {51 "dependencies": {
52+ "@libsql/client": "^0.14.0",
53+ "dotenv": "^16.4.7",
54+ "drizzle-orm": "^0.39.3",
55+ "uuid": "^11.0.5",
47 "xp.css": "^0.2.6"56 "xp.css": "^0.2.6"
48 }57 }
49 }58 }
M pnpm-lock.yaml +1164 -28 generated file: collapsed
A scripts/setup-aim-assets.sh +22 -0
@@ -0,0 +1,22 @@
1+#!/bin/bash
2+
3+# Create the aim directory if it doesn't exist
4+mkdir -p static/aim
5+
6+# Create icons for the buttons
7+magick -size 24x24 xc:transparent \
8+ -fill black -stroke black -strokewidth 2 \
9+ -draw "circle 12,12 12,2" \
10+ -draw "path 'M12,8 L12,14 M12,16 L12,17'" \
11+ static/aim/help-icon.png
12+
13+magick -size 24x24 xc:transparent \
14+ -fill black -stroke black -strokewidth 2 \
15+ -draw "path 'M4,12 L20,12 M12,4 L12,20'" \
16+ static/aim/setup-icon.png
17+
18+magick -size 24x24 xc:transparent \
19+ -fill black -stroke black -strokewidth 2 \
20+ -draw "circle 12,12 12,2" \
21+ -draw "path 'M8,12 L16,12 M12,8 L12,16'" \
22+ static/aim/signin-icon.png ⏎̸
@@ -0,0 +1,22 @@
1+#!/bin/bash
2+
3+# Create the aim directory if it doesn't exist
4+mkdir -p static/aim
5+
6+# Create icons for the buttons
7+magick -size 24x24 xc:transparent \
8+ -fill black -stroke black -strokewidth 2 \
9+ -draw "circle 12,12 12,2" \
10+ -draw "path 'M12,8 L12,14 M12,16 L12,17'" \
11+ static/aim/help-icon.png
12+
13+magick -size 24x24 xc:transparent \
14+ -fill black -stroke black -strokewidth 2 \
15+ -draw "path 'M4,12 L20,12 M12,4 L12,20'" \
16+ static/aim/setup-icon.png
17+
18+magick -size 24x24 xc:transparent \
19+ -fill black -stroke black -strokewidth 2 \
20+ -draw "circle 12,12 12,2" \
21+ -draw "path 'M8,12 L16,12 M12,8 L12,16'" \
22+ static/aim/signin-icon.png ⏎̸
A src/lib/actions/draggable.ts +126 -0
@@ -0,0 +1,126 @@
1+import type { Action } from 'svelte/action';
2+
3+interface DraggableParameters {
4+ handle?: string;
5+ bounds?: 'parent' | 'window';
6+ enabled?: boolean;
7+}
8+
9+interface DraggableAttributes {
10+ ondragstart?: (e: CustomEvent<{ x: number; y: number }>) => void;
11+ ondragmove?: (e: CustomEvent<{ x: number; y: number }>) => void;
12+ ondragend?: (e: CustomEvent<{ x: number; y: number }>) => void;
13+}
14+
15+export const draggable: Action<HTMLElement, DraggableParameters, DraggableAttributes> = (node, params = {}) => {
16+ let x = 0;
17+ let y = 0;
18+ let startX = 0;
19+ let startY = 0;
20+ let isDragging = false;
21+
22+ let isEnabled = params.enabled !== undefined ? params.enabled : true;
23+ const handleSelector = params.handle ?? null;
24+ const bounds = params.bounds ?? 'window';
25+
26+ const handleElement = handleSelector ? node.querySelector<HTMLElement>(handleSelector) : node;
27+ if (!handleElement) {
28+ console.warn('Draggable handle element not found');
29+ return;
30+ }
31+
32+ // Set initial position from existing inline styles or computed styles
33+ const computedStyle = getComputedStyle(node);
34+ const existingLeft = node.style.left || computedStyle.left;
35+ const existingTop = node.style.top || computedStyle.top;
36+
37+ x = parseInt(existingLeft) || 0;
38+ y = parseInt(existingTop) || 0;
39+
40+ // Ensure the element is positioned
41+ const position = computedStyle.position;
42+ if (position !== 'relative' && position !== 'absolute' && position !== 'fixed') {
43+ node.style.position = 'fixed';
44+ }
45+
46+ node.style.left = `${x}px`;
47+ node.style.top = `${y}px`;
48+
49+ function onMouseDown(event: MouseEvent) {
50+ if (!isEnabled) return;
51+
52+ // Only handle left mouse button
53+ if (event.button !== 0) return;
54+
55+ isDragging = true;
56+ const rect = node.getBoundingClientRect();
57+
58+ x = rect.left;
59+ y = rect.top;
60+ startX = event.clientX - x;
61+ startY = event.clientY - y;
62+
63+ node.dispatchEvent(new CustomEvent('dragstart', {
64+ detail: { x, y }
65+ }));
66+
67+ // Prevent text selection while dragging
68+ event.preventDefault();
69+ }
70+
71+ function onMouseMove(event: MouseEvent) {
72+ if (!isDragging || !isEnabled) return;
73+
74+ let newX = event.clientX - startX;
75+ let newY = event.clientY - startY;
76+
77+ if (bounds === 'window') {
78+ // Keep within window bounds
79+ newX = Math.max(0, Math.min(newX, window.innerWidth - node.offsetWidth));
80+ newY = Math.max(0, Math.min(newY, window.innerHeight - node.offsetHeight));
81+ } else if (bounds === 'parent' && node.parentElement) {
82+ // Keep within parent bounds
83+ const parent = node.parentElement;
84+ newX = Math.max(0, Math.min(newX, parent.offsetWidth - node.offsetWidth));
85+ newY = Math.max(0, Math.min(newY, parent.offsetHeight - node.offsetHeight));
86+ }
87+
88+ x = newX;
89+ y = newY;
90+
91+ node.style.left = `${x}px`;
92+ node.style.top = `${y}px`;
93+
94+ node.dispatchEvent(new CustomEvent('dragmove', {
95+ detail: { x, y }
96+ }));
97+ }
98+
99+ function onMouseUp() {
100+ if (!isDragging) return;
101+ isDragging = false;
102+
103+ node.dispatchEvent(new CustomEvent('dragend', {
104+ detail: { x, y }
105+ }));
106+ }
107+
108+ handleElement.style.cursor = isEnabled ? 'move' : 'default';
109+ handleElement.addEventListener('mousedown', onMouseDown as EventListener);
110+ window.addEventListener('mousemove', onMouseMove as EventListener);
111+ window.addEventListener('mouseup', onMouseUp as EventListener);
112+
113+ return {
114+ update(newParams: DraggableParameters) {
115+ const newEnabled = newParams.enabled !== undefined ? newParams.enabled : true;
116+ isEnabled = newEnabled;
117+ handleElement.style.cursor = isEnabled ? 'move' : 'default';
118+ Object.assign(params, newParams);
119+ },
120+ destroy() {
121+ handleElement.removeEventListener('mousedown', onMouseDown as EventListener);
122+ window.removeEventListener('mousemove', onMouseMove as EventListener);
123+ window.removeEventListener('mouseup', onMouseUp as EventListener);
124+ }
125+ };
126+}; ⏎̸
@@ -0,0 +1,126 @@
1+import type { Action } from 'svelte/action';
2+
3+interface DraggableParameters {
4+ handle?: string;
5+ bounds?: 'parent' | 'window';
6+ enabled?: boolean;
7+}
8+
9+interface DraggableAttributes {
10+ ondragstart?: (e: CustomEvent<{ x: number; y: number }>) => void;
11+ ondragmove?: (e: CustomEvent<{ x: number; y: number }>) => void;
12+ ondragend?: (e: CustomEvent<{ x: number; y: number }>) => void;
13+}
14+
15+export const draggable: Action<HTMLElement, DraggableParameters, DraggableAttributes> = (node, params = {}) => {
16+ let x = 0;
17+ let y = 0;
18+ let startX = 0;
19+ let startY = 0;
20+ let isDragging = false;
21+
22+ let isEnabled = params.enabled !== undefined ? params.enabled : true;
23+ const handleSelector = params.handle ?? null;
24+ const bounds = params.bounds ?? 'window';
25+
26+ const handleElement = handleSelector ? node.querySelector<HTMLElement>(handleSelector) : node;
27+ if (!handleElement) {
28+ console.warn('Draggable handle element not found');
29+ return;
30+ }
31+
32+ // Set initial position from existing inline styles or computed styles
33+ const computedStyle = getComputedStyle(node);
34+ const existingLeft = node.style.left || computedStyle.left;
35+ const existingTop = node.style.top || computedStyle.top;
36+
37+ x = parseInt(existingLeft) || 0;
38+ y = parseInt(existingTop) || 0;
39+
40+ // Ensure the element is positioned
41+ const position = computedStyle.position;
42+ if (position !== 'relative' && position !== 'absolute' && position !== 'fixed') {
43+ node.style.position = 'fixed';
44+ }
45+
46+ node.style.left = `${x}px`;
47+ node.style.top = `${y}px`;
48+
49+ function onMouseDown(event: MouseEvent) {
50+ if (!isEnabled) return;
51+
52+ // Only handle left mouse button
53+ if (event.button !== 0) return;
54+
55+ isDragging = true;
56+ const rect = node.getBoundingClientRect();
57+
58+ x = rect.left;
59+ y = rect.top;
60+ startX = event.clientX - x;
61+ startY = event.clientY - y;
62+
63+ node.dispatchEvent(new CustomEvent('dragstart', {
64+ detail: { x, y }
65+ }));
66+
67+ // Prevent text selection while dragging
68+ event.preventDefault();
69+ }
70+
71+ function onMouseMove(event: MouseEvent) {
72+ if (!isDragging || !isEnabled) return;
73+
74+ let newX = event.clientX - startX;
75+ let newY = event.clientY - startY;
76+
77+ if (bounds === 'window') {
78+ // Keep within window bounds
79+ newX = Math.max(0, Math.min(newX, window.innerWidth - node.offsetWidth));
80+ newY = Math.max(0, Math.min(newY, window.innerHeight - node.offsetHeight));
81+ } else if (bounds === 'parent' && node.parentElement) {
82+ // Keep within parent bounds
83+ const parent = node.parentElement;
84+ newX = Math.max(0, Math.min(newX, parent.offsetWidth - node.offsetWidth));
85+ newY = Math.max(0, Math.min(newY, parent.offsetHeight - node.offsetHeight));
86+ }
87+
88+ x = newX;
89+ y = newY;
90+
91+ node.style.left = `${x}px`;
92+ node.style.top = `${y}px`;
93+
94+ node.dispatchEvent(new CustomEvent('dragmove', {
95+ detail: { x, y }
96+ }));
97+ }
98+
99+ function onMouseUp() {
100+ if (!isDragging) return;
101+ isDragging = false;
102+
103+ node.dispatchEvent(new CustomEvent('dragend', {
104+ detail: { x, y }
105+ }));
106+ }
107+
108+ handleElement.style.cursor = isEnabled ? 'move' : 'default';
109+ handleElement.addEventListener('mousedown', onMouseDown as EventListener);
110+ window.addEventListener('mousemove', onMouseMove as EventListener);
111+ window.addEventListener('mouseup', onMouseUp as EventListener);
112+
113+ return {
114+ update(newParams: DraggableParameters) {
115+ const newEnabled = newParams.enabled !== undefined ? newParams.enabled : true;
116+ isEnabled = newEnabled;
117+ handleElement.style.cursor = isEnabled ? 'move' : 'default';
118+ Object.assign(params, newParams);
119+ },
120+ destroy() {
121+ handleElement.removeEventListener('mousedown', onMouseDown as EventListener);
122+ window.removeEventListener('mousemove', onMouseMove as EventListener);
123+ window.removeEventListener('mouseup', onMouseUp as EventListener);
124+ }
125+ };
126+}; ⏎̸
A src/lib/actions/maximizable.ts +109 -0
@@ -0,0 +1,109 @@
1+import type { Action } from 'svelte/action';
2+interface MaximizableParameters {
3+ enabled?: boolean;
4+ padding?: number;
5+}
6+
7+interface MaximizableAttributes {
8+ onmaximize?: (e: CustomEvent<{
9+ isMaximized: boolean;
10+ width: number;
11+ height: number;
12+ x: number;
13+ y: number;
14+ }>) => void;
15+}
16+
17+interface StoredState {
18+ width: number;
19+ height: number;
20+ x: number;
21+ y: number;
22+}
23+
24+export const maximizable: Action<HTMLDivElement, MaximizableParameters, MaximizableAttributes> = (node, params = {}) => {
25+ const {
26+ enabled = true,
27+ padding = 0
28+ } = params;
29+
30+ let isMaximized = false;
31+ let storedState: StoredState | null = null;
32+
33+ function maximize() {
34+ if (!enabled || isMaximized) return;
35+
36+ // Store current state
37+ storedState = {
38+ width: node.offsetWidth,
39+ height: node.offsetHeight,
40+ x: parseInt(node.style.left) || 0,
41+ y: parseInt(node.style.top) || 0
42+ };
43+
44+ // Set maximized state
45+ const maxWidth = window.innerWidth - (padding * 2);
46+ const maxHeight = window.innerHeight - (padding * 2);
47+
48+ node.style.width = `${maxWidth}px`;
49+ node.style.height = `${maxHeight}px`;
50+ node.style.left = `${padding}px`;
51+ node.style.top = `${padding}px`;
52+
53+ isMaximized = true;
54+
55+ node.dispatchEvent(new CustomEvent('maximize', {
56+ detail: {
57+ isMaximized: true,
58+ width: maxWidth,
59+ height: maxHeight,
60+ x: padding,
61+ y: padding
62+ }
63+ }));
64+ }
65+
66+ function restore() {
67+ if (!enabled || !isMaximized || !storedState) return;
68+
69+ // Restore previous state
70+ node.style.width = `${storedState.width}px`;
71+ node.style.height = `${storedState.height}px`;
72+ node.style.left = `${storedState.x}px`;
73+ node.style.top = `${storedState.y}px`;
74+
75+ isMaximized = false;
76+
77+ node.dispatchEvent(new CustomEvent('maximize', {
78+ detail: {
79+ isMaximized: false,
80+ ...storedState
81+ }
82+ }));
83+ }
84+
85+ function toggle() {
86+ if (isMaximized) {
87+ restore();
88+ } else {
89+ maximize();
90+ }
91+ }
92+
93+ // Expose methods to the node
94+ Object.assign(node, {
95+ maximize,
96+ restore,
97+ toggleMaximize: toggle,
98+ isMaximized: () => isMaximized
99+ });
100+
101+ return {
102+ update(newParams: MaximizableParameters) {
103+ Object.assign(params, newParams);
104+ },
105+ destroy() {
106+ // Clean up if needed
107+ }
108+ };
109+}; ⏎̸
@@ -0,0 +1,109 @@
1+import type { Action } from 'svelte/action';
2+interface MaximizableParameters {
3+ enabled?: boolean;
4+ padding?: number;
5+}
6+
7+interface MaximizableAttributes {
8+ onmaximize?: (e: CustomEvent<{
9+ isMaximized: boolean;
10+ width: number;
11+ height: number;
12+ x: number;
13+ y: number;
14+ }>) => void;
15+}
16+
17+interface StoredState {
18+ width: number;
19+ height: number;
20+ x: number;
21+ y: number;
22+}
23+
24+export const maximizable: Action<HTMLDivElement, MaximizableParameters, MaximizableAttributes> = (node, params = {}) => {
25+ const {
26+ enabled = true,
27+ padding = 0
28+ } = params;
29+
30+ let isMaximized = false;
31+ let storedState: StoredState | null = null;
32+
33+ function maximize() {
34+ if (!enabled || isMaximized) return;
35+
36+ // Store current state
37+ storedState = {
38+ width: node.offsetWidth,
39+ height: node.offsetHeight,
40+ x: parseInt(node.style.left) || 0,
41+ y: parseInt(node.style.top) || 0
42+ };
43+
44+ // Set maximized state
45+ const maxWidth = window.innerWidth - (padding * 2);
46+ const maxHeight = window.innerHeight - (padding * 2);
47+
48+ node.style.width = `${maxWidth}px`;
49+ node.style.height = `${maxHeight}px`;
50+ node.style.left = `${padding}px`;
51+ node.style.top = `${padding}px`;
52+
53+ isMaximized = true;
54+
55+ node.dispatchEvent(new CustomEvent('maximize', {
56+ detail: {
57+ isMaximized: true,
58+ width: maxWidth,
59+ height: maxHeight,
60+ x: padding,
61+ y: padding
62+ }
63+ }));
64+ }
65+
66+ function restore() {
67+ if (!enabled || !isMaximized || !storedState) return;
68+
69+ // Restore previous state
70+ node.style.width = `${storedState.width}px`;
71+ node.style.height = `${storedState.height}px`;
72+ node.style.left = `${storedState.x}px`;
73+ node.style.top = `${storedState.y}px`;
74+
75+ isMaximized = false;
76+
77+ node.dispatchEvent(new CustomEvent('maximize', {
78+ detail: {
79+ isMaximized: false,
80+ ...storedState
81+ }
82+ }));
83+ }
84+
85+ function toggle() {
86+ if (isMaximized) {
87+ restore();
88+ } else {
89+ maximize();
90+ }
91+ }
92+
93+ // Expose methods to the node
94+ Object.assign(node, {
95+ maximize,
96+ restore,
97+ toggleMaximize: toggle,
98+ isMaximized: () => isMaximized
99+ });
100+
101+ return {
102+ update(newParams: MaximizableParameters) {
103+ Object.assign(params, newParams);
104+ },
105+ destroy() {
106+ // Clean up if needed
107+ }
108+ };
109+}; ⏎̸
A src/lib/actions/resizable.ts +111 -0
@@ -0,0 +1,111 @@
1+import type { Action } from 'svelte/action';
2+
3+interface ResizableParameters {
4+ enabled?: boolean;
5+ minWidth?: number;
6+ minHeight?: number;
7+ maxWidth?: number;
8+ maxHeight?: number;
9+}
10+
11+interface ResizableAttributes {
12+ onresizestart?: (e: CustomEvent<{ width: number; height: number }>) => void;
13+ onresizemove?: (e: CustomEvent<{ width: number; height: number }>) => void;
14+ onresizeend?: (e: CustomEvent<{ width: number; height: number }>) => void;
15+}
16+
17+export const resizable: Action<HTMLElement, ResizableParameters, ResizableAttributes> = (node, params = {}) => {
18+ let isResizing = false;
19+ let startWidth = 0;
20+ let startHeight = 0;
21+ let startX = 0;
22+ let startY = 0;
23+
24+ const {
25+ enabled = true,
26+ minWidth = 200,
27+ minHeight = 200,
28+ maxWidth = window.innerWidth,
29+ maxHeight = window.innerHeight
30+ } = params;
31+
32+ // Create and append resize handle
33+ const handle = document.createElement('div');
34+ handle.className = 'resize-handle';
35+ Object.assign(handle.style, {
36+ position: 'absolute',
37+ bottom: '0',
38+ right: '0',
39+ width: '15px',
40+ height: '15px',
41+ cursor: 'se-resize',
42+ zIndex: '1000'
43+ });
44+ node.appendChild(handle);
45+
46+ // Ensure parent element has position
47+ if (getComputedStyle(node).position === 'static') {
48+ node.style.position = 'relative';
49+ }
50+
51+ function onMouseDown(event: MouseEvent) {
52+ if (!enabled) return;
53+
54+ isResizing = true;
55+ startX = event.clientX;
56+ startY = event.clientY;
57+ startWidth = node.offsetWidth;
58+ startHeight = node.offsetHeight;
59+
60+ node.dispatchEvent(new CustomEvent('resizestart', {
61+ detail: { width: startWidth, height: startHeight }
62+ }));
63+
64+ event.preventDefault();
65+ }
66+
67+ function onMouseMove(event: MouseEvent) {
68+ if (!isResizing || !enabled) return;
69+
70+ const deltaX = event.clientX - startX;
71+ const deltaY = event.clientY - startY;
72+
73+ const newWidth = Math.min(maxWidth, Math.max(minWidth, startWidth + deltaX));
74+ const newHeight = Math.min(maxHeight, Math.max(minHeight, startHeight + deltaY));
75+
76+ node.style.width = `${newWidth}px`;
77+ node.style.height = `${newHeight}px`;
78+
79+ node.dispatchEvent(new CustomEvent('resizemove', {
80+ detail: { width: newWidth, height: newHeight }
81+ }));
82+ }
83+
84+ function onMouseUp() {
85+ if (!isResizing) return;
86+
87+ isResizing = false;
88+ node.dispatchEvent(new CustomEvent('resizeend', {
89+ detail: {
90+ width: node.offsetWidth,
91+ height: node.offsetHeight
92+ }
93+ }));
94+ }
95+
96+ handle.addEventListener('mousedown', onMouseDown);
97+ window.addEventListener('mousemove', onMouseMove);
98+ window.addEventListener('mouseup', onMouseUp);
99+
100+ return {
101+ update(newParams: ResizableParameters) {
102+ Object.assign(params, newParams);
103+ },
104+ destroy() {
105+ handle.removeEventListener('mousedown', onMouseDown);
106+ window.removeEventListener('mousemove', onMouseMove);
107+ window.removeEventListener('mouseup', onMouseUp);
108+ node.removeChild(handle);
109+ }
110+ };
111+}; ⏎̸
@@ -0,0 +1,111 @@
1+import type { Action } from 'svelte/action';
2+
3+interface ResizableParameters {
4+ enabled?: boolean;
5+ minWidth?: number;
6+ minHeight?: number;
7+ maxWidth?: number;
8+ maxHeight?: number;
9+}
10+
11+interface ResizableAttributes {
12+ onresizestart?: (e: CustomEvent<{ width: number; height: number }>) => void;
13+ onresizemove?: (e: CustomEvent<{ width: number; height: number }>) => void;
14+ onresizeend?: (e: CustomEvent<{ width: number; height: number }>) => void;
15+}
16+
17+export const resizable: Action<HTMLElement, ResizableParameters, ResizableAttributes> = (node, params = {}) => {
18+ let isResizing = false;
19+ let startWidth = 0;
20+ let startHeight = 0;
21+ let startX = 0;
22+ let startY = 0;
23+
24+ const {
25+ enabled = true,
26+ minWidth = 200,
27+ minHeight = 200,
28+ maxWidth = window.innerWidth,
29+ maxHeight = window.innerHeight
30+ } = params;
31+
32+ // Create and append resize handle
33+ const handle = document.createElement('div');
34+ handle.className = 'resize-handle';
35+ Object.assign(handle.style, {
36+ position: 'absolute',
37+ bottom: '0',
38+ right: '0',
39+ width: '15px',
40+ height: '15px',
41+ cursor: 'se-resize',
42+ zIndex: '1000'
43+ });
44+ node.appendChild(handle);
45+
46+ // Ensure parent element has position
47+ if (getComputedStyle(node).position === 'static') {
48+ node.style.position = 'relative';
49+ }
50+
51+ function onMouseDown(event: MouseEvent) {
52+ if (!enabled) return;
53+
54+ isResizing = true;
55+ startX = event.clientX;
56+ startY = event.clientY;
57+ startWidth = node.offsetWidth;
58+ startHeight = node.offsetHeight;
59+
60+ node.dispatchEvent(new CustomEvent('resizestart', {
61+ detail: { width: startWidth, height: startHeight }
62+ }));
63+
64+ event.preventDefault();
65+ }
66+
67+ function onMouseMove(event: MouseEvent) {
68+ if (!isResizing || !enabled) return;
69+
70+ const deltaX = event.clientX - startX;
71+ const deltaY = event.clientY - startY;
72+
73+ const newWidth = Math.min(maxWidth, Math.max(minWidth, startWidth + deltaX));
74+ const newHeight = Math.min(maxHeight, Math.max(minHeight, startHeight + deltaY));
75+
76+ node.style.width = `${newWidth}px`;
77+ node.style.height = `${newHeight}px`;
78+
79+ node.dispatchEvent(new CustomEvent('resizemove', {
80+ detail: { width: newWidth, height: newHeight }
81+ }));
82+ }
83+
84+ function onMouseUp() {
85+ if (!isResizing) return;
86+
87+ isResizing = false;
88+ node.dispatchEvent(new CustomEvent('resizeend', {
89+ detail: {
90+ width: node.offsetWidth,
91+ height: node.offsetHeight
92+ }
93+ }));
94+ }
95+
96+ handle.addEventListener('mousedown', onMouseDown);
97+ window.addEventListener('mousemove', onMouseMove);
98+ window.addEventListener('mouseup', onMouseUp);
99+
100+ return {
101+ update(newParams: ResizableParameters) {
102+ Object.assign(params, newParams);
103+ },
104+ destroy() {
105+ handle.removeEventListener('mousedown', onMouseDown);
106+ window.removeEventListener('mousemove', onMouseMove);
107+ window.removeEventListener('mouseup', onMouseUp);
108+ node.removeChild(handle);
109+ }
110+ };
111+}; ⏎̸
A src/lib/components/aim-login.svelte +248 -0
@@ -0,0 +1,248 @@
1+<!-- AIM Login Component Updated for Svelte5 with Sign In/Sign Up Tabs -->
2+<script lang="ts">
3+ import { draggable } from '$lib/actions/draggable';
4+ import { createEventDispatcher } from 'svelte';
5+
6+ const dispatch = createEventDispatcher();
7+
8+ let activeTab = $state('signin');
9+
10+ // Sign In state
11+ let siUsername = $state('');
12+ let siPassword = $state('');
13+ let savePassword = $state(true);
14+ let autoLogin = $state(false);
15+ let error = $state('');
16+
17+ // Sign Up state
18+ let suUsername = $state('');
19+ let suPassword = $state('');
20+ let suConfirmPassword = $state('');
21+ let captchaAnswer = $state('');
22+ let signupError = $state('');
23+
24+ function handleSigninSubmit() {
25+ error = '';
26+ if (!siUsername || !siPassword) {
27+ error = 'Please enter both username and password';
28+ return;
29+ }
30+ // TODO: Implement authentication for sign in
31+ console.log('Sign in', siUsername, siPassword, savePassword, autoLogin);
32+ }
33+
34+ function handleSignupSubmit() {
35+ signupError = '';
36+ if (!suUsername || !suPassword || !suConfirmPassword || !captchaAnswer) {
37+ signupError = 'Please fill all fields';
38+ return;
39+ }
40+ if (suPassword !== suConfirmPassword) {
41+ signupError = 'Passwords do not match';
42+ return;
43+ }
44+ if (captchaAnswer.toLowerCase() !== 'personal digital repository') {
45+ signupError = 'Incorrect captcha answer';
46+ return;
47+ }
48+ // TODO: Implement registration for sign up
49+ console.log('Sign up', suUsername, suPassword);
50+ }
51+
52+ function switchTab(tab: string) {
53+ activeTab = tab;
54+ error = '';
55+ signupError = '';
56+ }
57+
58+ function handleClose() {
59+ dispatch('close');
60+ }
61+
62+ // Added dragging state and handler similar to chat-room
63+ let windowX = $state(0);
64+ let windowY = $state(0);
65+
66+ function handleDragMove(event: CustomEvent<{ x: number; y: number }>) {
67+ windowX = event.detail.x;
68+ windowY = event.detail.y;
69+ }
70+</script>
71+
72+<!-- Updated outer div to handle dragging with inline style and drag event -->
73+<div class="login-window window" use:draggable={{ handle: '.title-bar' }} ondragmove={handleDragMove} style="left: {windowX}px; top: {windowY}px;">
74+ <div class="title-bar">
75+ <div class="title-bar-text">
76+ <img src="/desktop/aim-desktop-icon.png" alt="AIM" class="title-icon" width="16" height="16" />
77+ {activeTab === 'signin' ? 'Sign In' : 'Sign Up'}
78+ </div>
79+ <div class="title-bar-controls">
80+ <button aria-label="Close" onclick={handleClose}>×</button>
81+ </div>
82+ </div>
83+
84+ <div class="tabs">
85+ <button class:active={activeTab === 'signin'} onclick={() => switchTab('signin')}>Sign In</button>
86+ <button class:active={activeTab === 'signup'} onclick={() => switchTab('signup')}>Sign Up</button>
87+ </div>
88+
89+ <div class="window-content">
90+ {#if activeTab === 'signin'}
91+ <form onsubmit={handleSigninSubmit}>
92+ <div class="form-group">
93+ <label for="si-username">Username</label>
94+ <input type="text" id="si-username" bind:value={siUsername} placeholder="Username" />
95+ </div>
96+ <div class="form-group">
97+ <label for="si-password">Password</label>
98+ <input type="password" id="si-password" bind:value={siPassword} placeholder="Password" />
99+ </div>
100+ <div class="checkboxes">
101+ <label>
102+ <input type="checkbox" bind:checked={savePassword} />
103+ Save password
104+ </label>
105+ <label>
106+ <input type="checkbox" bind:checked={autoLogin} />
107+ Auto-login
108+ </label>
109+ </div>
110+ {#if error}
111+ <div class="error">{error}</div>
112+ {/if}
113+ <div class="button-bar">
114+ <button type="submit" class="icon-button">
115+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
116+ <path d="M12 4C9.24 4 7 6.24 7 9c0 2.76 2.24 5 5 5s5-2.24 5-5c0-2.76-2.24-5-5-5zm0 2c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 10c-3.33 0-7 1.67-7 5v1h14v-1c0-3.33-3.67-5-7-5zm0 2c2.67 0 5 1.33 5 3H7c0-1.67 2.33-3 5-3z" fill="currentColor"/>
117+ </svg>
118+ Sign In
119+ </button>
120+ </div>
121+ </form>
122+ {:else}
123+ <form onsubmit={handleSignupSubmit}>
124+ <div class="form-group">
125+ <label for="su-username">Username</label>
126+ <input type="text" id="su-username" bind:value={suUsername} placeholder="Choose a username" />
127+ </div>
128+ <div class="form-group">
129+ <label for="su-password">Password</label>
130+ <input type="password" id="su-password" bind:value={suPassword} placeholder="Password" />
131+ </div>
132+ <div class="form-group">
133+ <label for="su-confirm-password">Confirm Password</label>
134+ <input type="password" id="su-confirm-password" bind:value={suConfirmPassword} placeholder="Confirm Password" />
135+ </div>
136+ <div class="form-group">
137+ <label for="captcha">What's the meaning of PDR?</label>
138+ <input type="text" id="captcha" bind:value={captchaAnswer} placeholder="Enter your answer" />
139+ </div>
140+ {#if signupError}
141+ <div class="error">{signupError}</div>
142+ {/if}
143+ <div class="button-bar">
144+ <button type="submit" class="icon-button">
145+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
146+ <path d="M15 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm-9-2V7H4v3H1v2h3v3h2v-3h3v-2H6zm9 4c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" fill="currentColor"/>
147+ </svg>
148+ Sign Up
149+ </button>
150+ </div>
151+ </form>
152+ {/if}
153+ </div>
154+</div>
155+
156+<style>
157+ .login-window {
158+ width: 300px;
159+ background: #ECE9D8;
160+ /* Changed to fixed positioning for correct dragging */
161+ position: fixed;
162+ }
163+ .title-bar {
164+ background: linear-gradient(180deg, #0054E3 0%, #0047AB 100%);
165+ padding: 4px 6px;
166+ display: flex;
167+ justify-content: space-between;
168+ align-items: center;
169+ color: white;
170+ font-weight: bold;
171+ user-select: none;
172+ position: relative;
173+ }
174+ .title-bar-text {
175+ display: flex;
176+ align-items: center;
177+ gap: 4px;
178+ font-size: 0.875rem;
179+ }
180+
181+ .tabs {
182+ padding: 0 3px;
183+ display: flex;
184+ border-bottom: 1px solid #0054E3;
185+ }
186+ .tabs button {
187+ flex: 1;
188+ padding: 8px;
189+ border: none;
190+ background: #ECE9D8;
191+ font-size: 1rem;
192+ cursor: pointer;
193+ }
194+ .tabs button.active {
195+ background: #fff;
196+ border-bottom: 2px solid #0054E3;
197+ font-weight: bold;
198+ }
199+ .window-content {
200+ padding: 16px;
201+ }
202+ .form-group {
203+ margin-bottom: 16px;
204+ }
205+ .form-group label {
206+ display: block;
207+ margin-bottom: 4px;
208+ font-size: 1rem;
209+ }
210+ .form-group input,
211+ .form-group select {
212+ width: 100%;
213+ padding: 4px;
214+ font-size: 1rem;
215+ }
216+ .checkboxes {
217+ display: flex;
218+ gap: 16px;
219+ margin-bottom: 16px;
220+ font-size: 1rem;
221+ }
222+ .checkboxes label {
223+ display: flex;
224+ align-items: center;
225+ gap: 4px;
226+ }
227+ .button-bar {
228+ display: flex;
229+ justify-content: center;
230+ margin-top: 20px;
231+ }
232+ .icon-button {
233+ display: flex;
234+ flex-direction: column;
235+ align-items: center;
236+ gap: 4px;
237+ background: none;
238+ border: none;
239+ cursor: pointer;
240+ font-size: 1rem;
241+ color: black;
242+ }
243+ .error {
244+ color: red;
245+ font-size: 1rem;
246+ margin-top: 8px;
247+ }
248+</style> ⏎̸
@@ -0,0 +1,248 @@
1+<!-- AIM Login Component Updated for Svelte5 with Sign In/Sign Up Tabs -->
2+<script lang="ts">
3+ import { draggable } from '$lib/actions/draggable';
4+ import { createEventDispatcher } from 'svelte';
5+
6+ const dispatch = createEventDispatcher();
7+
8+ let activeTab = $state('signin');
9+
10+ // Sign In state
11+ let siUsername = $state('');
12+ let siPassword = $state('');
13+ let savePassword = $state(true);
14+ let autoLogin = $state(false);
15+ let error = $state('');
16+
17+ // Sign Up state
18+ let suUsername = $state('');
19+ let suPassword = $state('');
20+ let suConfirmPassword = $state('');
21+ let captchaAnswer = $state('');
22+ let signupError = $state('');
23+
24+ function handleSigninSubmit() {
25+ error = '';
26+ if (!siUsername || !siPassword) {
27+ error = 'Please enter both username and password';
28+ return;
29+ }
30+ // TODO: Implement authentication for sign in
31+ console.log('Sign in', siUsername, siPassword, savePassword, autoLogin);
32+ }
33+
34+ function handleSignupSubmit() {
35+ signupError = '';
36+ if (!suUsername || !suPassword || !suConfirmPassword || !captchaAnswer) {
37+ signupError = 'Please fill all fields';
38+ return;
39+ }
40+ if (suPassword !== suConfirmPassword) {
41+ signupError = 'Passwords do not match';
42+ return;
43+ }
44+ if (captchaAnswer.toLowerCase() !== 'personal digital repository') {
45+ signupError = 'Incorrect captcha answer';
46+ return;
47+ }
48+ // TODO: Implement registration for sign up
49+ console.log('Sign up', suUsername, suPassword);
50+ }
51+
52+ function switchTab(tab: string) {
53+ activeTab = tab;
54+ error = '';
55+ signupError = '';
56+ }
57+
58+ function handleClose() {
59+ dispatch('close');
60+ }
61+
62+ // Added dragging state and handler similar to chat-room
63+ let windowX = $state(0);
64+ let windowY = $state(0);
65+
66+ function handleDragMove(event: CustomEvent<{ x: number; y: number }>) {
67+ windowX = event.detail.x;
68+ windowY = event.detail.y;
69+ }
70+</script>
71+
72+<!-- Updated outer div to handle dragging with inline style and drag event -->
73+<div class="login-window window" use:draggable={{ handle: '.title-bar' }} ondragmove={handleDragMove} style="left: {windowX}px; top: {windowY}px;">
74+ <div class="title-bar">
75+ <div class="title-bar-text">
76+ <img src="/desktop/aim-desktop-icon.png" alt="AIM" class="title-icon" width="16" height="16" />
77+ {activeTab === 'signin' ? 'Sign In' : 'Sign Up'}
78+ </div>
79+ <div class="title-bar-controls">
80+ <button aria-label="Close" onclick={handleClose}>×</button>
81+ </div>
82+ </div>
83+
84+ <div class="tabs">
85+ <button class:active={activeTab === 'signin'} onclick={() => switchTab('signin')}>Sign In</button>
86+ <button class:active={activeTab === 'signup'} onclick={() => switchTab('signup')}>Sign Up</button>
87+ </div>
88+
89+ <div class="window-content">
90+ {#if activeTab === 'signin'}
91+ <form onsubmit={handleSigninSubmit}>
92+ <div class="form-group">
93+ <label for="si-username">Username</label>
94+ <input type="text" id="si-username" bind:value={siUsername} placeholder="Username" />
95+ </div>
96+ <div class="form-group">
97+ <label for="si-password">Password</label>
98+ <input type="password" id="si-password" bind:value={siPassword} placeholder="Password" />
99+ </div>
100+ <div class="checkboxes">
101+ <label>
102+ <input type="checkbox" bind:checked={savePassword} />
103+ Save password
104+ </label>
105+ <label>
106+ <input type="checkbox" bind:checked={autoLogin} />
107+ Auto-login
108+ </label>
109+ </div>
110+ {#if error}
111+ <div class="error">{error}</div>
112+ {/if}
113+ <div class="button-bar">
114+ <button type="submit" class="icon-button">
115+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
116+ <path d="M12 4C9.24 4 7 6.24 7 9c0 2.76 2.24 5 5 5s5-2.24 5-5c0-2.76-2.24-5-5-5zm0 2c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 10c-3.33 0-7 1.67-7 5v1h14v-1c0-3.33-3.67-5-7-5zm0 2c2.67 0 5 1.33 5 3H7c0-1.67 2.33-3 5-3z" fill="currentColor"/>
117+ </svg>
118+ Sign In
119+ </button>
120+ </div>
121+ </form>
122+ {:else}
123+ <form onsubmit={handleSignupSubmit}>
124+ <div class="form-group">
125+ <label for="su-username">Username</label>
126+ <input type="text" id="su-username" bind:value={suUsername} placeholder="Choose a username" />
127+ </div>
128+ <div class="form-group">
129+ <label for="su-password">Password</label>
130+ <input type="password" id="su-password" bind:value={suPassword} placeholder="Password" />
131+ </div>
132+ <div class="form-group">
133+ <label for="su-confirm-password">Confirm Password</label>
134+ <input type="password" id="su-confirm-password" bind:value={suConfirmPassword} placeholder="Confirm Password" />
135+ </div>
136+ <div class="form-group">
137+ <label for="captcha">What's the meaning of PDR?</label>
138+ <input type="text" id="captcha" bind:value={captchaAnswer} placeholder="Enter your answer" />
139+ </div>
140+ {#if signupError}
141+ <div class="error">{signupError}</div>
142+ {/if}
143+ <div class="button-bar">
144+ <button type="submit" class="icon-button">
145+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
146+ <path d="M15 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm-9-2V7H4v3H1v2h3v3h2v-3h3v-2H6zm9 4c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" fill="currentColor"/>
147+ </svg>
148+ Sign Up
149+ </button>
150+ </div>
151+ </form>
152+ {/if}
153+ </div>
154+</div>
155+
156+<style>
157+ .login-window {
158+ width: 300px;
159+ background: #ECE9D8;
160+ /* Changed to fixed positioning for correct dragging */
161+ position: fixed;
162+ }
163+ .title-bar {
164+ background: linear-gradient(180deg, #0054E3 0%, #0047AB 100%);
165+ padding: 4px 6px;
166+ display: flex;
167+ justify-content: space-between;
168+ align-items: center;
169+ color: white;
170+ font-weight: bold;
171+ user-select: none;
172+ position: relative;
173+ }
174+ .title-bar-text {
175+ display: flex;
176+ align-items: center;
177+ gap: 4px;
178+ font-size: 0.875rem;
179+ }
180+
181+ .tabs {
182+ padding: 0 3px;
183+ display: flex;
184+ border-bottom: 1px solid #0054E3;
185+ }
186+ .tabs button {
187+ flex: 1;
188+ padding: 8px;
189+ border: none;
190+ background: #ECE9D8;
191+ font-size: 1rem;
192+ cursor: pointer;
193+ }
194+ .tabs button.active {
195+ background: #fff;
196+ border-bottom: 2px solid #0054E3;
197+ font-weight: bold;
198+ }
199+ .window-content {
200+ padding: 16px;
201+ }
202+ .form-group {
203+ margin-bottom: 16px;
204+ }
205+ .form-group label {
206+ display: block;
207+ margin-bottom: 4px;
208+ font-size: 1rem;
209+ }
210+ .form-group input,
211+ .form-group select {
212+ width: 100%;
213+ padding: 4px;
214+ font-size: 1rem;
215+ }
216+ .checkboxes {
217+ display: flex;
218+ gap: 16px;
219+ margin-bottom: 16px;
220+ font-size: 1rem;
221+ }
222+ .checkboxes label {
223+ display: flex;
224+ align-items: center;
225+ gap: 4px;
226+ }
227+ .button-bar {
228+ display: flex;
229+ justify-content: center;
230+ margin-top: 20px;
231+ }
232+ .icon-button {
233+ display: flex;
234+ flex-direction: column;
235+ align-items: center;
236+ gap: 4px;
237+ background: none;
238+ border: none;
239+ cursor: pointer;
240+ font-size: 1rem;
241+ color: black;
242+ }
243+ .error {
244+ color: red;
245+ font-size: 1rem;
246+ margin-top: 8px;
247+ }
248+</style> ⏎̸
M src/lib/components/chat-room.svelte +100 -17
@@ -1,17 +1,29 @@
11 <script lang="ts">
22 import { chatState } from '../states/chat.svelte';
3-import type { ChatMessage, User } from '../types/chat';
3+import type { MessageType, User, ChatRoom, Message, UserStatus } from '../types/chat';
44 import { onMount } from 'svelte';
55 import { browser } from '$app/environment';
6+import { draggable } from '$lib/actions/draggable';
7+import { resizable } from '$lib/actions/resizable';
8+import { maximizable } from '$lib/actions/maximizable';
69
710 // Initialize with default values for SSR
8-let windowWidth = $state(800);
9-let windowHeight = $state(600);
11+let windowWidth = $state(400);
12+let windowHeight = $state(500);
1013 let windowX = $state(0);
1114 let windowY = $state(0);
1215 let isMobile = $state(false);
1316 let currentMessage = $state('');
1417 let showUserList = $state(false);
18+let isMaximized = $state(false);
19+
20+
21+// let { value = $bindable(), ...props } = $props();
22+let { showChatRoom = $bindable() } = $props();
23+
24+function handleClose() {
25+ showChatRoom = false;
26+}
1527
1628 // Reactive state using derived values
1729 let messages = $derived(chatState.getMessages());
@@ -29,8 +41,8 @@ onMount(() => {
2941 windowX = 0;
3042 windowY = 0;
3143 } else {
32- windowWidth = Math.min(800, window.innerWidth * 0.8);
33- windowHeight = Math.min(600, window.innerHeight * 0.8);
44+ windowWidth = Math.min(800, Math.max(400, window.innerWidth * 0.8));
45+ windowHeight = Math.min(600, Math.max(500, window.innerHeight * 0.8));
3446 windowX = Math.max(0, (window.innerWidth - windowWidth) / 2);
3547 windowY = Math.max(0, (window.innerHeight - windowHeight) / 2);
3648 }
@@ -67,23 +79,69 @@ function getStatusIcon(status: User['status']) {
6779 default: return '⚫';
6880 }
6981 }
82+
83+function handleDragMove(event: CustomEvent<{ x: number; y: number }>) {
84+ windowX = event.detail.x;
85+ windowY = event.detail.y;
86+}
87+
88+interface MaximizableNode extends HTMLElement {
89+ toggleMaximize: () => void;
90+}
91+
92+function handleMaximize(event: MouseEvent) {
93+ const node = (event.currentTarget as HTMLElement).closest('.window') as MaximizableNode;
94+ if (node) {
95+ node.toggleMaximize();
96+ }
97+}
98+
99+function handleMaximizeEvent(event: CustomEvent<{
100+ isMaximized: boolean;
101+ width: number;
102+ height: number;
103+ x: number;
104+ y: number;
105+}>) {
106+ isMaximized = event.detail.isMaximized;
107+ windowWidth = event.detail.width;
108+ windowHeight = event.detail.height;
109+ windowX = event.detail.x;
110+ windowY = event.detail.y;
111+}
70112 </script>
71113
114+{#if showChatRoom}
72115 <div
73- class="window"
74- style:width="{windowWidth}px"
75- style:height="{windowHeight}px"
76- style:position="absolute"
77- style:left="{windowX}px"
78- style:top="{windowY}px"
116+ class="chat-window window"
117+ style="width: {windowWidth}px; height: {windowHeight}px; left: {windowX}px; top: {windowY}px;"
118+ use:draggable={{ handle: '.title-bar', enabled: !isMobile && !isMaximized }}
119+ use:resizable={{
120+ enabled: !isMobile && !isMaximized,
121+ minWidth: 400,
122+ minHeight: 500,
123+ maxWidth: window.innerWidth - 40,
124+ maxHeight: window.innerHeight - 40
125+ }}
126+ use:maximizable={{ enabled: !isMobile, padding: 4 }}
127+ onmaximize={handleMaximizeEvent}
128+ onresizemove={(e) => {
129+ windowWidth = Math.max(400, e.detail.width);
130+ windowHeight = Math.max(500, e.detail.height);
131+ }}
132+ ondragmove={handleDragMove}
79133 >
80134 <div class="title-bar">
81- <div class="title-bar-text">AIM Chat Room - {currentUser?.nickname}</div>
135+ <div class="title-bar-text">Pdr Aim - {currentUser?.nickname}</div>
82136 <div class="title-bar-controls">
83137 {#if !isMobile}
84138 <button aria-label="Minimize"></button>
85- <button aria-label="Maximize"></button>
86- <button aria-label="Close"></button>
139+ <button
140+ aria-label="Maximize"
141+ class:maximized={isMaximized}
142+ onclick={handleMaximize}
143+ ></button>
144+ <button onclick={handleClose} aria-label="Close"></button>
87145 {:else}
88146 <button
89147 aria-label="Toggle Buddy List"
@@ -139,8 +197,8 @@ function getStatusIcon(status: User['status']) {
139197 <span class="status-icon">{getStatusIcon(user.status)}</span>
140198 <div class="user-info">
141199 <div class="nickname">{user.nickname}</div>
142- {#if user.statusMessage}
143- <div class="status-message">{user.statusMessage}</div>
200+ {#if user.status === 'away'}
201+ <div class="status-message">Away</div>
144202 {/if}
145203 </div>
146204 </div>
@@ -148,18 +206,27 @@ function getStatusIcon(status: User['status']) {
148206 </div>
149207 </div>
150208 </div>
209+{/if}
151210
152211 <style>
153212 :global(*) {
154213 font-size: 1rem;
214+ font-family: 'Microsoft Sans Serif', 'Segoe UI', Tahoma, sans-serif;
155215 }
156216
157217 .title-bar {
158218 height: 2rem;
219+ position: relative;
220+ cursor: move;
221+ user-select: none;
222+ }
223+
224+ .chat-window {
225+ position: fixed;
226+ box-sizing: border-box;
159227 }
160228
161229 .chat-area {
162- font-family: 'Tahoma', sans-serif;
163230 line-height: 1.4;
164231 }
165232
@@ -243,6 +310,18 @@ function getStatusIcon(status: User['status']) {
243310 display: none !important;
244311 }
245312
313+ :global(.resize-handle) {
314+ position: absolute;
315+ bottom: 0;
316+ right: 0;
317+ width: 15px !important;
318+ height: 15px !important;
319+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='15' height='15'%3E%3Cpath d='M11 11v-2h2v2h-2zm0-4h2v2h-2V7zm-2 2V7h2v2H9zm0 2v-2h2v2H9zm-2 0v-2h2v2H7z' fill='%23000'/%3E%3C/svg%3E");
320+ background-position: bottom right;
321+ background-repeat: no-repeat;
322+ cursor: se-resize !important;
323+ }
324+
246325 @media (max-width: 768px) {
247326 .window {
248327 position: fixed !important;
@@ -280,5 +359,9 @@ function getStatusIcon(status: User['status']) {
280359 padding: 0.5rem;
281360 border-top: 0.125rem solid #dfdfdf;
282361 }
362+
363+ :global(.resize-handle) {
364+ display: none;
365+ }
283366 }
284367 </style>⏎̸
@@ -1,17 +1,29 @@
1 <script lang="ts">1 <script lang="ts">
2 import { chatState } from '../states/chat.svelte';2 import { chatState } from '../states/chat.svelte';
3-import type { ChatMessage, User } from '../types/chat';3+import type { MessageType, User, ChatRoom, Message, UserStatus } from '../types/chat';
4 import { onMount } from 'svelte';4 import { onMount } from 'svelte';
5 import { browser } from '$app/environment';5 import { browser } from '$app/environment';
6+import { draggable } from '$lib/actions/draggable';
7+import { resizable } from '$lib/actions/resizable';
8+import { maximizable } from '$lib/actions/maximizable';
6 9
7 // Initialize with default values for SSR10 // Initialize with default values for SSR
8-let windowWidth = $state(800);11+let windowWidth = $state(400);
9-let windowHeight = $state(600);12+let windowHeight = $state(500);
10 let windowX = $state(0);13 let windowX = $state(0);
11 let windowY = $state(0);14 let windowY = $state(0);
12 let isMobile = $state(false);15 let isMobile = $state(false);
13 let currentMessage = $state('');16 let currentMessage = $state('');
14 let showUserList = $state(false);17 let showUserList = $state(false);
18+let isMaximized = $state(false);
19+
20+
21+// let { value = $bindable(), ...props } = $props();
22+let { showChatRoom = $bindable() } = $props();
23+
24+function handleClose() {
25+ showChatRoom = false;
26+}
15 27
16 // Reactive state using derived values28 // Reactive state using derived values
17 let messages = $derived(chatState.getMessages());29 let messages = $derived(chatState.getMessages());
@@ -29,8 +41,8 @@ onMount(() => {
29 windowX = 0;41 windowX = 0;
30 windowY = 0;42 windowY = 0;
31 } else {43 } else {
32- windowWidth = Math.min(800, window.innerWidth * 0.8);44+ windowWidth = Math.min(800, Math.max(400, window.innerWidth * 0.8));
33- windowHeight = Math.min(600, window.innerHeight * 0.8);45+ windowHeight = Math.min(600, Math.max(500, window.innerHeight * 0.8));
34 windowX = Math.max(0, (window.innerWidth - windowWidth) / 2);46 windowX = Math.max(0, (window.innerWidth - windowWidth) / 2);
35 windowY = Math.max(0, (window.innerHeight - windowHeight) / 2);47 windowY = Math.max(0, (window.innerHeight - windowHeight) / 2);
36 }48 }
@@ -67,23 +79,69 @@ function getStatusIcon(status: User['status']) {
67 default: return '⚫';79 default: return '⚫';
68 }80 }
69 }81 }
82+
83+function handleDragMove(event: CustomEvent<{ x: number; y: number }>) {
84+ windowX = event.detail.x;
85+ windowY = event.detail.y;
86+}
87+
88+interface MaximizableNode extends HTMLElement {
89+ toggleMaximize: () => void;
90+}
91+
92+function handleMaximize(event: MouseEvent) {
93+ const node = (event.currentTarget as HTMLElement).closest('.window') as MaximizableNode;
94+ if (node) {
95+ node.toggleMaximize();
96+ }
97+}
98+
99+function handleMaximizeEvent(event: CustomEvent<{
100+ isMaximized: boolean;
101+ width: number;
102+ height: number;
103+ x: number;
104+ y: number;
105+}>) {
106+ isMaximized = event.detail.isMaximized;
107+ windowWidth = event.detail.width;
108+ windowHeight = event.detail.height;
109+ windowX = event.detail.x;
110+ windowY = event.detail.y;
111+}
70 </script>112 </script>
71 113
114+{#if showChatRoom}
72 <div 115 <div
73- class="window"116+ class="chat-window window"
74- style:width="{windowWidth}px"117+ style="width: {windowWidth}px; height: {windowHeight}px; left: {windowX}px; top: {windowY}px;"
75- style:height="{windowHeight}px"118+ use:draggable={{ handle: '.title-bar', enabled: !isMobile && !isMaximized }}
76- style:position="absolute"119+ use:resizable={{
77- style:left="{windowX}px"120+ enabled: !isMobile && !isMaximized,
78- style:top="{windowY}px"121+ minWidth: 400,
122+ minHeight: 500,
123+ maxWidth: window.innerWidth - 40,
124+ maxHeight: window.innerHeight - 40
125+ }}
126+ use:maximizable={{ enabled: !isMobile, padding: 4 }}
127+ onmaximize={handleMaximizeEvent}
128+ onresizemove={(e) => {
129+ windowWidth = Math.max(400, e.detail.width);
130+ windowHeight = Math.max(500, e.detail.height);
131+ }}
132+ ondragmove={handleDragMove}
79 >133 >
80 <div class="title-bar">134 <div class="title-bar">
81- <div class="title-bar-text">AIM Chat Room - {currentUser?.nickname}</div>135+ <div class="title-bar-text">Pdr Aim - {currentUser?.nickname}</div>
82 <div class="title-bar-controls">136 <div class="title-bar-controls">
83 {#if !isMobile}137 {#if !isMobile}
84 <button aria-label="Minimize"></button>138 <button aria-label="Minimize"></button>
85- <button aria-label="Maximize"></button>139+ <button
86- <button aria-label="Close"></button>140+ aria-label="Maximize"
141+ class:maximized={isMaximized}
142+ onclick={handleMaximize}
143+ ></button>
144+ <button onclick={handleClose} aria-label="Close"></button>
87 {:else}145 {:else}
88 <button 146 <button
89 aria-label="Toggle Buddy List"147 aria-label="Toggle Buddy List"
@@ -139,8 +197,8 @@ function getStatusIcon(status: User['status']) {
139 <span class="status-icon">{getStatusIcon(user.status)}</span>197 <span class="status-icon">{getStatusIcon(user.status)}</span>
140 <div class="user-info">198 <div class="user-info">
141 <div class="nickname">{user.nickname}</div>199 <div class="nickname">{user.nickname}</div>
142- {#if user.statusMessage}200+ {#if user.status === 'away'}
143- <div class="status-message">{user.statusMessage}</div>201+ <div class="status-message">Away</div>
144 {/if}202 {/if}
145 </div>203 </div>
146 </div>204 </div>
@@ -148,18 +206,27 @@ function getStatusIcon(status: User['status']) {
148 </div>206 </div>
149 </div>207 </div>
150 </div>208 </div>
209+{/if}
151 210
152 <style>211 <style>
153 :global(*) {212 :global(*) {
154 font-size: 1rem;213 font-size: 1rem;
214+ font-family: 'Microsoft Sans Serif', 'Segoe UI', Tahoma, sans-serif;
155 }215 }
156 216
157 .title-bar {217 .title-bar {
158 height: 2rem;218 height: 2rem;
219+ position: relative;
220+ cursor: move;
221+ user-select: none;
222+ }
223+
224+ .chat-window {
225+ position: fixed;
226+ box-sizing: border-box;
159 }227 }
160 228
161 .chat-area {229 .chat-area {
162- font-family: 'Tahoma', sans-serif;
163 line-height: 1.4;230 line-height: 1.4;
164 }231 }
165 232
@@ -243,6 +310,18 @@ function getStatusIcon(status: User['status']) {
243 display: none !important;310 display: none !important;
244 }311 }
245 312
313+ :global(.resize-handle) {
314+ position: absolute;
315+ bottom: 0;
316+ right: 0;
317+ width: 15px !important;
318+ height: 15px !important;
319+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='15' height='15'%3E%3Cpath d='M11 11v-2h2v2h-2zm0-4h2v2h-2V7zm-2 2V7h2v2H9zm0 2v-2h2v2H9zm-2 0v-2h2v2H7z' fill='%23000'/%3E%3C/svg%3E");
320+ background-position: bottom right;
321+ background-repeat: no-repeat;
322+ cursor: se-resize !important;
323+ }
324+
246 @media (max-width: 768px) {325 @media (max-width: 768px) {
247 .window {326 .window {
248 position: fixed !important;327 position: fixed !important;
@@ -280,5 +359,9 @@ function getStatusIcon(status: User['status']) {
280 padding: 0.5rem;359 padding: 0.5rem;
281 border-top: 0.125rem solid #dfdfdf;360 border-top: 0.125rem solid #dfdfdf;
282 }361 }
362+
363+ :global(.resize-handle) {
364+ display: none;
365+ }
283 }366 }
284 </style>⏎̸367 </style>⏎̸
A src/lib/components/desktop-icons.svelte +96 -0
@@ -0,0 +1,96 @@
1+<script lang="ts">
2+ let { showChatRoom = $bindable(), showAuth = $bindable() } = $props();
3+
4+ function handleKeydown(event: KeyboardEvent, action: () => void) {
5+ if (event.key === 'Enter' || event.key === ' ') {
6+ event.preventDefault();
7+ action();
8+ }
9+ }
10+
11+ function handleChatRoomClick() {
12+ console.log('handleChatRoomClick');
13+ showChatRoom = true
14+ }
15+
16+ function handleAuthClick() {
17+ console.log('handleAuthClick');
18+ showAuth = true;
19+ }
20+</script>
21+
22+<div class="desktop-icons">
23+ <div
24+ class="desktop-icon"
25+ ondblclick={handleChatRoomClick}
26+ onkeydown={(e) => handleKeydown(e, handleChatRoomClick)}
27+ role="button"
28+ tabindex="0"
29+ >
30+ <img src="/desktop/aim-desktop-icon.png" alt="Chat Room" />
31+ <span class="icon-label">Pdr Aim</span>
32+ </div>
33+
34+ <div
35+ class="desktop-icon"
36+ ondblclick={handleAuthClick}
37+ onkeydown={(e) => handleKeydown(e, handleAuthClick)}
38+ role="button"
39+ tabindex="0"
40+ >
41+ <img src="/desktop/application-x-shellscript.png" alt="Sign In" />
42+ <span class="icon-label">login</span>
43+ </div>
44+</div>
45+
46+<style>
47+ .desktop-icons {
48+ position: fixed;
49+ top: 20px;
50+ left: 20px;
51+ display: flex;
52+ flex-direction: column;
53+ gap: 20px;
54+ }
55+
56+ .desktop-icon {
57+ display: flex;
58+ flex-direction: column;
59+ align-items: center;
60+ cursor: pointer;
61+ width: 75px;
62+ text-align: center;
63+ padding: 8px;
64+ border: 1px dotted transparent;
65+ border-radius: 2px;
66+ margin: 2px;
67+ }
68+
69+ .desktop-icon:hover, .desktop-icon:focus {
70+ background: rgba(49, 106, 197, 0.2);
71+ border: 1px dotted rgba(255, 255, 255, 0.8);
72+ outline: 1px solid rgba(49, 106, 197, 0.6);
73+ outline-offset: -1px;
74+ }
75+
76+ .desktop-icon:active {
77+ background: rgba(49, 106, 197, 0.4);
78+ }
79+
80+ .desktop-icon img {
81+ width: 48px;
82+ height: 48px;
83+ margin-bottom: 5px;
84+ pointer-events: none; /* Prevents image from interfering with hover */
85+ }
86+
87+ .icon-label {
88+ color: white;
89+ text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.8);
90+ font-size: 12px;
91+ user-select: none;
92+ pointer-events: none; /* Prevents text from interfering with hover */
93+ max-width: 100%;
94+ overflow-wrap: break-word;
95+ }
96+</style> ⏎̸
@@ -0,0 +1,96 @@
1+<script lang="ts">
2+ let { showChatRoom = $bindable(), showAuth = $bindable() } = $props();
3+
4+ function handleKeydown(event: KeyboardEvent, action: () => void) {
5+ if (event.key === 'Enter' || event.key === ' ') {
6+ event.preventDefault();
7+ action();
8+ }
9+ }
10+
11+ function handleChatRoomClick() {
12+ console.log('handleChatRoomClick');
13+ showChatRoom = true
14+ }
15+
16+ function handleAuthClick() {
17+ console.log('handleAuthClick');
18+ showAuth = true;
19+ }
20+</script>
21+
22+<div class="desktop-icons">
23+ <div
24+ class="desktop-icon"
25+ ondblclick={handleChatRoomClick}
26+ onkeydown={(e) => handleKeydown(e, handleChatRoomClick)}
27+ role="button"
28+ tabindex="0"
29+ >
30+ <img src="/desktop/aim-desktop-icon.png" alt="Chat Room" />
31+ <span class="icon-label">Pdr Aim</span>
32+ </div>
33+
34+ <div
35+ class="desktop-icon"
36+ ondblclick={handleAuthClick}
37+ onkeydown={(e) => handleKeydown(e, handleAuthClick)}
38+ role="button"
39+ tabindex="0"
40+ >
41+ <img src="/desktop/application-x-shellscript.png" alt="Sign In" />
42+ <span class="icon-label">login</span>
43+ </div>
44+</div>
45+
46+<style>
47+ .desktop-icons {
48+ position: fixed;
49+ top: 20px;
50+ left: 20px;
51+ display: flex;
52+ flex-direction: column;
53+ gap: 20px;
54+ }
55+
56+ .desktop-icon {
57+ display: flex;
58+ flex-direction: column;
59+ align-items: center;
60+ cursor: pointer;
61+ width: 75px;
62+ text-align: center;
63+ padding: 8px;
64+ border: 1px dotted transparent;
65+ border-radius: 2px;
66+ margin: 2px;
67+ }
68+
69+ .desktop-icon:hover, .desktop-icon:focus {
70+ background: rgba(49, 106, 197, 0.2);
71+ border: 1px dotted rgba(255, 255, 255, 0.8);
72+ outline: 1px solid rgba(49, 106, 197, 0.6);
73+ outline-offset: -1px;
74+ }
75+
76+ .desktop-icon:active {
77+ background: rgba(49, 106, 197, 0.4);
78+ }
79+
80+ .desktop-icon img {
81+ width: 48px;
82+ height: 48px;
83+ margin-bottom: 5px;
84+ pointer-events: none; /* Prevents image from interfering with hover */
85+ }
86+
87+ .icon-label {
88+ color: white;
89+ text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.8);
90+ font-size: 12px;
91+ user-select: none;
92+ pointer-events: none; /* Prevents text from interfering with hover */
93+ max-width: 100%;
94+ overflow-wrap: break-word;
95+ }
96+</style> ⏎̸
A src/lib/db/db.server.ts +14 -0
@@ -0,0 +1,14 @@
1+import { drizzle } from 'drizzle-orm/libsql';
2+import { createClient } from '@libsql/client';
3+import { TURSO_AUTH_TOKEN, TURSO_DATABASE_URL } from '$env/static/private';
4+import * as schema from './schema';
5+
6+const client = createClient({ url: TURSO_DATABASE_URL, authToken: TURSO_AUTH_TOKEN });
7+const db = drizzle(client, { schema });
8+
9+export const testConnection = async () => {
10+ const result = await db.query.users.findMany();
11+ console.log(result);
12+};
13+
14+export default db;⏎̸
@@ -0,0 +1,14 @@
1+import { drizzle } from 'drizzle-orm/libsql';
2+import { createClient } from '@libsql/client';
3+import { TURSO_AUTH_TOKEN, TURSO_DATABASE_URL } from '$env/static/private';
4+import * as schema from './schema';
5+
6+const client = createClient({ url: TURSO_DATABASE_URL, authToken: TURSO_AUTH_TOKEN });
7+const db = drizzle(client, { schema });
8+
9+export const testConnection = async () => {
10+ const result = await db.query.users.findMany();
11+ console.log(result);
12+};
13+
14+export default db;⏎̸
A src/lib/db/schema.ts +87 -0
@@ -0,0 +1,87 @@
1+import { sqliteTable as table } from "drizzle-orm/sqlite-core";
2+import * as t from "drizzle-orm/sqlite-core";
3+import type { User, ChatRoom, Message, UserStatus, MessageType } from "../types/chat";
4+import { v4 as uuidv4 } from "uuid";
5+import { eq } from "drizzle-orm";
6+
7+export const users = table(
8+ "users",
9+ {
10+ id: t.text("id").primaryKey().$default(() => uuidv4()),
11+ email: t.text("email").notNull().unique(),
12+ password: t.text("password").notNull(),
13+ nickname: t.text("nickname").notNull(),
14+ status: t.text("status").$type<UserStatus>().default("offline"),
15+ avatarUrl: t.text("avatar_url"),
16+ createdAt: t.integer("created_at").notNull(),
17+ lastSeen: t.integer("last_seen"),
18+ }
19+);
20+
21+export const chatRooms = table(
22+ "chat_rooms",
23+ {
24+ id: t.text("id").primaryKey().$default(() => uuidv4()),
25+ name: t.text("name"),
26+ type: t.text("type").$type<"direct" | "group">().notNull().default("direct"),
27+ createdAt: t.integer("created_at").notNull(),
28+ }
29+);
30+
31+export const messages = table(
32+ "messages",
33+ {
34+ id: t.text("id").primaryKey().$default(() => uuidv4()),
35+ chatRoomId: t.text("chat_room_id").references(() => chatRooms.id).notNull(),
36+ senderId: t.text("sender_id").references(() => users.id).notNull(),
37+ content: t.text("content").notNull(),
38+ type: t.text("type").$type<MessageType>().notNull().default("chat"),
39+ timestamp: t.integer("timestamp").notNull(),
40+ }
41+);
42+
43+// Validate schema types against interfaces
44+type UsersTable = typeof users.$inferSelect;
45+type ChatRoomsTable = typeof chatRooms.$inferSelect;
46+type MessagesTable = typeof messages.$inferSelect;
47+
48+// These type assertions will fail if the schema doesn't match the interfaces
49+type _UsersValidation = Omit<User, keyof UsersTable> & Omit<UsersTable, keyof User>;
50+type _ChatRoomsValidation = Omit<ChatRoom, keyof ChatRoomsTable> & Omit<ChatRoomsTable, keyof ChatRoom>;
51+type _MessagesValidation = Omit<Message, keyof MessagesTable> & Omit<MessagesTable, keyof Message>;
52+
53+export const userView = t.sqliteView("user_view").as((qb) =>
54+ qb.select({
55+ id: users.id,
56+ nickname: users.nickname,
57+ status: users.status,
58+ avatarUrl: users.avatarUrl,
59+ lastSeen: users.lastSeen
60+ }).from(users)
61+);
62+
63+export const chatRoomView = t.sqliteView("chat_room_view").as((qb) =>
64+ qb.select({
65+ id: chatRooms.id,
66+ name: chatRooms.name,
67+ type: chatRooms.type,
68+ createdAt: chatRooms.createdAt
69+ }).from(chatRooms)
70+);
71+
72+export const messageView = t.sqliteView("message_view").as((qb) =>
73+ qb.select({
74+ id: messages.id,
75+ content: messages.content,
76+ type: messages.type,
77+ timestamp: messages.timestamp,
78+ sender: users.nickname,
79+ chatRoom: chatRooms.name
80+ })
81+ .from(messages)
82+ .leftJoin(users, eq(messages.senderId, users.id))
83+ .leftJoin(chatRooms, eq(messages.chatRoomId, chatRooms.id))
84+);
85+
86+
87+
@@ -0,0 +1,87 @@
1+import { sqliteTable as table } from "drizzle-orm/sqlite-core";
2+import * as t from "drizzle-orm/sqlite-core";
3+import type { User, ChatRoom, Message, UserStatus, MessageType } from "../types/chat";
4+import { v4 as uuidv4 } from "uuid";
5+import { eq } from "drizzle-orm";
6+
7+export const users = table(
8+ "users",
9+ {
10+ id: t.text("id").primaryKey().$default(() => uuidv4()),
11+ email: t.text("email").notNull().unique(),
12+ password: t.text("password").notNull(),
13+ nickname: t.text("nickname").notNull(),
14+ status: t.text("status").$type<UserStatus>().default("offline"),
15+ avatarUrl: t.text("avatar_url"),
16+ createdAt: t.integer("created_at").notNull(),
17+ lastSeen: t.integer("last_seen"),
18+ }
19+);
20+
21+export const chatRooms = table(
22+ "chat_rooms",
23+ {
24+ id: t.text("id").primaryKey().$default(() => uuidv4()),
25+ name: t.text("name"),
26+ type: t.text("type").$type<"direct" | "group">().notNull().default("direct"),
27+ createdAt: t.integer("created_at").notNull(),
28+ }
29+);
30+
31+export const messages = table(
32+ "messages",
33+ {
34+ id: t.text("id").primaryKey().$default(() => uuidv4()),
35+ chatRoomId: t.text("chat_room_id").references(() => chatRooms.id).notNull(),
36+ senderId: t.text("sender_id").references(() => users.id).notNull(),
37+ content: t.text("content").notNull(),
38+ type: t.text("type").$type<MessageType>().notNull().default("chat"),
39+ timestamp: t.integer("timestamp").notNull(),
40+ }
41+);
42+
43+// Validate schema types against interfaces
44+type UsersTable = typeof users.$inferSelect;
45+type ChatRoomsTable = typeof chatRooms.$inferSelect;
46+type MessagesTable = typeof messages.$inferSelect;
47+
48+// These type assertions will fail if the schema doesn't match the interfaces
49+type _UsersValidation = Omit<User, keyof UsersTable> & Omit<UsersTable, keyof User>;
50+type _ChatRoomsValidation = Omit<ChatRoom, keyof ChatRoomsTable> & Omit<ChatRoomsTable, keyof ChatRoom>;
51+type _MessagesValidation = Omit<Message, keyof MessagesTable> & Omit<MessagesTable, keyof Message>;
52+
53+export const userView = t.sqliteView("user_view").as((qb) =>
54+ qb.select({
55+ id: users.id,
56+ nickname: users.nickname,
57+ status: users.status,
58+ avatarUrl: users.avatarUrl,
59+ lastSeen: users.lastSeen
60+ }).from(users)
61+);
62+
63+export const chatRoomView = t.sqliteView("chat_room_view").as((qb) =>
64+ qb.select({
65+ id: chatRooms.id,
66+ name: chatRooms.name,
67+ type: chatRooms.type,
68+ createdAt: chatRooms.createdAt
69+ }).from(chatRooms)
70+);
71+
72+export const messageView = t.sqliteView("message_view").as((qb) =>
73+ qb.select({
74+ id: messages.id,
75+ content: messages.content,
76+ type: messages.type,
77+ timestamp: messages.timestamp,
78+ sender: users.nickname,
79+ chatRoom: chatRooms.name
80+ })
81+ .from(messages)
82+ .leftJoin(users, eq(messages.senderId, users.id))
83+ .leftJoin(chatRooms, eq(messages.chatRoomId, chatRooms.id))
84+);
85+
86+
87+
M src/lib/types/chat.ts +28 -11
@@ -1,19 +1,36 @@
1-export type UserStatus = 'online' | 'away' | 'busy' | 'offline';
1+/**
2+ * TypeScript interfaces for an AOL Instant Messenger–like project.
3+ * Each interface represents a table structure in your database.
4+ */
25
6+// Users table: holds authentication and profile details
37 export interface User {
48 id: string;
9+ email: string;
10+ password: string; // hashed password
511 nickname: string;
6- status: UserStatus;
7- statusMessage?: string;
12+ status: UserStatus; // default is 'offline'
813 avatarUrl?: string;
9-}
14+ createdAt: number; // Timestamp (e.g., Unix timestamp)
15+ lastSeen?: number; // Optional last active timestamp
16+ }
1017
11-export type MessageType = 'chat' | 'emote' | 'system';
12-
13-export interface ChatMessage {
18+export type UserStatus = 'offline' | 'online' | 'away' | string;
19+
20+ // Chat rooms table: supports both direct (private) and group chat conversations
21+ export interface ChatRoom {
22+ id: string;
23+ name?: string; // Optional for direct chats, required for groups
24+ type: 'direct' | 'group' | string; // Default is 'direct'
25+ createdAt: number; // Creation timestamp
26+ }
27+
28+ // Messages table: stores the conversation messages
29+ export interface Message {
1430 id: string;
15- userId: string;
31+ chatRoomId: string; // Reference to the chat room (ChatRoom.id)
32+ senderId: string; // Reference to the sender (User.id)
1633 content: string;
17- timestamp: Date;
18- type: MessageType;
19-} ⏎̸
34+ type: 'chat' | 'emote' | 'system' | string; // Default is 'chat'
35+ timestamp: number; // Timestamp when the message was sent
36+ }⏎̸
@@ -1,19 +1,36 @@
1-export type UserStatus = 'online' | 'away' | 'busy' | 'offline';1+/**
2+ * TypeScript interfaces for an AOL Instant Messenger–like project.
3+ * Each interface represents a table structure in your database.
4+ */
2 5
6+// Users table: holds authentication and profile details
3 export interface User {7 export interface User {
4 id: string;8 id: string;
9+ email: string;
10+ password: string; // hashed password
5 nickname: string;11 nickname: string;
6- status: UserStatus;12+ status: UserStatus; // default is 'offline'
7- statusMessage?: string;
8 avatarUrl?: string;13 avatarUrl?: string;
9-}14+ createdAt: number; // Timestamp (e.g., Unix timestamp)
15+ lastSeen?: number; // Optional last active timestamp
16+ }
10 17
11-export type MessageType = 'chat' | 'emote' | 'system';18+export type UserStatus = 'offline' | 'online' | 'away' | string;
12-19+
13-export interface ChatMessage {20+ // Chat rooms table: supports both direct (private) and group chat conversations
21+ export interface ChatRoom {
22+ id: string;
23+ name?: string; // Optional for direct chats, required for groups
24+ type: 'direct' | 'group' | string; // Default is 'direct'
25+ createdAt: number; // Creation timestamp
26+ }
27+
28+ // Messages table: stores the conversation messages
29+ export interface Message {
14 id: string;30 id: string;
15- userId: string;31+ chatRoomId: string; // Reference to the chat room (ChatRoom.id)
32+ senderId: string; // Reference to the sender (User.id)
16 content: string;33 content: string;
17- timestamp: Date;34+ type: 'chat' | 'emote' | 'system' | string; // Default is 'chat'
18- type: MessageType;35+ timestamp: number; // Timestamp when the message was sent
19-} ⏎̸36+ }⏎̸
M src/routes/+layout.svelte +22 -19
@@ -1,31 +1,34 @@
11 <script lang="ts">
22 import '../app.css';
3+ import DesktopIcons from '$lib/components/desktop-icons.svelte';
4+ import ChatRoom from '$lib/components/chat-room.svelte';
35 let { children } = $props();
6+
7+ let showChatRoom = $state(false);
8+ let showAuth = $state(false);
49 </script>
510
611 <div class="windows-xp-bg">
712 {@render children()}
813 </div>
914
10-
1115 <style>
1216 .windows-xp-bg {
13- position: fixed;
14- top: 0;
15- left: 0;
16- width: 100vw;
17- height: 100vh;
18- margin: 0;
19- padding: 0;
20- overflow: hidden;
21- background: linear-gradient(
22- to bottom,
23- #2195f2 0%, /* Lighter blue at the top */
24- #4aa6e2 25%, /* Mid-light blue */
25- #7ab8e0 50%, /* Mid blue */
26- #86c55c 75%, /* Blue-green transition */
27- #90d047 100% /* Bright green at the bottom */
28- );
29- }
30-
17+ position: fixed;
18+ top: 0;
19+ left: 0;
20+ width: 100vw;
21+ height: 100vh;
22+ margin: 0;
23+ padding: 0;
24+ overflow: hidden;
25+ background: linear-gradient(
26+ to bottom,
27+ #2195f2 0%, /* Lighter blue at the top */
28+ #4aa6e2 25%, /* Mid-light blue */
29+ #7ab8e0 50%, /* Mid blue */
30+ #86c55c 75%, /* Blue-green transition */
31+ #90d047 100% /* Bright green at the bottom */
32+ );
33+ }
3134 </style>⏎̸
@@ -1,31 +1,34 @@
1 <script lang="ts">1 <script lang="ts">
2 import '../app.css';2 import '../app.css';
3+ import DesktopIcons from '$lib/components/desktop-icons.svelte';
4+ import ChatRoom from '$lib/components/chat-room.svelte';
3 let { children } = $props();5 let { children } = $props();
6+
7+ let showChatRoom = $state(false);
8+ let showAuth = $state(false);
4 </script>9 </script>
5 10
6 <div class="windows-xp-bg">11 <div class="windows-xp-bg">
7 {@render children()}12 {@render children()}
8 </div>13 </div>
9 14
10-
11 <style>15 <style>
12 .windows-xp-bg {16 .windows-xp-bg {
13- position: fixed;17+ position: fixed;
14- top: 0;18+ top: 0;
15- left: 0;19+ left: 0;
16- width: 100vw;20+ width: 100vw;
17- height: 100vh;21+ height: 100vh;
18- margin: 0;22+ margin: 0;
19- padding: 0;23+ padding: 0;
20- overflow: hidden;24+ overflow: hidden;
21- background: linear-gradient(25+ background: linear-gradient(
22- to bottom,26+ to bottom,
23- #2195f2 0%, /* Lighter blue at the top */27+ #2195f2 0%, /* Lighter blue at the top */
24- #4aa6e2 25%, /* Mid-light blue */28+ #4aa6e2 25%, /* Mid-light blue */
25- #7ab8e0 50%, /* Mid blue */29+ #7ab8e0 50%, /* Mid blue */
26- #86c55c 75%, /* Blue-green transition */30+ #86c55c 75%, /* Blue-green transition */
27- #90d047 100% /* Bright green at the bottom */31+ #90d047 100% /* Bright green at the bottom */
28- );32+ );
29- }33+ }
30-
31 </style>⏎̸34 </style>⏎̸
M src/routes/+page.svelte +20 -2
@@ -1,9 +1,26 @@
11 <script lang="ts">
22 import ChatRoom from '$lib/components/chat-room.svelte';
3+ import DesktopIcons from '$lib/components/desktop-icons.svelte';
4+ import AimLogin from '$lib/components/aim-login.svelte';
5+
6+ let showChatRoom = $state(true);
7+ let showAuth = $state(false);
8+
9+ function handleAuthClose() {
10+ showAuth = false;
11+ }
312 </script>
413
5-<div class="content-wrapper">
6- <ChatRoom />
14+<div class="desktop content-wrapper">
15+ <DesktopIcons bind:showChatRoom bind:showAuth />
16+
17+ {#if showChatRoom}
18+ <ChatRoom bind:showChatRoom />
19+ {/if}
20+
21+ {#if showAuth}
22+ <AimLogin on:close={handleAuthClose} />
23+ {/if}
724 </div>
825
926 <style>
@@ -15,4 +32,5 @@
1532 justify-content: center;
1633 box-sizing: border-box;
1734 }
35+
1836 </style>⏎̸
@@ -1,9 +1,26 @@
1 <script lang="ts">1 <script lang="ts">
2 import ChatRoom from '$lib/components/chat-room.svelte';2 import ChatRoom from '$lib/components/chat-room.svelte';
3+ import DesktopIcons from '$lib/components/desktop-icons.svelte';
4+ import AimLogin from '$lib/components/aim-login.svelte';
5+
6+ let showChatRoom = $state(true);
7+ let showAuth = $state(false);
8+
9+ function handleAuthClose() {
10+ showAuth = false;
11+ }
3 </script>12 </script>
4 13
5-<div class="content-wrapper">14+<div class="desktop content-wrapper">
6- <ChatRoom />15+ <DesktopIcons bind:showChatRoom bind:showAuth />
16+
17+ {#if showChatRoom}
18+ <ChatRoom bind:showChatRoom />
19+ {/if}
20+
21+ {#if showAuth}
22+ <AimLogin on:close={handleAuthClose} />
23+ {/if}
7 </div>24 </div>
8 25
9 <style>26 <style>
@@ -15,4 +32,5 @@
15 justify-content: center;32 justify-content: center;
16 box-sizing: border-box;33 box-sizing: border-box;
17 }34 }
35+
18 </style>⏎̸36 </style>⏎̸
A static/aim/help-icon.png +0 -0 binary file: no text diff

binary file: no text diff

A static/aim/setup-icon.png +0 -0 binary file: no text diff

binary file: no text diff

A static/aim/signin-icon.png +0 -0 binary file: no text diff

binary file: no text diff

A static/desktop/aim-desktop-icon.png +0 -0 binary file: no text diff

binary file: no text diff

A static/desktop/application-x-shellscript.png +0 -0 binary file: no text diff

binary file: no text diff

M static/favicon.png +0 -0 binary file: no text diff

binary file: no text diff

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