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 Unverified
SHA256:H91DFuEG4jpk5edzLA4aUKkpv4zv9Q339ipRmkph6TkThe key is unknown
25 files · +2559 -78
M .gitignore +2 -0
| @@ -21,3 +21,5 @@ Thumbs.db | |||
| 21 | 21 | # Vite | |
| 22 | 22 | vite.config.js.timestamp-* | |
| 23 | 23 | vite.config.ts.timestamp-* | |
| 24 | + | ||
| 25 | + | docs/⏎̸ | |
| @@ -21,3 +21,5 @@ Thumbs.db | |||||
| 21 | # Vite | 21 | # 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 @@ | |||
| 16 | 16 | "format": "prettier --write .", | |
| 17 | 17 | "lint": "prettier --check . && eslint .", | |
| 18 | 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 | 23 | "devDependencies": { | |
| 22 | 24 | "@eslint/compat": "^1.2.5", | |
| @@ -27,7 +29,9 @@ | |||
| 27 | 29 | "@tailwindcss/container-queries": "^0.1.1", | |
| 28 | 30 | "@tailwindcss/forms": "^0.5.10", | |
| 29 | 31 | "@tailwindcss/typography": "^0.5.16", | |
| 32 | + | "@types/node": "^22.13.1", | |
| 30 | 33 | "autoprefixer": "^10.4.20", | |
| 34 | + | "drizzle-kit": "^0.30.4", | |
| 31 | 35 | "eslint": "^9.18.0", | |
| 32 | 36 | "eslint-config-prettier": "^10.0.1", | |
| 33 | 37 | "eslint-plugin-svelte": "^2.46.1", | |
| @@ -38,12 +42,17 @@ | |||
| 38 | 42 | "svelte": "^5.0.0", | |
| 39 | 43 | "svelte-check": "^4.0.0", | |
| 40 | 44 | "tailwindcss": "^3.4.17", | |
| 45 | + | "tsx": "^4.19.2", | |
| 41 | 46 | "typescript": "^5.0.0", | |
| 42 | 47 | "typescript-eslint": "^8.20.0", | |
| 43 | 48 | "vite": "^6.0.0", | |
| 44 | 49 | "vitest": "^3.0.0" | |
| 45 | 50 | }, | |
| 46 | 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 | 56 | "xp.css": "^0.2.6" | |
| 48 | 57 | } | |
| 49 | 58 | } | |
| @@ -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 @@ | |||
| 1 | 1 | <script lang="ts"> | |
| 2 | 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 | 4 | import { onMount } from 'svelte'; | |
| 5 | 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 | 10 | // 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); | |
| 10 | 13 | let windowX = $state(0); | |
| 11 | 14 | let windowY = $state(0); | |
| 12 | 15 | let isMobile = $state(false); | |
| 13 | 16 | let currentMessage = $state(''); | |
| 14 | 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 | 28 | // Reactive state using derived values | |
| 17 | 29 | let messages = $derived(chatState.getMessages()); | |
| @@ -29,8 +41,8 @@ onMount(() => { | |||
| 29 | 41 | windowX = 0; | |
| 30 | 42 | windowY = 0; | |
| 31 | 43 | } 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)); | |
| 34 | 46 | windowX = Math.max(0, (window.innerWidth - windowWidth) / 2); | |
| 35 | 47 | windowY = Math.max(0, (window.innerHeight - windowHeight) / 2); | |
| 36 | 48 | } | |
| @@ -67,23 +79,69 @@ function getStatusIcon(status: User['status']) { | |||
| 67 | 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 | 112 | </script> | |
| 71 | 113 | ||
| 114 | + | {#if showChatRoom} | |
| 72 | 115 | <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} | |
| 79 | 133 | > | |
| 80 | 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 | 136 | <div class="title-bar-controls"> | |
| 83 | 137 | {#if !isMobile} | |
| 84 | 138 | <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> | |
| 87 | 145 | {:else} | |
| 88 | 146 | <button | |
| 89 | 147 | aria-label="Toggle Buddy List" | |
| @@ -139,8 +197,8 @@ function getStatusIcon(status: User['status']) { | |||
| 139 | 197 | <span class="status-icon">{getStatusIcon(user.status)}</span> | |
| 140 | 198 | <div class="user-info"> | |
| 141 | 199 | <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> | |
| 144 | 202 | {/if} | |
| 145 | 203 | </div> | |
| 146 | 204 | </div> | |
| @@ -148,18 +206,27 @@ function getStatusIcon(status: User['status']) { | |||
| 148 | 206 | </div> | |
| 149 | 207 | </div> | |
| 150 | 208 | </div> | |
| 209 | + | {/if} | |
| 151 | 210 | ||
| 152 | 211 | <style> | |
| 153 | 212 | :global(*) { | |
| 154 | 213 | font-size: 1rem; | |
| 214 | + | font-family: 'Microsoft Sans Serif', 'Segoe UI', Tahoma, sans-serif; | |
| 155 | 215 | } | |
| 156 | 216 | ||
| 157 | 217 | .title-bar { | |
| 158 | 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 | 229 | .chat-area { | |
| 162 | - | font-family: 'Tahoma', sans-serif; | |
| 163 | 230 | line-height: 1.4; | |
| 164 | 231 | } | |
| 165 | 232 | ||
| @@ -243,6 +310,18 @@ function getStatusIcon(status: User['status']) { | |||
| 243 | 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 | 325 | @media (max-width: 768px) { | |
| 247 | 326 | .window { | |
| 248 | 327 | position: fixed !important; | |
| @@ -280,5 +359,9 @@ function getStatusIcon(status: User['status']) { | |||
| 280 | 359 | padding: 0.5rem; | |
| 281 | 360 | border-top: 0.125rem solid #dfdfdf; | |
| 282 | 361 | } | |
| 362 | + | ||
| 363 | + | :global(.resize-handle) { | |
| 364 | + | display: none; | |
| 365 | + | } | |
| 283 | 366 | } | |
| 284 | 367 | </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 SSR | 10 | // 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 values | 28 | // 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 | + | */ | |
| 2 | 5 | ||
| 6 | + | // Users table: holds authentication and profile details | |
| 3 | 7 | export interface User { | |
| 4 | 8 | id: string; | |
| 9 | + | email: string; | |
| 10 | + | password: string; // hashed password | |
| 5 | 11 | nickname: string; | |
| 6 | - | status: UserStatus; | |
| 7 | - | statusMessage?: string; | |
| 12 | + | status: UserStatus; // default is 'offline' | |
| 8 | 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'; | |
| 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 { | |
| 14 | 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 | 33 | 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 @@ | |||
| 1 | 1 | <script lang="ts"> | |
| 2 | 2 | import '../app.css'; | |
| 3 | + | import DesktopIcons from '$lib/components/desktop-icons.svelte'; | |
| 4 | + | import ChatRoom from '$lib/components/chat-room.svelte'; | |
| 3 | 5 | let { children } = $props(); | |
| 6 | + | ||
| 7 | + | let showChatRoom = $state(false); | |
| 8 | + | let showAuth = $state(false); | |
| 4 | 9 | </script> | |
| 5 | 10 | ||
| 6 | 11 | <div class="windows-xp-bg"> | |
| 7 | 12 | {@render children()} | |
| 8 | 13 | </div> | |
| 9 | 14 | ||
| 10 | - | ||
| 11 | 15 | <style> | |
| 12 | 16 | .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 | + | } | |
| 31 | 34 | </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 @@ | |||
| 1 | 1 | <script lang="ts"> | |
| 2 | 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 | 12 | </script> | |
| 4 | 13 | ||
| 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} | |
| 7 | 24 | </div> | |
| 8 | 25 | ||
| 9 | 26 | <style> | |
| @@ -15,4 +32,5 @@ | |||
| 15 | 32 | justify-content: center; | |
| 16 | 33 | box-sizing: border-box; | |
| 17 | 34 | } | |
| 35 | + | ||
| 18 | 36 | </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