diff --git a/bun.lock b/bun.lock index 52bf286..f72589f 100644 --- a/bun.lock +++ b/bun.lock @@ -14,7 +14,6 @@ "iovalkey": "^0.3.3", "jsonwebtoken": "^9.0.3", "nodemailer": "^7.0.12", - "pg": "^8.16.3", "qrcode": "^1.5.4", "sharp": "^0.34.5", "speakeasy": "^2.0.0", @@ -39,7 +38,6 @@ "@types/colorthief": "^2.6.0", "@types/jsonwebtoken": "^9.0.10", "@types/nodemailer": "^7.0.5", - "@types/pg": "^8.16.0", "@types/qrcode": "^1.5.6", "@types/speakeasy": "^2.0.10", "bits-ui": "^2.15.4", @@ -632,8 +630,6 @@ "@types/nodemailer": ["@types/nodemailer@7.0.5", "", { "dependencies": { "@aws-sdk/client-sesv2": "^3.839.0", "@types/node": "*" } }, "sha512-7WtR4MFJUNN2UFy0NIowBRJswj5KXjXDhlZY43Hmots5eGu5q/dTeFd/I6GgJA/qj3RqO6dDy4SvfcV3fOVeIA=="], - "@types/pg": ["@types/pg@8.16.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ=="], - "@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="], "@types/speakeasy": ["@types/speakeasy@2.0.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-QVRlDW5r4yl7p7xkNIbAIC/JtyOcClDIIdKfuG7PWdDT1MmyhtXSANsildohy0K+Lmvf/9RUtLbNLMacvrVwxA=="], @@ -1214,22 +1210,6 @@ "peek-readable": ["peek-readable@4.1.0", "", {}, "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg=="], - "pg": ["pg@8.17.1", "", { "dependencies": { "pg-connection-string": "^2.10.0", "pg-pool": "^3.11.0", "pg-protocol": "^1.11.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.3.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-EIR+jXdYNSMOrpRp7g6WgQr7SaZNZfS7IzZIO0oTNEeibq956JxeD15t3Jk3zZH0KH8DmOIx38qJfQenoE8bXQ=="], - - "pg-cloudflare": ["pg-cloudflare@1.3.0", "", {}, "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ=="], - - "pg-connection-string": ["pg-connection-string@2.10.0", "", {}, "sha512-ur/eoPKzDx2IjPaYyXS6Y8NSblxM7X64deV2ObV57vhjsWiwLvUD6meukAzogiOsu60GO8m/3Cb6FdJsWNjwXg=="], - - "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], - - "pg-pool": ["pg-pool@3.11.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-MJYfvHwtGp870aeusDh+hg9apvOe2zmpZJpyt+BMtzUWlVqbhFmMK6bOBXLBUPd7iRtIF9fZplDc7KrPN3PN7w=="], - - "pg-protocol": ["pg-protocol@1.11.0", "", {}, "sha512-pfsxk2M9M3BuGgDOfuy37VNRRX3jmKgMjcvAcWqNDpZSf4cUmv8HSOl5ViRQFsfARFn0KuUQTgLxVMbNq5NW3g=="], - - "pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], - - "pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="], - "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], @@ -1260,14 +1240,6 @@ "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], - "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], - - "postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="], - - "postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="], - - "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], - "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "prettier": ["prettier@3.8.0", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-yEPsovQfpxYfgWNhCfECjG5AQaO+K3dp6XERmOepyPDVqcJm+bjyCVO3pmU+nAPe0N5dDvekfGezt/EIiRe1TA=="], @@ -1368,8 +1340,6 @@ "speakeasy": ["speakeasy@2.0.0", "", { "dependencies": { "base32.js": "0.0.1" } }, "sha512-lW2A2s5LKi8rwu77ewisuUOtlCydF/hmQSOJjpTqTj1gZLkNgTaYnyvfxy2WBr4T/h+9c4g8HIITfj83OkFQFw=="], - "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], - "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], "standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="], @@ -1524,8 +1494,6 @@ "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], - "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], - "y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="], "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], diff --git a/docker-compose.prod.yaml b/docker-compose.prod.yaml index 716a4c8..c0495b4 100755 --- a/docker-compose.prod.yaml +++ b/docker-compose.prod.yaml @@ -34,7 +34,6 @@ services: - .env volumes: - pg_data:/var/lib/postgresql - - ./sql/init.sql:/docker-entrypoint-initdb.d/init.sql:Z valkey: container_name: outfiter_valkey diff --git a/docker-compose.yaml b/docker-compose.yaml index b464c12..1b569e8 100755 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -36,7 +36,6 @@ services: - .env volumes: - pg_data:/var/lib/postgresql - - ./sql/init.sql:/docker-entrypoint-initdb.d/init.sql:Z valkey: container_name: valkey diff --git a/entrypoint.sh b/entrypoint.sh index 6bab645..ff8af27 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -3,10 +3,8 @@ set -e -# Import environment variables from .env file -if [ -f .env ]; then - export $(grep -v '^#' .env | xargs) -fi +# Since the DATABASE_URL en var is constructed using other en vars, we need to export it here to account for overrides from docker-compose +export DATABASE_URL="postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}" mkdir -p /app/assets/{profile_pictures,clothing_item,publication} diff --git a/package.json b/package.json index f1f457e..cdd0c36 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,6 @@ "@types/colorthief": "^2.6.0", "@types/jsonwebtoken": "^9.0.10", "@types/nodemailer": "^7.0.5", - "@types/pg": "^8.16.0", "@types/qrcode": "^1.5.6", "@types/speakeasy": "^2.0.10", "bits-ui": "^2.15.4", @@ -80,7 +79,6 @@ "iovalkey": "^0.3.3", "jsonwebtoken": "^9.0.3", "nodemailer": "^7.0.12", - "pg": "^8.16.3", "qrcode": "^1.5.4", "sharp": "^0.34.5", "speakeasy": "^2.0.0", diff --git a/scripts/db/makeAdmin.ts b/scripts/db/makeAdmin.ts index 475339f..ffc1222 100644 --- a/scripts/db/makeAdmin.ts +++ b/scripts/db/makeAdmin.ts @@ -3,7 +3,7 @@ // -- regular user into an admin -- // ----------------------------------- -import pool from './pool.ts'; +import { sql } from 'bun'; if (Bun.argv.length !== 3) { throw new Error('Usage: bun run db:make-admin '); @@ -11,7 +11,7 @@ if (Bun.argv.length !== 3) { const username = Bun.argv[2]; -await pool.query("UPDATE users SET role = 'admin' WHERE username = $1", [username]); +await sql`UPDATE users SET role = 'admin' WHERE username = ${username}`; console.log(`Made user ${username} an admin!`); process.exit(0); diff --git a/scripts/db/migrate.ts b/scripts/db/migrate.ts index 8bc11ae..b95358f 100755 --- a/scripts/db/migrate.ts +++ b/scripts/db/migrate.ts @@ -5,42 +5,31 @@ // -- already been done. -- // --------------------------------------------------------- -import pool from './pool.ts'; import { HERE } from '../shared.ts'; -import { readdir, readFile } from 'node:fs/promises'; +import { readdir } from 'node:fs/promises'; import { join } from 'node:path'; +import { sql } from 'bun'; -const getLastMigrationDate = async (): Promise => { - const { rows } = await pool.query<{ created_at: string }>( - 'SELECT created_at FROM migrations ORDER BY created_at DESC LIMIT 1' - ); - return rows.length > 0 ? new Date(rows[0].created_at) : new Date(0); +const initScriptPath = join(HERE, `../sql/migrations/init.sql`); +await sql.file(initScriptPath); + +const getAppliedMigrations = async (): Promise> => { + const rows = await sql<{ name: string }[]>`SELECT name FROM migrations`; + return new Set(rows.map((row) => row.name)); }; async function applyMigration(name: string) { const migrationPath = join(HERE, `../sql/migrations/${name}`); - const sql = await readFile(migrationPath, 'utf8'); - if (!sql) { - throw new Error(`Migration file ${name} not found.`); - } - const migrationSQL = await pool.query(sql); + await sql.file(migrationPath); const timeStamp = name.match(/migration\.(\d+)\.sql/); const migrationTime = new Date(parseInt(timeStamp![1], 10)); // Update migrations table - await pool.query('INSERT INTO migrations (name, created_at) VALUES ($1, $2)', [ - name, - migrationTime, - ]); + await sql`INSERT INTO migrations (name, created_at) VALUES (${name}, ${migrationTime})`; console.log(`Migration ${name} applied successfully.`); - return migrationSQL; } -const lastMigrationDate = await getLastMigrationDate(); -if (lastMigrationDate) { - console.log(`Last migration: ${lastMigrationDate}`); -} else { - console.log('No previous migrations found.'); -} +const appliedMigrations = await getAppliedMigrations(); +console.log(`Applied migrations: ${appliedMigrations.size}`); const availableMigrations = (await readdir(join(HERE, '../sql/migrations'))) .filter((f) => f.match(/migration\.(\d+)\.sql/)) @@ -50,14 +39,7 @@ const availableMigrations = (await readdir(join(HERE, '../sql/migrations'))) return timeA - timeB; }); -const newMigrations = availableMigrations - .filter((file) => { - const timeStamp = file.match(/migration\.(\d+)\.sql/); - if (!timeStamp) return false; - const migrationTime = new Date(parseInt(timeStamp[1], 10)); - return !lastMigrationDate || migrationTime > lastMigrationDate; - }) - .sort(); +const newMigrations = availableMigrations.filter((file) => !appliedMigrations.has(file)); console.log(`Available migrations: ${availableMigrations.length}`); console.log(`New migrations to apply: ${newMigrations.length}`); diff --git a/scripts/db/pool.ts b/scripts/db/pool.ts deleted file mode 100755 index 4a1ef49..0000000 --- a/scripts/db/pool.ts +++ /dev/null @@ -1,26 +0,0 @@ -import pg from 'pg'; - -const { Pool } = pg; - -let pool: pg.Pool; - -try { - pool = new Pool({ - host: process.env.POSTGRES_HOST, - user: process.env.POSTGRES_USER, - password: process.env.POSTGRES_PASSWORD, - database: process.env.POSTGRES_DB, - port: parseInt(process.env.POSTGRES_PORT ?? '', 10) || 5432, - connectionTimeoutMillis: 5000, - idleTimeoutMillis: 30000, - }); - - pool.on('error', (err) => { - console.error('Unexpected error on idle client', err); - }); -} catch (error) { - console.error('Failed to create database pool:', error); - throw error; -} - -export default pool; diff --git a/sql/assets.zip b/sql/assets.zip deleted file mode 100644 index ba24db3..0000000 Binary files a/sql/assets.zip and /dev/null differ diff --git a/sql/bootstrap.sql b/sql/bootstrap.sql deleted file mode 100644 index ddd0972..0000000 --- a/sql/bootstrap.sql +++ /dev/null @@ -1,235 +0,0 @@ --- Test user : --- Username: test --- Email: test@test.test --- Password: testtest - -INSERT INTO public.users (id, email, username, password_hash, created_at) VALUES -('a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'test@test.test', 'test', '$2b$10$k4y0Gn/gfHeWyhpzI7hba.pJMNcZqibUln2p6pY1AHM5QEpPW04CK', '2025-11-21 08:42:46.715068'), -('db43a681-0f60-410d-8a81-3db2a45a9633', 'angus@angus.fr', 'Angus', '$2b$10$k4y0Gn/gfHeWyhpzI7hba.pJMNcZqibUln2p6pY1AHM5QEpPW04CK', '2025-11-21 08:42:46.715068'), -('b2079a72-65da-4b78-ac28-7728dabe54b9', 'paqui@paqui.fr', 'Paqui', '$2b$10$k4y0Gn/gfHeWyhpzI7hba.pJMNcZqibUln2p6pY1AHM5QEpPW04CK', '2025-11-21 08:42:46.715068'), -('f79374b7-7f31-4006-b357-f60a1383951a', 'maxence@maxence.fr', 'Maxence', '$2b$10$k4y0Gn/gfHeWyhpzI7hba.pJMNcZqibUln2p6pY1AHM5QEpPW04CK', '2025-11-21 08:42:46.715068'), -('dca1ea5c-040f-4458-a9c4-ca224a2d9912', 'neo@neo.fr', 'Néo', '$2b$10$k4y0Gn/gfHeWyhpzI7hba.pJMNcZqibUln2p6pY1AHM5QEpPW04CK', '2025-11-21 08:42:46.715068'), -('8ae6fb1c-6644-4bb6-b852-fad6b44a821a', 'yann@yann.fr', 'Yann', '$2b$10$k4y0Gn/gfHeWyhpzI7hba.pJMNcZqibUln2p6pY1AHM5QEpPW04CK', '2025-11-21 08:42:46.715068'), -('234f9429-d3b9-4e86-a17b-546ce08f50dd', 'clement@clement.fr', 'Clément', '$2b$10$k4y0Gn/gfHeWyhpzI7hba.pJMNcZqibUln2p6pY1AHM5QEpPW04CK', '2025-11-21 08:42:46.715068'), --- Dummy users (for reactions) -('11111111-1111-1111-1111-111111111111', 'alice@test.dev', 'alice', 'hash', now()), -('22222222-2222-2222-2222-222222222222', 'bob@test.dev', 'bob', 'hash', now()), -('33333333-3333-3333-3333-333333333333', 'charlie@test.dev', 'charlie', 'hash', now()), -('44444444-4444-4444-4444-444444444444', 'diana@test.dev', 'diana', 'hash', now()), -('55555555-5555-5555-5555-555555555555', 'ed@test.dev', 'ed', 'hash', now()), -('66666666-6666-6666-6666-666666666666', 'fiona@test.dev', 'fiona', 'hash', now()), -('77777777-7777-7777-7777-777777777777', 'george@test.dev', 'george', 'hash', now()); - - -INSERT INTO public.clothing_item (id, user_id, name, type, color) VALUES -('6eac116a-fc20-4229-aa4b-7aec8c52c0b6', 'b2079a72-65da-4b78-ac28-7728dabe54b9', 'Black shirt', 'shirt', 'black'), -('6ac8b8a3-2f3b-41a4-a54e-2270a94b010f', 'db43a681-0f60-410d-8a81-3db2a45a9633', 'Blue shirt', 'shirt', 'blue'), -('eb7ac92e-9e2a-4839-938c-5f44fc5428ad', 'b2079a72-65da-4b78-ac28-7728dabe54b9', 'White boxy shirt', 'shirt', 'white'), -('985b0ca5-2cdc-4abe-971c-9271af767b81', '234f9429-d3b9-4e86-a17b-546ce08f50dd', 'Blue long sleeve shirt', 'shirt', 'blue'), -('ca727857-1425-4643-9cb6-f666f60d9321', 'dca1ea5c-040f-4458-a9c4-ca224a2d9912', 'White shoes', 'shoes', 'white'), -('dbb24210-100c-446a-bf13-b317e043b0f4', 'f79374b7-7f31-4006-b357-f60a1383951a', 'Boots', 'shoes', 'yellow'), -('d432cda3-b385-48bf-9073-8c1ca60d3d38', 'db43a681-0f60-410d-8a81-3db2a45a9633', 'Red sweater', 'sweater', 'red'), -('45efc73a-03d8-46a8-a476-dbac6b515b6a', 'dca1ea5c-040f-4458-a9c4-ca224a2d9912', 'Brown sweater', 'sweater', 'brown'), -('20da05b1-ecca-41d0-a0a3-7c838becdd69', '234f9429-d3b9-4e86-a17b-546ce08f50dd', 'Black sweater', 'sweater', 'black'), -('95806fba-b9bb-4259-8d3d-70ee2517ff91', '234f9429-d3b9-4e86-a17b-546ce08f50dd', 'Gray sweater', 'sweater', 'gray'), -('30fd6c47-3046-4ec4-8d62-cb15b896e487', '234f9429-d3b9-4e86-a17b-546ce08f50dd', 'Striped green pullover', 'sweater', 'green'), -('b20f86db-2584-4264-ae19-e22a8c93095c', '234f9429-d3b9-4e86-a17b-546ce08f50dd', 'Yellow pullover', 'sweater', 'yellow'), -('8e1d617e-3639-44a5-8ea8-88d0751d5058', 'dca1ea5c-040f-4458-a9c4-ca224a2d9912', 'Blue jeans', 'pants', 'blue'), -('730aac07-983c-4ece-99f0-9775d425abd4', 'b2079a72-65da-4b78-ac28-7728dabe54b9', 'Black pants', 'pants', 'black'), -('6e901b26-d6e6-499d-860c-c67afd533b79', 'dca1ea5c-040f-4458-a9c4-ca224a2d9912', 'Gray sweater pants', 'pants', 'gray'), -('5fe3f5c9-923b-447b-bb30-df39f665cb42', 'dca1ea5c-040f-4458-a9c4-ca224a2d9912', 'Purple sweater pants', 'pants', 'purple'), -('1245574f-606f-4f8a-ab1e-554b7c646964', '8ae6fb1c-6644-4bb6-b852-fad6b44a821a', 'Brown puffer', 'jacket', 'brown'), -('765b168c-c84d-4aea-bbb9-9b2ac0a1f901', 'db43a681-0f60-410d-8a81-3db2a45a9633', 'Black leather jacket', 'jacket', 'black'), -('34192650-280b-4472-a031-216c7bd324e1', '234f9429-d3b9-4e86-a17b-546ce08f50dd', 'Black leather jacket', 'jacket', 'black'), -('875e1ec1-602e-4c89-9556-56dccb846baa', 'b2079a72-65da-4b78-ac28-7728dabe54b9', 'Blue denim jacket', 'jacket', 'blue'), -('2d61f298-d5f4-4cad-921c-b486ed426d4a', 'db43a681-0f60-410d-8a81-3db2a45a9633', 'Dark denim jacket', 'jacket', 'black'), -('3b592e54-bc5f-45a9-9120-59719e227ccb', 'f79374b7-7f31-4006-b357-f60a1383951a', 'White pants', 'pants', 'white'), -('3f6d534c-111a-4ca2-b0d2-bb6c3f7442ec', 'f79374b7-7f31-4006-b357-f60a1383951a', 'White & pink watch', 'accessory', 'white'), -('4b6a668a-c8f7-4076-b03b-6a53f3788fec', 'f79374b7-7f31-4006-b357-f60a1383951a', 'Gray Nike joggers', 'pants', 'gray'), -('4d200693-8e67-41b1-b611-daf3cebdb391', 'db43a681-0f60-410d-8a81-3db2a45a9633', 'Blue jeans', 'pants', 'blue'), -('05f54653-503e-4f85-8153-e8cded4c49ec', 'db43a681-0f60-410d-8a81-3db2a45a9633', 'Yellow t-shirt', 'shirt', 'yellow'), -('6e13a2c9-5bfa-4f3a-b714-acc7cac9be36', '8ae6fb1c-6644-4bb6-b852-fad6b44a821a', 'Blue cap', 'accessory', 'blue'), -('8ba04ae8-0174-4725-a18f-4684005ba9d9', '234f9429-d3b9-4e86-a17b-546ce08f50dd', 'Blue jeans', 'pants', 'blue'), -('22ef05ab-7a14-4026-aee0-551d0ca170ce', '8ae6fb1c-6644-4bb6-b852-fad6b44a821a', 'Brown loafers', 'shoes', 'brown'), -('70f0aef1-d003-43ab-8bbc-f672bbd1f953', 'f79374b7-7f31-4006-b357-f60a1383951a', 'White printer t-shirt', 'shirt', 'white'), -('a34e6f7c-f55b-4b27-8347-6e5f39dfe26d', '8ae6fb1c-6644-4bb6-b852-fad6b44a821a', 'Pink fitted t-shirt', 'shirt', 'pink'), -('a50fd24f-f697-497a-89fc-cf774447453d', 'f79374b7-7f31-4006-b357-f60a1383951a', 'Green distressed bag', 'accessory', 'green'), -('aabea46e-fd75-47be-8432-9cba56c1bc52', 'db43a681-0f60-410d-8a81-3db2a45a9633', 'Blue jeans', 'pants', 'blue'), -('b4dc799c-ba46-4c72-8165-6e29f18c5ef9', 'f79374b7-7f31-4006-b357-f60a1383951a', 'Blue jeans', 'pants', 'blue'), -('c1b5cec7-30c0-4d16-8300-45522e4478ab', 'dca1ea5c-040f-4458-a9c4-ca224a2d9912', 'Red fitted t-shirt', 'shirt', 'red'), -('c1d1755d-d872-4f08-b80e-1323c71e620e', 'dca1ea5c-040f-4458-a9c4-ca224a2d9912', 'Beige pants', 'pants', 'white'), -('cd9eb4b7-871d-4801-a7bc-ecdb1d94c090', 'db43a681-0f60-410d-8a81-3db2a45a9633', 'Ted t-shirt', 'shirt', 'red'), -('de6f1750-e850-4412-8402-fff4f1223376', 'dca1ea5c-040f-4458-a9c4-ca224a2d9912', 'Denim skirt', 'pants', 'blue'), -('e3aafd93-feee-490e-801f-34312a13fb86', 'dca1ea5c-040f-4458-a9c4-ca224a2d9912', 'Converse shoes', 'shoes', 'black'), -('e9dc8f3b-ac0e-490c-a2d5-19ce99f6f6c6', 'f79374b7-7f31-4006-b357-f60a1383951a', 'Asics shoes', 'shoes', 'brown'), -('e16eed9f-5345-4e2a-af15-9e690270072b', 'f79374b7-7f31-4006-b357-f60a1383951a', 'Black boots', 'shoes', 'black'), -('e9741e11-97eb-4677-aa17-c0a43bc4bf13', 'b2079a72-65da-4b78-ac28-7728dabe54b9', 'Dark blue jeans', 'pants', 'blue'), -('e6820933-00fb-44c9-8667-fd0912c963ff', '8ae6fb1c-6644-4bb6-b852-fad6b44a821a', 'Black printed sweater', 'sweater', 'black'), -('7f3f24c6-696e-45d7-99eb-c6053394113a', '8ae6fb1c-6644-4bb6-b852-fad6b44a821a', 'Red sweater', 'sweater', 'red'), -('45fd8039-2f9e-4a0e-91ce-f11edf76fd23', 'b2079a72-65da-4b78-ac28-7728dabe54b9', 'Green sweater', 'sweater', 'green'), -('57af8738-5e11-4c4c-9972-98184616a551', '8ae6fb1c-6644-4bb6-b852-fad6b44a821a', 'Dark red sweater', 'sweater', 'red'), -('67ba3b61-f36d-48b7-9d5a-52dc03d3b9f2', 'dca1ea5c-040f-4458-a9c4-ca224a2d9912', 'Black sweater', 'sweater', 'black'), -('0775f0f9-c215-4b7c-9e80-68146067fcc4', '234f9429-d3b9-4e86-a17b-546ce08f50dd', 'Gray hoodie', 'sweater', 'gray'), -('d515ceee-8554-4976-8377-5676d6e6db4c', 'db43a681-0f60-410d-8a81-3db2a45a9633', 'Black windbreaker', 'jacket', 'black'), -('e502afe4-3ce9-4ef1-a8f1-a2fc3197b1ee', 'f79374b7-7f31-4006-b357-f60a1383951a', 'Blue hoodie', 'sweater', 'blue'), -('ef08589f-19f8-4a9c-85c6-8795f9b101ff', 'dca1ea5c-040f-4458-a9c4-ca224a2d9912', 'Black hoodie', 'sweater', 'black'), -('f17fb603-edee-4a4c-9de1-0e27213c09e1', 'b2079a72-65da-4b78-ac28-7728dabe54b9', 'Black hoodie', 'sweater', 'black'), -('f4271462-a141-4b33-b2bf-816f70162395', '8ae6fb1c-6644-4bb6-b852-fad6b44a821a', 'Dark blue hoodie', 'sweater', 'blue'), --- Test user has all clothing items -('0a304bf7-1604-4e0f-b0b4-f55eada0191f', 'a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'Black shirt', 'shirt', 'black'), -('5d8a1ae5-41f2-4c0a-9ba2-0cce7703acca', 'a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'Blue shirt', 'shirt', 'blue'), -('f1cfb503-476c-42a4-a6c8-6cc576853f5d', 'a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'White boxy shirt', 'shirt', 'white'), -('f0e3df01-1517-4642-9899-4ff1a87a878d', 'a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'Blue long sleeve shirt', 'shirt', 'blue'), -('537a195c-028e-4515-b60a-039e1005c38c', 'a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'White shoes', 'shoes', 'white'), -('258ffc80-71a4-47a4-9a67-b7d6c2c554ec', 'a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'Boots', 'shoes', 'yellow'), -('30ca3b2a-b451-454b-8f51-9b23f22bab7f', 'a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'Red sweater', 'sweater', 'red'), -('aaf5f4f9-0607-4322-a7d2-95f02d040691', 'a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'Brown sweater', 'sweater', 'brown'), -('230b9a41-b869-4f62-9871-0c84efa2221d', 'a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'Black sweater', 'sweater', 'black'), -('81b75449-6bdf-4715-a552-d279b0744bc1', 'a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'Gray sweater', 'sweater', 'gray'), -('96843892-488c-49ff-b5cd-a16c2849a616', 'a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'Striped green pullover', 'sweater', 'green'), -('d7923e1b-46a3-475f-93d5-e1af7521fbb1', 'a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'Yellow pullover', 'sweater', 'yellow'), -('7c539c4f-4bfb-4093-80f1-44ef88b44a1c', 'a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'Blue jeans', 'pants', 'blue'), -('8c1e65e9-7e83-4c79-ae01-1fba7ee34a15', 'a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'Black pants', 'pants', 'black'), -('99cd391a-5a2a-4ae5-b3eb-f86dbef5c8e2', 'a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'Gray sweater pants', 'pants', 'gray'), -('15ef2dfb-ecff-40e7-b877-a42c6340a6d0', 'a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'Purple sweater pants', 'pants', 'purple'), -('78d11ebc-f673-4b40-828d-591b88f21e35', 'a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'Brown puffer', 'jacket', 'brown'), -('b2d4876a-775c-4470-886d-113b5f43d44e', 'a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'Black leather jacket', 'jacket', 'black'), -('386e6505-7f9a-4c11-b9e2-a9944f8c23ca', 'a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'Black leather jacket', 'jacket', 'black'), -('eb0330c8-4935-4ead-b8c0-4ed27ac00eaf', 'a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'Blue denim jacket', 'jacket', 'blue'), -('cfb34522-d2c4-458e-8700-12cd35cf68ca', 'a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'Dark denim jacket', 'jacket', 'black'), -('d717a850-ef69-4cc6-887f-47fe37c979a5', 'a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'White pants', 'pants', 'white'), -('d1a069f6-d5e0-4d1e-831b-125789811787', 'a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'White & pink watch', 'accessory', 'white'), -('348bebaa-f194-4900-ba4b-b684dc9ce64f', 'a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'Gray Nike joggers', 'pants', 'gray'), -('728de5e4-9b08-4959-b712-2ff22e111e83', 'a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'Blue jeans', 'pants', 'blue'), -('54318b59-4968-420f-af12-d331204bba43', 'a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'Yellow t-shirt', 'shirt', 'yellow'), -('a26a1960-52cb-4daf-bac6-85758bb18af3', 'a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'Blue cap', 'accessory', 'blue'), -('f05c1c95-0b6b-443a-a0fc-5fe25ce4de02', 'a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'Blue jeans', 'pants', 'blue'), -('7d172c9d-43d2-41c5-8e58-14f32abe7a50', 'a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'Brown loafers', 'shoes', 'brown'), -('5c8e46f7-8d56-43e8-b440-efa308ca9b4d', 'a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'White printer t-shirt', 'shirt', 'white'), -('a3b0f7fc-c3de-4572-862a-2caa31e28e0c', 'a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'Pink fitted t-shirt', 'shirt', 'pink'), -('d4ebd351-dc7d-4545-9c49-062f43ac98ea', 'a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'Green distressed bag', 'accessory', 'green'), -('b607a920-eb81-4e92-9414-147103708115', 'a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'Blue jeans', 'pants', 'blue'), -('b0ff80e4-ba06-4e1e-bc76-84efd4be6096', 'a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'Blue jeans', 'pants', 'blue'), -('79cc761a-e83a-4e48-a952-56fe0e1e4ccd', 'a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'Red fitted t-shirt', 'shirt', 'red'), -('2092a297-aa27-4491-9f04-da77e7ed82a6', 'a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'Beige pants', 'pants', 'white'), -('6af86d26-789c-4198-967e-731eeeffaca7', 'a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'Ted t-shirt', 'shirt', 'red'), -('e70846e2-0b46-4399-bcbe-00d6cd778970', 'a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'Denim skirt', 'pants', 'blue'), -('0036898d-5d31-49a3-bc0c-793387319cea', 'a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'Converse shoes', 'shoes', 'black'), -('22f6f1bf-1331-4be7-b25a-79109ffcd850', 'a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'Asics shoes', 'shoes', 'brown'), -('66a092ec-3835-48dd-bffd-23f7baab7107', 'a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'Black boots', 'shoes', 'black'), -('8cb2374a-d9b5-44af-9be6-edc391c1efb7', 'a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'Dark blue jeans', 'pants', 'blue'), -('f9c844d9-e31c-42a6-863d-91f4c7a8ad1b', 'a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'Black printed sweater', 'sweater', 'black'), -('d52bd870-1d29-4060-9bd2-4f3a58e46958', 'a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'Red sweater', 'sweater', 'red'), -('d47971b7-fda4-433c-8541-93c3757f28c6', 'a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'Green sweater', 'sweater', 'green'), -('4e560b23-1b00-4c9e-93fb-c3580bd15912', 'a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'Dark red sweater', 'sweater', 'red'), -('c6e0155a-de46-4034-bbfe-1acd7c810dd4', 'a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'Black sweater', 'sweater', 'black'), -('aa9c313c-0894-491c-b69d-eb866787610c', 'a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'Gray hoodie', 'sweater', 'gray'), -('e984be51-c76b-4b3a-bff9-77807887d49f', 'a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'Black windbreaker', 'jacket', 'black'), -('730f35f3-0c24-4482-9623-00b0aa959185', 'a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'Blue hoodie', 'sweater', 'blue'), -('6279785a-5517-40df-94ef-32be1c1ca237', 'a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'Black hoodie', 'sweater', 'black'), -('627fe089-7b01-452c-bb2d-174e4342dbe3', 'a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'Black hoodie', 'sweater', 'black'), -('fd8c02e9-4065-4a31-8e83-2b49c094f6df', 'a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'Dark blue hoodie', 'sweater', 'blue'); - -INSERT INTO public.outfit (id, user_id, created_at) VALUES -('0d199683-b419-45d3-8e3e-1eaa46001dc5', 'db43a681-0f60-410d-8a81-3db2a45a9633', now() - interval '15 days'), -('06d3fb17-238e-4f94-83a7-0c439f340e7c', 'db43a681-0f60-410d-8a81-3db2a45a9633', now() - interval '12 days'), -('07150517-05fd-4f4e-96a1-aa732ef929e1', 'b2079a72-65da-4b78-ac28-7728dabe54b9', now() - interval '30 days'), -('2a28f904-171b-455f-8caa-e3940a612c5a', 'b2079a72-65da-4b78-ac28-7728dabe54b9', now() - interval '7 days'), -('221b3dad-53aa-4a7d-bf09-25ea4bc1696d', '234f9429-d3b9-4e86-a17b-546ce08f50dd', now() - interval '6 days'), -('595a6312-72ca-42ab-94b6-484d39af498a', 'f79374b7-7f31-4006-b357-f60a1383951a', now() - interval '31 days'), -('bd4e5e11-7dd1-4677-a265-c802627d79f3', '234f9429-d3b9-4e86-a17b-546ce08f50dd', now() - interval '18 days'), -('cd75a0c3-13e1-4a6e-a219-d5b67b566db3', 'f79374b7-7f31-4006-b357-f60a1383951a', now() - interval '5 days'), -('fac3beaa-8605-467c-aab2-0ac2e9459983', '8ae6fb1c-6644-4bb6-b852-fad6b44a821a', now() - interval '23 days'); - -INSERT INTO outfit_clothing_items (outfit_id, clothing_item_id) VALUES -('06d3fb17-238e-4f94-83a7-0c439f340e7c', 'ca727857-1425-4643-9cb6-f666f60d9321'), -('06d3fb17-238e-4f94-83a7-0c439f340e7c', '4d200693-8e67-41b1-b611-daf3cebdb391'), -('06d3fb17-238e-4f94-83a7-0c439f340e7c', 'b20f86db-2584-4264-ae19-e22a8c93095c'), -('06d3fb17-238e-4f94-83a7-0c439f340e7c', 'd515ceee-8554-4976-8377-5676d6e6db4c'), -('0d199683-b419-45d3-8e3e-1eaa46001dc5', '6ac8b8a3-2f3b-41a4-a54e-2270a94b010f'), -('0d199683-b419-45d3-8e3e-1eaa46001dc5', 'aabea46e-fd75-47be-8432-9cba56c1bc52'), -('0d199683-b419-45d3-8e3e-1eaa46001dc5', '765b168c-c84d-4aea-bbb9-9b2ac0a1f901'), -('07150517-05fd-4f4e-96a1-aa732ef929e1', '6eac116a-fc20-4229-aa4b-7aec8c52c0b6'), -('07150517-05fd-4f4e-96a1-aa732ef929e1', '730aac07-983c-4ece-99f0-9775d425abd4'), -('07150517-05fd-4f4e-96a1-aa732ef929e1', '875e1ec1-602e-4c89-9556-56dccb846baa'), -('2a28f904-171b-455f-8caa-e3940a612c5a', '45fd8039-2f9e-4a0e-91ce-f11edf76fd23'), -('2a28f904-171b-455f-8caa-e3940a612c5a', 'e9741e11-97eb-4677-aa17-c0a43bc4bf13'), -('595a6312-72ca-42ab-94b6-484d39af498a', '70f0aef1-d003-43ab-8bbc-f672bbd1f953'), -('595a6312-72ca-42ab-94b6-484d39af498a', '3b592e54-bc5f-45a9-9120-59719e227ccb'), -('595a6312-72ca-42ab-94b6-484d39af498a', 'dbb24210-100c-446a-bf13-b317e043b0f4'), -('cd75a0c3-13e1-4a6e-a219-d5b67b566db3', 'e502afe4-3ce9-4ef1-a8f1-a2fc3197b1ee'), -('cd75a0c3-13e1-4a6e-a219-d5b67b566db3', '4b6a668a-c8f7-4076-b03b-6a53f3788fec'), -('cd75a0c3-13e1-4a6e-a219-d5b67b566db3', 'e16eed9f-5345-4e2a-af15-9e690270072b'), -('221b3dad-53aa-4a7d-bf09-25ea4bc1696d', '985b0ca5-2cdc-4abe-971c-9271af767b81'), -('221b3dad-53aa-4a7d-bf09-25ea4bc1696d', '8ba04ae8-0174-4725-a18f-4684005ba9d9'), -('221b3dad-53aa-4a7d-bf09-25ea4bc1696d', '34192650-280b-4472-a031-216c7bd324e1'), -('bd4e5e11-7dd1-4677-a265-c802627d79f3', '0775f0f9-c215-4b7c-9e80-68146067fcc4'), -('bd4e5e11-7dd1-4677-a265-c802627d79f3', '95806fba-b9bb-4259-8d3d-70ee2517ff91'), -('bd4e5e11-7dd1-4677-a265-c802627d79f3', '20da05b1-ecca-41d0-a0a3-7c838becdd69'), -('fac3beaa-8605-467c-aab2-0ac2e9459983', 'a34e6f7c-f55b-4b27-8347-6e5f39dfe26d'), -('fac3beaa-8605-467c-aab2-0ac2e9459983', '6e13a2c9-5bfa-4f3a-b714-acc7cac9be36'), -('fac3beaa-8605-467c-aab2-0ac2e9459983', '1245574f-606f-4f8a-ab1e-554b7c646964'); - -INSERT INTO followers (follower_id, following_id) VALUES --- Angus follows -('db43a681-0f60-410d-8a81-3db2a45a9633', 'b2079a72-65da-4b78-ac28-7728dabe54b9'), -('db43a681-0f60-410d-8a81-3db2a45a9633', '234f9429-d3b9-4e86-a17b-546ce08f50dd'), --- Paqui follows -('b2079a72-65da-4b78-ac28-7728dabe54b9', 'db43a681-0f60-410d-8a81-3db2a45a9633'), -('b2079a72-65da-4b78-ac28-7728dabe54b9', 'dca1ea5c-040f-4458-a9c4-ca224a2d9912'), --- Maxence follows -('f79374b7-7f31-4006-b357-f60a1383951a', 'db43a681-0f60-410d-8a81-3db2a45a9633'), -('f79374b7-7f31-4006-b357-f60a1383951a', '8ae6fb1c-6644-4bb6-b852-fad6b44a821a'), --- Néo follows -('dca1ea5c-040f-4458-a9c4-ca224a2d9912', 'b2079a72-65da-4b78-ac28-7728dabe54b9'), -('dca1ea5c-040f-4458-a9c4-ca224a2d9912', '234f9429-d3b9-4e86-a17b-546ce08f50dd'), --- Yann follows -('8ae6fb1c-6644-4bb6-b852-fad6b44a821a', 'db43a681-0f60-410d-8a81-3db2a45a9633'), -('8ae6fb1c-6644-4bb6-b852-fad6b44a821a', 'f79374b7-7f31-4006-b357-f60a1383951a'), --- Clément follows -('234f9429-d3b9-4e86-a17b-546ce08f50dd', 'db43a681-0f60-410d-8a81-3db2a45a9633'), -('234f9429-d3b9-4e86-a17b-546ce08f50dd', 'dca1ea5c-040f-4458-a9c4-ca224a2d9912'), --- test user follows a bit of everyone -('a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'db43a681-0f60-410d-8a81-3db2a45a9633'), -('a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'b2079a72-65da-4b78-ac28-7728dabe54b9'), -('a9fd34f8-9917-4f70-88c9-d6ad276463d3', 'f79374b7-7f31-4006-b357-f60a1383951a'); - -INSERT INTO publication (id, user_id, description, outfit_id, created_at) VALUES --- Angus -('0d199683-b419-45d3-8e3e-1eaa46001dc5', 'db43a681-0f60-410d-8a81-3db2a45a9633', 'Casual daily outfit', - '0d199683-b419-45d3-8e3e-1eaa46001dc5', now() - interval '15 days'), -('2a7c3338-39d4-46f3-b50d-2f240a948a12', 'db43a681-0f60-410d-8a81-3db2a45a9633', 'Simple winter look', - '06d3fb17-238e-4f94-83a7-0c439f340e7c', now() - interval '12 days'), --- Paqui -('7b44c349-47e4-4201-917a-e13c0f0e2a00', 'b2079a72-65da-4b78-ac28-7728dabe54b9', 'Classic black outfit', - '07150517-05fd-4f4e-96a1-aa732ef929e1', now() - interval '30 days'), -('63e9df0c-2f75-4777-b3f0-a6fa8ebd9ca3', 'b2079a72-65da-4b78-ac28-7728dabe54b9', 'Green tones today', - '2a28f904-171b-455f-8caa-e3940a612c5a', now() - interval '7 days'), --- Maxence -('aefc6311-af3b-4961-a0be-8604cd093896', 'f79374b7-7f31-4006-b357-f60a1383951a', 'Minimal white fit', - '595a6312-72ca-42ab-94b6-484d39af498a', now() - interval '31 days'), -('c1c3e277-493e-424d-a311-38458233e2a1', 'f79374b7-7f31-4006-b357-f60a1383951a', 'Sporty casual', - 'cd75a0c3-13e1-4a6e-a219-d5b67b566db3', now() - interval '5 days'), --- Clément -('d7741982-ba76-4724-b6ae-b6af6a4b25b1', '234f9429-d3b9-4e86-a17b-546ce08f50dd', 'Layered winter fit', - '221b3dad-53aa-4a7d-bf09-25ea4bc1696d', now() - interval '6 days'), -('f2656f11-dd32-428e-aa45-1d2b98dc9c21', '234f9429-d3b9-4e86-a17b-546ce08f50dd', 'Simple black jacket', - 'bd4e5e11-7dd1-4677-a265-c802627d79f3', now() - interval '18 days'), --- Yann -('ff7735e8-3653-4619-8ac3-3f55cd13bb0a', '8ae6fb1c-6644-4bb6-b852-fad6b44a821a', 'Warm puffer fit', - 'fac3beaa-8605-467c-aab2-0ac2e9459983', now() - interval '23 days'); - -INSERT INTO reaction (post_id, user_id, type, created_at) VALUES -('2a7c3338-39d4-46f3-b50d-2f240a948a12', '11111111-1111-1111-1111-111111111111', 'like', now() - interval '22 hours'), -('2a7c3338-39d4-46f3-b50d-2f240a948a12', '66666666-6666-6666-6666-666666666666', 'love', now() - interval '20 hours'), -('2a7c3338-39d4-46f3-b50d-2f240a948a12', '77777777-7777-7777-7777-777777777777', 'wow', now() - interval '19 hours'), -('c1c3e277-493e-424d-a311-38458233e2a1', '22222222-2222-2222-2222-222222222222', 'like', now() - interval '4 days'), -('c1c3e277-493e-424d-a311-38458233e2a1', '33333333-3333-3333-3333-333333333333', 'haha', now() - interval '3 days'), -('c1c3e277-493e-424d-a311-38458233e2a1', '44444444-4444-4444-4444-444444444444', 'like', now() - interval '2 days'), -('ff7735e8-3653-4619-8ac3-3f55cd13bb0a', '55555555-5555-5555-5555-555555555555', 'like', now() - interval '3 days'), -('ff7735e8-3653-4619-8ac3-3f55cd13bb0a', '66666666-6666-6666-6666-666666666666', 'love', now() - interval '2 days'), -('c1c3e277-493e-424d-a311-38458233e2a1', '77777777-7777-7777-7777-777777777777', 'like', now() - interval '2 days'), -('c1c3e277-493e-424d-a311-38458233e2a1', '11111111-1111-1111-1111-111111111111', 'wow', now() - interval '1 day'), -('aefc6311-af3b-4961-a0be-8604cd093896', '22222222-2222-2222-2222-222222222222', 'like', now() - interval '25 days'), -('7b44c349-47e4-4201-917a-e13c0f0e2a00', '33333333-3333-3333-3333-333333333333', 'sad', now() - interval '28 days'), -('aefc6311-af3b-4961-a0be-8604cd093896', '44444444-4444-4444-4444-444444444444', 'like', now() - interval '30 days'); diff --git a/sql/init.sql b/sql/migrations/init.sql similarity index 85% rename from sql/init.sql rename to sql/migrations/init.sql index 9398116..e12044c 100755 --- a/sql/init.sql +++ b/sql/migrations/init.sql @@ -1,11 +1,9 @@ -\c outfitter; - GRANT ALL PRIVILEGES ON DATABASE outfitter TO outfitter_user; ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO outfitter_user; -CREATE TABLE migrations ( +CREATE TABLE IF NOT EXISTS migrations ( id SERIAL PRIMARY KEY, name TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP diff --git a/sql/migrations/migration.1769360148451.sql b/sql/migrations/migration.1769360148451.sql new file mode 100644 index 0000000..6fb47c6 --- /dev/null +++ b/sql/migrations/migration.1769360148451.sql @@ -0,0 +1,6 @@ +-- Migration created at 2026-01-25T16:55:48.451Z + +ALTER TABLE reaction DROP CONSTRAINT IF EXISTS reaction_user_id_fkey; + +ALTER TABLE reaction +ADD CONSTRAINT reaction_user_id_fkey FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE; diff --git a/src/lib/components/wardrobe/OutfitCard.svelte b/src/lib/components/wardrobe/OutfitCard.svelte index 556334b..b2a4400 100644 --- a/src/lib/components/wardrobe/OutfitCard.svelte +++ b/src/lib/components/wardrobe/OutfitCard.svelte @@ -22,7 +22,7 @@
{#if showDate}
-

+

{DateUtils.formatDate(outfit.createdAt)}

diff --git a/src/lib/i18n/lang.json b/src/lib/i18n/lang.json index 577d046..ffb5f68 100755 --- a/src/lib/i18n/lang.json +++ b/src/lib/i18n/lang.json @@ -1,7 +1,4 @@ { "en": "English", - "fr": "Français", - "sp": "Español", - "jp": "日本語", - "de": "Deutsch" + "fr": "Français" } diff --git a/src/lib/i18n/messages/de.json b/src/lib/i18n/messages/de.json deleted file mode 100644 index ac6eba6..0000000 --- a/src/lib/i18n/messages/de.json +++ /dev/null @@ -1,627 +0,0 @@ -{ - "seo": { - "titleSuffix": "Outfitter", - "defaults": { - "title": "Outfitter", - "description": "Willkommen bei Outfitter, deinem persönlichen Outfit-Generator und Kleiderschrank-Manager. Erstelle stylische Outfits basierend auf deiner eigenen Kleidung.", - "error": { - "title": "Ein Fehler ist aufgetreten", - "description": "Entschuldigung, ein unerwarteter Fehler ist aufgetreten. Bitte versuche es später noch einmal." - } - }, - "home": { - "homePage": { - "title": "Startseite", - "description": "Willkommen bei Outfitter, deinem persönlichen Outfit-Generator und Kleiderschrank-Manager. Erstelle stylische Outfits basierend auf deiner eigenen Kleidung." - }, - "legal": { - "tos": { - "title": "Nutzungsbedingungen", - "description": "Lies die Nutzungsbedingungen für die Verwendung von Outfitter." - }, - "privacyPolicy": { - "title": "Datenschutzerklärung", - "description": "Lies die Datenschutzerklärung, um zu verstehen, wie Outfitter mit deinen Daten umgeht." - } - } - }, - "auth": { - "logIn": { - "title": "Anmelden", - "description": "Melde dich bei deinem Outfitter-Konto an, um deinen Kleiderschrank zu verwalten und stylische Outfits passend zu deiner Kleidung zu generieren." - }, - "signUp": { - "title": "Registrieren", - "description": "Erstelle ein Konto bei Outfitter, um deinen Kleiderschrank zu verwalten und stylische Outfits passend zu deiner Kleidung zu generieren." - }, - "forgotPassword": { - "title": "Passwort vergessen", - "description": "Setze dein Passwort zurück, um wieder Zugriff auf deinen Kleiderschrank und die Outfit-Funktionen zu erhalten." - }, - "resetPassword": { - "title": "Passwort zurücksetzen", - "description": "Lege ein neues Passwort für dein Outfitter-Konto fest." - } - }, - "account": { - "title": "Konto", - "description": "Verwalte dein Outfitter-Konto", - "settings": { - "title": "Kontoeinstellungen", - "description": "Verwalte deine Kontoeinstellungen, einschließlich Benutzername, E-Mail, Passwort und Sicherheitsoptionen wie TOTP und Passkeys." - } - }, - "wardrobe": { - "title": "Kleiderschrank", - "description": "Verwalte deinen Kleiderschrank, indem du Kleidungsstücke hinzufügst und stylische Outfits basierend auf deiner Kollektion generierst.", - "item": { - "title": "Kleidungsstück", - "description": "Sieh dir Details zu deinen Kleidungsstücken in deinem Outfitter-Kleiderschrank an.", - "edit": { - "title": "Kleidungsstück bearbeiten", - "description": "Bearbeite die Details deines Kleidungsstücks." - } - } - }, - "social": { - "feed": { - "title": "Feed", - "description": "Entdecke den Outfitter-Community-Feed, um stylische Outfits und Kleidungsstücke anderer Nutzer zu sehen." - }, - "post": { - "title": "Beitrag", - "description": "Sieh dir ein Outfit oder Kleidungsstück an, das von einem Nutzer im Feed geteilt wurde." - }, - "profile": { - "title": "Profil von {{username}}", - "description": "Entdecke das Profil von {{username}} auf Outfitter, um deren Kleiderschrank und Outfits zu sehen." - } - } - }, - "errors": { - "auth": { - "badUsername": "Kein Nutzer mit diesem Namen gefunden.", - "invalidCredentials": "Ungültige Anmeldedaten. Bitte versuche es erneut.", - "createUser": "Nutzer konnte nicht erstellt werden.", - "usernameTaken": "Dieser Benutzername ist bereits vergeben. Bitte wähle einen anderen.", - "userDoesNotExist": "Nutzer existiert nicht.", - "userNotFound": "Nutzer nicht gefunden.", - "userIsRequired": "Nutzer ist erforderlich.", - "usernameTooShort": "Benutzername muss mindestens 3 Zeichen lang sein.", - "usernameTooLong": "Benutzername darf höchstens 20 Zeichen lang sein.", - "unauthorized": "Nicht autorisiert.", - "emailInUse": "Diese E-Mail-Adresse wird bereits verwendet.", - "invalidEmail": "Ungültige E-Mail-Adresse.", - "createPasskey": "Passkey konnte nicht erstellt werden.", - "passkeyAlreadyExists": "Dieser Passkey existiert bereits.", - "challengeExpired": "Die Sitzung ist abgelaufen. Bitte versuche es erneut.", - "noPasskey": "Kein Passkey für diesen Nutzer registriert.", - "verificationFailed": "Verifizierung fehlgeschlagen.", - "passkeyLoginFailed": "Passkey-Login fehlgeschlagen.", - "passwordTooShort": "Passwort muss mindestens 8 Zeichen lang sein.", - "passwordTooLong": "Passwort darf höchstens 100 Zeichen lang sein.", - "totp": { - "invalidToken": "Ungültiger TOTP-Token.", - "registerFailed": "TOTP-Registrierung fehlgeschlagen.", - "invalidCode": "Ungültiger TOTP-Code.", - "notEnabled": "Du musst TOTP aktivieren, um diese Aktion auszuführen.", - "needTOTP": "Bitte gib einen TOTP-Code ein." - }, - "passwordReset": { - "noToken": "Kein Reset-Token vorhanden!", - "expiredToken": "Die Anfrage ist abgelaufen!", - "passwordsDontMatch": "Passwörter stimmen nicht überein!", - "noAccountWithEmail": "Kein Konto mit dieser E-Mail-Adresse gefunden!", - "wrongCurrentPassword": "Das aktuelle Passwort ist falsch!" - } - }, - "server": { - "connectionRefused": "Verbindung verweigert. Bitte versuche es später erneut.", - "forbidden": "Verboten", - "weatherFetchFailed": "Wetterdaten konnten nicht geladen werden." - }, - "account": { - "settings": { - "cannotChangeUsername": "Benutzername kann nicht geändert werden.", - "invalidProfilePicture": "Ungültiges Profilbild. Bitte lade eine gültige Bilddatei hoch." - } - }, - "clothing": { - "item": { - "requiredField": "Folgendes Feld ist erforderlich: {{field}}", - "classificationFailed": "Kleidungsstück konnte nicht automatisch klassifiziert werden. Bitte gib die Werte manuell ein.", - "missingImage": "Bilddatei ist erforderlich.", - "name": "Bitte gib einen Namen ein (max. 100 Zeichen).", - "description": "Bitte gib eine Beschreibung ein (max. 500 Zeichen).", - "type": "Bitte wähle einen Typ aus.", - "color": "Bitte wähle eine Farbe aus.", - "pattern": "Bitte wähle ein Muster aus.", - "image": "Bitte mache ein Foto von dem Teil.", - "notFound": "Teil nicht gefunden." - }, - "outfit": { - "delete": "Outfit konnte nicht gelöscht werden.", - "notAuthorized": "Dieses Outfit gehört dir nicht.", - "notFound": "Outfit nicht gefunden." - } - }, - "outfitGeneration": { - "notEnoughItems": "Nicht genug Kleidung vorhanden, um ein Outfit zu generieren. Bitte füge mehr Teile hinzu.", - "apiError": "Outfit-Generierung aufgrund eines internen Fehlers fehlgeschlagen." - }, - "social": { - "cannotFollowYourself": "Du kannst dir nicht selbst folgen.", - "searchFailed": "Nutzer-Suche fehlgeschlagen.", - "item": { - "requiredField": "Folgendes Feld ist erforderlich: {{field}}" - }, - "post": { - "notFound": "Beitrag nicht gefunden.", - "deleteFailed": "Beitrag konnte nicht gelöscht werden.", - "notAuthorized": "Du bist nicht berechtigt, diesen Beitrag zu löschen.", - "comment": { - "notFound": "Kommentar nicht gefunden", - "deleteFailed": "Kommentar konnte nicht gelöscht werden", - "emptyContent": "Der Kommentar darf nicht leer sein" - } - } - }, - "icsToken": { - "creationFailed": "Kalender-Link konnte nicht erstellt werden.", - "revocationFailed": "Kalender-Link konnte nicht widerrufen werden.", - "notFound": "Kalender-Link nicht gefunden." - } - }, - "successes": { - "passkeyAdded": "Passkey erfolgreich hinzugefügt.", - "passkeyDeleted": "Passkey erfolgreich gelöscht.", - "setUpTOTP": "TOTP wurde erfolgreich eingerichtet.", - "unlinkTOTP": "TOTP wurde erfolgreich deaktiviert.", - "resetPasswordRequest": "Prüfe deinen Posteingang, um dein Passwort zurückzusetzen!", - "resetPassword": "Dein Passwort wurde erfolgreich zurückgesetzt!", - "usernameUpdated": "Benutzername erfolgreich aktualisiert.", - "emailUpdated": "E-Mail erfolgreich aktualisiert.", - "profilePictureUpdated": "Profilbild erfolgreich aktualisiert.", - "ics": { - "created": "Kalender-Link erfolgreich erstellt.", - "revoked": "Kalender-Link erfolgreich widerrufen.", - "copied": "Kalender-Link in die Zwischenablage kopiert." - }, - "clothing": { - "item": { - "created": "Kleidungsstück erfolgreich hinzugefügt.", - "updated": "Kleidungsstück erfolgreich aktualisiert." - } - }, - "social": { - "item": { - "created": "Gepostet." - }, - "post": { - "edited": "Beitrag erfolgreich bearbeitet." - }, - "comment": { - "added": "Kommentar erfolgreich hinzugefügt." - } - } - }, - "auth": { - "username": "Benutzername", - "email": "E-Mail", - "password": "Passwort", - "confirmPassword": "Passwort bestätigen", - "newPassword": "Neues Passwort", - "currentPassword": "Aktuelles Passwort", - "rememberMe": "Angemeldet bleiben", - "forgotPasswordKeyword": "Passwort vergessen?", - "submit": "Absenden", - "signIn": { - "title": "Bei deinem Konto anmelden", - "alreadyHaveAnAccount": { - "text": "Hast du schon ein Konto?", - "cta": "Einloggen" - } - }, - "logIn": { - "title": "Einloggen", - "dontHaveAnAccount": { - "text": "Noch kein Konto?", - "cta": "Registrieren" - } - }, - "passkey": { - "separator": "oder", - "button": "Passkey verwenden" - }, - "totp": { - "logIn": { - "title": "Mit TOTP einloggen", - "description": "Gib deinen TOTP-Code ein, um dich anzumelden.", - "nextButton": "Weiter" - }, - "settings": { - "page": { - "enable": { - "description": "2FA ist für dein Konto nicht aktiviert.", - "button": "Aktivieren" - }, - "disable": { - "description": "2FA ist für dein Konto aktiviert.", - "button": "Deaktivieren" - } - }, - "manage": { - "title": "TOTP verwalten", - "description": "Hier kannst du deine TOTP-Einstellungen verwalten.", - "button": "TOTP deaktivieren", - "secondStep": { - "description": "Bestätige deine Identität durch Eingabe deines TOTP-Codes.", - "cancel": "Abbrechen", - "confirm": "Deaktivieren" - } - }, - "setup": { - "title": "TOTP einrichten", - "description": "Scanne den QR-Code mit deiner Authenticator-App.", - "button": "TOTP einrichten", - "qrCodeAlt": "QR-Code für 2FA-Einrichtung", - "unableToScan": "Scannen nicht möglich? Geheimschlüssel anzeigen:", - "showHideValue": "Wert anzeigen/ausblenden", - "confirmCode": "Code bestätigen", - "nextButton": "Weiter" - }, - "success": { - "title": "TOTP erfolgreich eingerichtet", - "description": "Du hast TOTP erfolgreich für dein Konto aktiviert.", - "message": "Dein Konto ist nun durch eine zweite Sicherheitsebene geschützt.", - "buttons": { - "close": "Schließen" - } - } - } - }, - "forgotPassword": { - "title": "Passwort vergessen?", - "button": "Reset-Link senden" - }, - "resetPassword": { - "title": "Passwort zurücksetzen", - "button": "Passwort zurücksetzen", - "successAction": "Einloggen" - }, - "formWrapper": { - "back": "Zurück" - } - }, - "errorPage": { - "cta": { - "goHome": "Zur Startseite", - "goBack": "Zurück" - } - }, - "account": { - "tabs": { - "settings": "Einstellungen", - "legal": "Rechtliches" - }, - "settings": { - "tabs": { - "general": { - "title": "Allgemein", - "description": "Aktualisiere deine allgemeinen Kontoeinstellungen.", - "changeUsername": { - "title": "Benutzernamen ändern", - "alerts": { - "taken": { - "title": "Benutzername vergeben", - "description": "Der Name {{username}} ist bereits vergeben." - }, - "available": { - "title": "Benutzername verfügbar", - "description": "Der Name {{username}} ist frei!" - } - } - }, - "changeLang": { - "title": "Sprache" - }, - "changeEmail": { - "title": "E-Mail ändern" - }, - "theme": { - "theme": "Design", - "mode": "Modus", - "options": { - "light": "Licht", - "dark": "Dunkles", - "system": "System" - } - }, - "logout": "Ausloggen", - "danger": { - "title": "Gefahrenzone" - }, - "profilePicture": "Profilbild" - }, - "password": { - "title": "Passwort", - "description": "Ändere dein Passwort, um dein Konto sicher zu halten." - }, - "calendars": { - "title": "Kalender", - "description": "Verwalte deine Kalender-Integrationen.", - "createButton": "Neuen Kalender-Link erstellen", - "revokeButton": "Widerrufen" - }, - "TOTP": { - "title": "TOTP", - "description": "Die Zwei-Faktor-Authentisierung (2FA) bietet zusätzliche Sicherheit für dein Konto." - }, - "passkey": { - "title": "Passkey", - "description": "Ein Passkey ist eine sichere Methode, sich ohne Passwort anzumelden.", - "register": { - "button": "Passkey registrieren" - }, - "remove": { - "button": "Passkey entfernen", - "modal": { - "confirm": "Entfernen", - "title": "Passkey entfernen", - "description": "Bist du sicher? Diese Aktion kann nicht rückgängig gemacht werden.", - "cancel": "Abbrechen" - } - } - } - } - } - }, - "nav": { - "home": "Home", - "about": "Über uns", - "pricing": "Preise", - "outfits": "Outfits", - "account": "Konto", - "settings": "Einstellungen", - "feed": "Feed", - "logIn": "Login", - "signUp": "Registrieren", - "wardrobe": "Kleiderschrank", - "add": { - "title": "Hinzufügen", - "description": "Füge ein neues Teil oder Outfit hinzu.", - "item": "Teil", - "outfit": "Outfit" - } - }, - "wardrobe": { - "createItem": { - "title": "Teil hinzufügen", - "description": "Fülle das Formular aus, um neue Kleidung hinzuzufügen.", - "submit": "Teil erstellen", - "fields": { - "name": "Name", - "type": "Typ", - "color": "Farbe", - "pattern": "Muster", - "description": "Beschreibung", - "image": { - "label": "Foto machen" - } - } - }, - "itemList": { - "items": { - "title": "Teile", - "empty": { - "title": "Noch keine Kleidung", - "description": "Du hast noch keine Teile hinzugefügt.", - "createButton": "Teil erstellen" - } - }, - "outfits": { - "title": "Outfits", - "empty": { - "title": "Noch keine Outfits", - "description": "Erstelle dein erstes Outfit durch Kombinieren deiner Kleidung.", - "createButton": "Outfit erstellen" - } - } - }, - "outfitGeneration": { - "initialQuestions": { - "style": { - "question": "Welchen Stil suchst du heute?", - "options": { - "default": "Überrasch mich", - "comfort": "Bequem", - "new": "Neu", - "style": "Stylisch", - "formal": "Formal" - } - }, - "weather": { - "question": "Wie ist das Wetter?", - "options": { - "sunny": "Sonnig", - "rainy": "Regnerisch", - "cold": "Kalt", - "snowy": "Schnee" - } - } - }, - "swiper": { - "loadingText": "Outfits werden generiert...", - "noMoreCards": { - "title": "Keine weiteren Outfits", - "subTitle": "Bitte versuche es später erneut.", - "retryButton": "Erneut versuchen" - } - }, - "chooseOutfitModal": { - "title": "Dieses Outfit wählen", - "description": "Das ist dein Look für heute!", - "confirmButton": "Wählen", - "cancelButton": "Doch nicht" - }, - "choosen": { - "title": "Dein Outfit heute", - "description": "Du bist startklar! Hier ist dein gewähltes Outfit.", - "change": { - "cta": "Outfit ändern", - "title": "Outfit ändern", - "description": "Möchtest du dein Outfit für heute wirklich ändern?", - "confirm": "Ja, ändern", - "cancel": "Nein, behalten" - } - }, - "mixAndMatch": { - "button": "Ich bin inspiriert!", - "title": "Mix & Match", - "createButton": "Outfit erstellen" - } - }, - "reminder": { - "noTodayOutfit": "Du hast noch kein Outfit für heute – erstelle eins!", - "createButton": "Erstellen" - }, - "outfitDetails": { - "lastWornOn": "Zuletzt getragen am {{date}}" - }, - "itemPage": { - "delete": { - "title": "Teil löschen", - "description": "Möchtest du dieses Teil wirklich löschen? Das kann nicht rückgängig gemacht werden.", - "confirm": "Löschen", - "cancel": "Abbrechen" - } - } - }, - "transactional": { - "emails": { - "passwordReset": { - "subject": "Outfitter Passwort zurücksetzen", - "greeting": "Hallo {{username}},", - "body": "Wir haben eine Anfrage zum Zurücksetzen deines Passworts erhalten. Klicke auf den Link unten:", - "resetLinkText": "Passwort zurücksetzen", - "footer": "Falls du dies nicht angefordert hast, ignoriere diese E-Mail einfach." - } - } - }, - "social": { - "profile": { - "follow": { - "follow": "Folgen", - "unfollow": "Entfolgen" - }, - "nbFollowers": "{{count}} Follower", - "nbFollowing": "{{count}} Folgt", - "isFollowing": "Folgt dir", - "notFollowing": "Folgt dir nicht" - }, - "feed": { - "search": { - "placeholder": "Nutzer suchen..." - }, - "addPublication": { - "title": "Beitrag erstellen", - "description": { - "label": "Beschreibung" - }, - "submit": "Posten", - "todaysOutfit": "Trägst du das Outfit, das du heute gewählt hast?" - }, - "loadingMore": "Weitere Beiträge laden...", - "noMorePosts": "Keine weiteren Beiträge.", - "forYou": "Für dich", - "followedFeed": "Gefolgt" - }, - "post": { - "delete": { - "title": "Beitrag löschen", - "description": "Möchtest du diesen Beitrag wirklich löschen?", - "confirm": "Löschen", - "cancel": "Abbrechen" - }, - "blurred": "Poste ein Bild deines Outfits, um diesen Inhalt zu sehen", - "comments": { - "noComments": "Noch keine Kommentare. Schreib den ersten!", - "cancel": "Abbrechen", - "addComment": { - "title": "Kommentar hinzufügen", - "placeholder": "Schreibe einen Kommentar...", - "submit": "Posten", - "button": "Kommentieren" - }, - "reply": { - "button": "Antworten", - "placeholder": "Schreibe eine Antwort...", - "submit": "Antworten", - "title": "Antwort an {{username}}" - } - } - } - }, - "homepage": { - "hero": { - "version": "Neu: Outfitter v{{version}}", - "slogan": "Tragen. Teilen. Lieben.", - "description": "Outfitter ist dein digitaler Kleiderschrank. Organisiere deine Mode, erhalte Empfehlungen und verbinde dich mit der Community. Mode einfach gemacht.", - "cta": { - "findMyLook": "Look finden", - "learnMore": "Mehr erfahren" - } - }, - "about": { - "badge": "Über Outfitter", - "title": "Wer wir sind", - "intro": "Outfitter entstand als ambitioniertes Universitätsprojekt im 3. Jahr des Informatik-Studiums an der Paul Sabatier Universität in Toulouse. Initiiert von einem leidenschaftlichen Team, spiegelt diese App unser Engagement für Innovation und praktische Lösungen wider. Aus diesem akademischen Projekt wurde eine moderne Plattform, die technisches Know-how mit Kreativität verbindet.", - "description": "Outfitter ist eine hochmoderne Web-Anwendung, die das Management deines Kleiderschranks revolutioniert. Unsere Mission ist es, Modebegeisterte mit innovativen Tools zu unterstützen." - }, - "features": { - "badge": "Features", - "title": "Was wir bieten", - "items": { - "wardrobeManagement": { - "title": "1. Kleiderschrank-Management", - "description": "Katalogisiere und organisiere deine Kleidung intuitiv. Füge Details und Fotos für einen schnellen Zugriff hinzu." - }, - "outfitGeneration": { - "title": "2. Outfit-Generierung", - "description": "Erhalte personalisierte Empfehlungen. Unser smarter Algorithmus schlägt stylische Kombinationen vor." - }, - "exploreShare": { - "title": "3. Entdecken & Teilen", - "description": "Werde Teil der Fashion-Community. Teile deine Looks und lass dich von anderen inspirieren." - } - } - } - }, - "footer": { - "links": { - "home": "Home", - "about": "Über uns", - "features": "Features", - "account": "Konto", - "logIn": "Login", - "signUp": "Registrieren" - }, - "rights": "© {{year}} Outfitter. Alle Rechte vorbehalten.", - "madeWith": "Erstellt mit ❤️" - }, - "ics": { - "event": { - "title": "Outfit für den {{date}}", - "description": "Schau dir das Outfit hier an: {{url}}" - } - }, - "wrapped": { - "cta": "Dein Outfitter Wrapped ansehen", - "tabs": { - "mostWornItems": "Meistgetragene Teile", - "mostLikedPost": "Beliebtester Beitrag" - }, - "noLikedPost": "Du hast dieses Jahr keine Outfits gepostet.", - "noWornItems": "Du hast dieses Jahr kein Outfit getragen." - } -} diff --git a/src/lib/i18n/messages/en.json b/src/lib/i18n/messages/en.json index 682b2e6..cb9b9da 100755 --- a/src/lib/i18n/messages/en.json +++ b/src/lib/i18n/messages/en.json @@ -460,7 +460,7 @@ }, "chooseOutfitModal": { "title": "Choose this outfit", - "description": "This will be the outfit you will be rocking today!", + "description": "This will be the outfit you will be rocking {{date}}!", "confirmButton": "Choose", "cancelButton": "I changed my mind" }, @@ -623,5 +623,10 @@ }, "noLikedPost": "You didn't post any outfit this year.", "noWornItems": "You didn't worn any outfit this year." + }, + "date": { + "today": "today", + "tomorrow": "tomorrow", + "yesterday": "yesterday" } } diff --git a/src/lib/i18n/messages/fr.json b/src/lib/i18n/messages/fr.json index de7e21f..003001e 100644 --- a/src/lib/i18n/messages/fr.json +++ b/src/lib/i18n/messages/fr.json @@ -460,7 +460,7 @@ }, "chooseOutfitModal": { "title": "Choisir cette tenue", - "description": "Celle‑ci sera la tenue que vous porterez aujourd'hui !", + "description": "Celle‑ci sera la tenue que vous porterez {{date}}!", "confirmButton": "Choisir", "cancelButton": "J'ai changé d'avis" }, @@ -623,5 +623,10 @@ }, "noLikedPost": "Vous n'avez pas publié de tenue cette année.", "noWornItems": "Vous n'avez pas porté de vêtement cette année." + }, + "date": { + "today": "aujourd'hui", + "tomorrow": "demain", + "yesterday": "hier" } } diff --git a/src/lib/i18n/messages/jp.json b/src/lib/i18n/messages/jp.json deleted file mode 100644 index c364f40..0000000 --- a/src/lib/i18n/messages/jp.json +++ /dev/null @@ -1,627 +0,0 @@ -{ - "seo": { - "titleSuffix": "Outfitter", - "defaults": { - "title": "Outfitter", - "description": "Outfitterへようこそ。あなたの個人的なコーディネート作成・ワードローブ管理ツールです。持ち合わせの服からスタイリッシュなコーディネートを提案します。", - "error": { - "title": "エラーが発生しました", - "description": "申し訳ありません、予期しないエラーが発生しました。後でもう一度お試しください。" - } - }, - "home": { - "homePage": { - "title": "ホーム", - "description": "Outfitterへようこそ。あなたの個人的なコーディネート作成・ワードローブ管理ツールです。持ち合わせの服からスタイリッシュなコーディネートを提案します。" - }, - "legal": { - "tos": { - "title": "利用規約", - "description": "Outfitterの利用規約をお読みください。" - }, - "privacyPolicy": { - "title": "プライバシーポリシー", - "description": "Outfitterがお客様のデータをどのように扱うかについては、プライバシーポリシーをご確認ください。" - } - } - }, - "auth": { - "logIn": { - "title": "ログイン", - "description": "Outfitterアカウントにログインして、ワードローブの管理や服に合わせたコーディネート作成を行いましょう。" - }, - "signUp": { - "title": "新規登録", - "description": "Outfitterのアカウントを作成して、ワードローブの管理やスタイリッシュなコーディネート作成を始めましょう。" - }, - "forgotPassword": { - "title": "パスワードを忘れた場合", - "description": "Outfitterアカウントのパスワードをリセットして、ワードローブやコーディネート機能へのアクセスを再開します。" - }, - "resetPassword": { - "title": "パスワードのリセット", - "description": "Outfitterアカウントの新しいパスワードを設定します。" - } - }, - "account": { - "title": "アカウント", - "description": "Outfitterアカウントの管理", - "settings": { - "title": "アカウント設定", - "description": "ユーザー名、メールアドレス、パスワード、およびTOTPやパスキーなどのセキュリティ設定を管理します。" - } - }, - "wardrobe": { - "title": "ワードローブ", - "description": "アイテムを追加してワードローブを管理し、コレクションに基づいたコーディネートを作成しましょう。", - "item": { - "title": "衣類アイテム", - "description": "Outfitterワードローブ内のアイテム詳細を表示・管理します。", - "edit": { - "title": "アイテムの編集", - "description": "Outfitterワードローブ内のアイテム詳細を編集します。" - } - } - }, - "social": { - "feed": { - "title": "フィード", - "description": "Outfitterコミュニティフィードで、他のユーザーがシェアしたコーディネートやアイテムをチェックしましょう。" - }, - "post": { - "title": "投稿", - "description": "Outfitterコミュニティに投稿されたコーディネートやアイテムを表示します。" - }, - "profile": { - "title": "{{username}}さんのプロフィール", - "description": "Outfitterで{{username}}さんのプロフィールを表示し、ワードローブやコーディネートをチェックしましょう。" - } - } - }, - "errors": { - "auth": { - "badUsername": "このユーザー名に該当するユーザーが見つかりません", - "invalidCredentials": "認証情報が無効です。もう一度お試しください。", - "createUser": "ユーザーの作成に失敗しました", - "usernameTaken": "このユーザー名は既に使用されています。別の名前を選択してください。", - "userDoesNotExist": "ユーザーが存在しません。", - "userNotFound": "ユーザーが見つかりません。", - "userIsRequired": "ユーザー情報は必須です。", - "usernameTooShort": "ユーザー名は3文字以上で入力してください。", - "usernameTooLong": "ユーザー名は20文字以内で入力してください。", - "unauthorized": "認証されていません。", - "emailInUse": "このメールアドレスは既に登録されています。", - "invalidEmail": "無効なメールアドレスです。", - "createPasskey": "パスキーの作成に失敗しました", - "passkeyAlreadyExists": "このパスキーは既に存在します。", - "challengeExpired": "チャレンジの期限が切れました。もう一度お試しください。", - "noPasskey": "このユーザーに登録されているパスキーはありません。", - "verificationFailed": "認証に失敗しました。もう一度お試しください。", - "passkeyLoginFailed": "パスキーでのログインに失敗しました。もう一度お試しください。", - "passwordTooShort": "パスワードは8文字以上である必要があります。", - "passwordTooLong": "パスワードは100文字以内で入力してください。", - "totp": { - "invalidToken": "無効なTOTPトークンです。", - "registerFailed": "TOTPの登録に失敗しました。もう一度お試しください。", - "invalidCode": "無効なTOTPコードです", - "notEnabled": "このアクションを実行するにはTOTPを有効にする必要があります。", - "needTOTP": "このアクションを実行するにはTOTPコードを入力してください。" - }, - "passwordReset": { - "noToken": "リセットトークンが提供されていません。", - "expiredToken": "リクエストの期限が切れました。", - "passwordsDontMatch": "パスワードが一致しません。", - "noAccountWithEmail": "このメールアドレスに関連付けられたアカウントが見つかりません。", - "wrongCurrentPassword": "現在のパスワードが正しくありません。" - } - }, - "server": { - "connectionRefused": "接続が拒否されました。後でもう一度お試しください。", - "forbidden": "アクセス権限がありません", - "weatherFetchFailed": "気象データの取得に失敗しました。後でもう一度お試しください。" - }, - "account": { - "settings": { - "cannotChangeUsername": "ユーザー名を変更できません。", - "invalidProfilePicture": "無効なプロフィール画像です。有効な画像ファイルをアップロードしてください。" - } - }, - "clothing": { - "item": { - "requiredField": "以下の項目は必須です: {{field}}", - "classificationFailed": "アイテムの自動分類に失敗しました。値を手動で入力してください。", - "missingImage": "画像ファイルが必要です", - "name": "アイテム名を入力してください(最大100文字)。", - "description": "アイテムの説明を入力してください(最大500文字)。", - "type": "アイテムの種類を選択してください。", - "color": "アイテムの色を選択してください。", - "pattern": "アイテムの柄を選択してください。", - "image": "アイテムの写真を撮影してください。", - "notFound": "アイテムが見つかりません。" - }, - "outfit": { - "delete": "コーディネートの削除に失敗しました。後でもう一度お試しください。", - "notAuthorized": "このコーディネートを操作する権限がありません。", - "notFound": "コーディネートが見つかりません。" - } - }, - "outfitGeneration": { - "notEnoughItems": "コーディネートを生成するためのアイテムが不足しています。ワードローブにアイテムを追加してください。", - "apiError": "内部エラーによりコーディネートの生成に失敗しました。後でもう一度お試しください。" - }, - "social": { - "cannotFollowYourself": "自分自身をフォローすることはできません。", - "searchFailed": "ユーザー検索に失敗しました。後でもう一度お試しください。", - "item": { - "requiredField": "以下の項目は必須です: {{field}}" - }, - "post": { - "notFound": "投稿が見つかりません。", - "deleteFailed": "投稿の削除に失敗しました。後でもう一度お試しください。", - "notAuthorized": "この投稿を削除する権限がありません。", - "comment": { - "notFound": "コメントが見つかりません", - "deleteFailed": "コメントを削除できませんでした", - "emptyContent": "コメントの内容は空欄にできません" - } - } - }, - "icsToken": { - "creationFailed": "カレンダーリンクの作成に失敗しました。後でもう一度お試しください。", - "revocationFailed": "カレンダーリンクの無効化に失敗しました。後でもう一度お試しください。", - "notFound": "カレンダーリンクが見つかりません。" - } - }, - "successes": { - "passkeyAdded": "パスキーが正常に追加されました。", - "passkeyDeleted": "パスキーが正常に削除されました。", - "setUpTOTP": "TOTPの設定が完了しました。", - "unlinkTOTP": "TOTPの解除が完了しました。", - "resetPasswordRequest": "パスワード再設定用のメールを送信しました。受信トレイをご確認ください。", - "resetPassword": "パスワードが正常にリセットされました。", - "usernameUpdated": "ユーザー名が更新されました。", - "emailUpdated": "メールアドレスが更新されました。", - "profilePictureUpdated": "プロフィール画像が更新されました。", - "ics": { - "created": "カレンダーリンクが作成されました。", - "revoked": "カレンダーリンクが無効化されました。", - "copied": "カレンダーリンクをクリップボードにコピーしました。" - }, - "clothing": { - "item": { - "created": "アイテムが正常に作成されました。", - "updated": "アイテムが正常に更新されました。" - } - }, - "social": { - "item": { - "created": "投稿されました。" - }, - "post": { - "edited": "投稿が更新されました。" - }, - "comment": { - "added": "コメントが追加されました。" - } - } - }, - "auth": { - "username": "ユーザー名", - "email": "メールアドレス", - "password": "パスワード", - "confirmPassword": "パスワード(確認)", - "newPassword": "新しいパスワード", - "currentPassword": "現在のパスワード", - "rememberMe": "ログイン状態を保持する", - "forgotPasswordKeyword": "パスワードをお忘れですか?", - "submit": "送信", - "signIn": { - "title": "アカウントにサインイン", - "alreadyHaveAnAccount": { - "text": "既にアカウントをお持ちですか?", - "cta": "ログイン" - } - }, - "logIn": { - "title": "アカウントにログイン", - "dontHaveAnAccount": { - "text": "アカウントをお持ちではありませんか?", - "cta": "新規登録" - } - }, - "passkey": { - "separator": "または", - "button": "パスキーを使用" - }, - "totp": { - "logIn": { - "title": "TOTPでログイン", - "description": "ログインするにはTOTPコードを入力してください。", - "nextButton": "次へ" - }, - "settings": { - "page": { - "enable": { - "description": "2段階認証は有効になっていません。", - "button": "有効にする" - }, - "disable": { - "description": "2段階認証は有効です。", - "button": "無効にする" - } - }, - "manage": { - "title": "TOTPの管理", - "description": "ここでTOTP設定を管理できます。", - "button": "TOTPを無効にする", - "secondStep": { - "description": "TOTPコードを入力して本人確認を行ってください。", - "cancel": "キャンセル", - "confirm": "無効にする" - } - }, - "setup": { - "title": "TOTPの設定", - "description": "認証アプリでQRコードをスキャンしてTOTPを設定してください。", - "button": "TOTPを設定する", - "qrCodeAlt": "2段階認証設定用QRコード", - "unableToScan": "スキャンできない場合は、シークレットキーを表示できます:", - "showHideValue": "表示/非表示", - "confirmCode": "コードを確認", - "nextButton": "次へ" - }, - "success": { - "title": "TOTP設定完了", - "description": "アカウントのTOTP設定が正常に完了しました。", - "message": "TOTPにより、アカウントに2層目のセキュリティが追加されました。", - "buttons": { - "close": "閉じる" - } - } - } - }, - "forgotPassword": { - "title": "パスワードを忘れましたか?", - "button": "リセットリンクを送信" - }, - "resetPassword": { - "title": "パスワードをリセット", - "button": "パスワードをリセット", - "successAction": "ログイン" - }, - "formWrapper": { - "back": "戻る" - } - }, - "errorPage": { - "cta": { - "goHome": "ホームへ戻る", - "goBack": "戻る" - } - }, - "account": { - "tabs": { - "settings": "設定", - "legal": "法的情報" - }, - "settings": { - "tabs": { - "general": { - "title": "一般", - "description": "全般的なアカウント設定を更新します。", - "changeUsername": { - "title": "ユーザー名の変更", - "alerts": { - "taken": { - "title": "使用不可", - "description": "ユーザー名「{{username}}」は既に使用されています。" - }, - "available": { - "title": "使用可能", - "description": "ユーザー名「{{username}}」は使用可能です!" - } - } - }, - "changeLang": { - "title": "言語" - }, - "changeEmail": { - "title": "メールアドレスの変更" - }, - "theme": { - "theme": "テーマ", - "mode": "モード", - "options": { - "light": "ライト", - "dark": "暗い", - "system": "システム" - } - }, - "logout": "ログアウト", - "danger": { - "title": "危険地帯" - }, - "profilePicture": "プロフィール画像" - }, - "password": { - "title": "パスワード", - "description": "アカウントの安全性を保つためにパスワードを変更します。" - }, - "calendars": { - "title": "カレンダー", - "description": "カレンダー連携と設定を管理します。", - "createButton": "新しいカレンダーリンクを作成", - "revokeButton": "無効化" - }, - "TOTP": { - "title": "TOTP", - "description": "2段階認証(2FA)は、ログイン時に2つ目の確認ステップを要求することで、アカウントのセキュリティを強化します。" - }, - "passkey": { - "title": "パスキー", - "description": "パスキーは、パスワードを使わずにアカウントにログインできる安全な方法です。デバイスに保存された暗号鍵を使用します。", - "register": { - "button": "パスキーを登録" - }, - "remove": { - "button": "パスキーを削除", - "modal": { - "confirm": "削除", - "title": "パスキーの削除", - "description": "このパスキーを削除してもよろしいですか?この操作は取り消せません。", - "cancel": "キャンセル" - } - } - } - } - } - }, - "nav": { - "home": "ホーム", - "about": "Outfitterについて", - "pricing": "料金プラン", - "outfits": "コーディネート", - "account": "アカウント", - "settings": "設定", - "feed": "フィード", - "logIn": "ログイン", - "signUp": "新規登録", - "wardrobe": "ワードローブ", - "add": { - "title": "ワードローブに追加", - "description": "新しいアイテムまたはコーディネートを追加します。", - "item": "アイテム", - "outfit": "コーディネート" - } - }, - "wardrobe": { - "createItem": { - "title": "アイテムを追加", - "description": "以下のフォームを入力して、ワードローブに新しい衣類を追加してください。", - "submit": "アイテムを作成", - "fields": { - "name": "名前", - "type": "種類", - "color": "色", - "pattern": "柄", - "description": "説明", - "image": { - "label": "写真を撮る" - } - } - }, - "itemList": { - "items": { - "title": "アイテム", - "empty": { - "title": "服がありません", - "description": "まだ衣類アイテムが追加されていません。最初のアイテムを作成しましょう。", - "createButton": "アイテムを作成" - } - }, - "outfits": { - "title": "コーディネート", - "empty": { - "title": "コーディネートがありません", - "description": "まだコーディネートが作成されていません。服を組み合わせて作成しましょう。", - "createButton": "コーデを作成" - } - } - }, - "outfitGeneration": { - "initialQuestions": { - "style": { - "question": "今日のスタイルはどうしますか?", - "options": { - "default": "おまかせ", - "comfort": "快適さ重視", - "new": "新鮮なスタイル", - "style": "スタイリッシュ", - "formal": "フォーマル" - } - }, - "weather": { - "question": "天気はどうですか?", - "options": { - "sunny": "晴れ", - "rainy": "雨", - "cold": "寒い", - "snowy": "雪" - } - } - }, - "swiper": { - "loadingText": "コーディネートを生成中...", - "noMoreCards": { - "title": "候補がありません", - "subTitle": "後ほどもう一度お試しください。", - "retryButton": "再生成する" - } - }, - "chooseOutfitModal": { - "title": "このコーデを選択", - "description": "これが今日のあなたの勝負服になります!", - "confirmButton": "決定", - "cancelButton": "考え直す" - }, - "choosen": { - "title": "今日のコーディネート", - "description": "準備完了です!こちらが本日選んだコーディネートです。", - "change": { - "cta": "コーデを変更", - "title": "コーデの変更", - "description": "今日のコーディネートを変更しますか?この操作は取り消せません。", - "confirm": "はい、変更します", - "cancel": "いいえ、このままにする" - } - }, - "mixAndMatch": { - "button": "自分で組み合わせる!", - "title": "ミックス&マッチ", - "createButton": "コーディネートを作成" - } - }, - "reminder": { - "noTodayOutfit": "今日のコーディネートが未設定です。作成しましょう!", - "createButton": "作成する" - }, - "outfitDetails": { - "lastWornOn": "最後に着用した日: {{date}}" - }, - "itemPage": { - "delete": { - "title": "アイテムの削除", - "description": "このアイテムを削除してもよろしいですか?この操作は取り消せません。", - "confirm": "削除", - "cancel": "キャンセル" - } - } - }, - "transactional": { - "emails": { - "passwordReset": { - "subject": "Outfitter パスワードリセット", - "greeting": "{{username}} 様", - "body": "Outfitterアカウントのパスワードリセット要求を受け取りました。以下のリンクをクリックしてパスワードをリセットしてください:", - "resetLinkText": "パスワードをリセット", - "footer": "もしパスワードリセットをリクエストしていない場合は、このメールを無視してください。パスワードは変更されません。" - } - } - }, - "social": { - "profile": { - "follow": { - "follow": "フォロー", - "unfollow": "フォロー解除" - }, - "nbFollowers": "{{count}} フォロワー", - "nbFollowing": "{{count}} フォロー中", - "isFollowing": "あなたをフォローしています", - "notFollowing": "あなたをフォローしていません" - }, - "feed": { - "search": { - "placeholder": "ユーザーを検索..." - }, - "addPublication": { - "title": "投稿を作成", - "description": { - "label": "説明" - }, - "submit": "投稿する", - "todaysOutfit": "今日選んだコーディネートを着ていますか?" - }, - "loadingMore": "投稿を読み込み中...", - "noMorePosts": "表示する投稿はこれ以上ありません。", - "forYou": "おすすめ", - "followedFeed": "フォロー中" - }, - "post": { - "delete": { - "title": "投稿の削除", - "description": "この投稿を削除してもよろしいですか?この操作は取り消せません。", - "confirm": "削除", - "cancel": "キャンセル" - }, - "blurred": "自分のコーディネートを投稿すると表示されます", - "comments": { - "noComments": "コメントはまだありません。最初のコメントを書きましょう!", - "cancel": "キャンセル", - "addComment": { - "title": "コメントを追加", - "placeholder": "コメントを追加...", - "submit": "コメントを投稿", - "button": "コメントする" - }, - "reply": { - "button": "返信", - "placeholder": "返信を書く...", - "submit": "返信を投稿", - "title": "{{username}}さんに返信" - } - } - } - }, - "homepage": { - "hero": { - "version": "Outfitter v{{version}} 登場", - "slogan": "着て、シェアして、自分らしく。", - "description": "Outfitterはあなたのワードローブの良きパートナーです。ファッションを整理し、おすすめのコーディネートを提案。スタイル主導のコミュニティと繋がることができます。ファッションをもっと身近に、軽やかに。", - "cta": { - "findMyLook": "自分のルックを探す", - "learnMore": "もっと詳しく" - } - }, - "about": { - "badge": "Outfitterについて", - "title": "Outfitterとは?", - "intro": "Outfitterは、トゥールーズのポール・サバティエ大学におけるコンピュータサイエンス学士3年次の意欲的な大学プロジェクトから誕生しました。情熱的な学生チームによって開始されたこのアプリケーションは、イノベーションへの取り組みと、日常の課題に対する実用的なソリューションの創造を反映しています。この学術プロジェクトは、技術的な専門知識と創造性を組み合わせ、優れたユーザーエクスペリエンスを提供する本物のモダンなプラットフォームへと進化しました。", - "description": "Outfitterは、ワードローブの管理とスタイリングの方法に革命を起こすために設計された最先端のウェブアプリケーションです。私たちのミッションは、衣類を整理し、新しいコーディネートのアイデアを発見し、志を同じくする人々の活気あるコミュニティとつながるための革新的なツールを提供することで、ファッション愛好家をサポートすることです。" - }, - "features": { - "badge": "機能", - "title": "提供するサービス", - "items": { - "wardrobeManagement": { - "title": "1. ワードローブ管理", - "description": "直感的な管理システムで、衣類アイテムを簡単にカタログ化して整理。詳細や写真を追加し、カテゴリ分けすることで、必要な服をすぐに見つけられます。" - }, - "outfitGeneration": { - "title": "2. コーディネート生成", - "description": "あなたのワードローブに基づいた、パーソナライズされたコーディネートの提案。スマートなアルゴリズムが、毎日最高のあなたでいられるよう、スタイリッシュな組み合わせを提案します。" - }, - "exploreShare": { - "title": "3. 探索とシェア", - "description": "ファッション愛好家のコミュニティに参加しましょう。自分のコーデをシェアしたり、他人のスタイルからインスピレーションを得たり、新しいトレンドやスタイルを発見したりできます。" - } - } - } - }, - "footer": { - "links": { - "home": "ホーム", - "about": "について", - "features": "機能", - "account": "アカウント", - "logIn": "ログイン", - "signUp": "新規登録" - }, - "rights": "© {{year}} Outfitter. All rights reserved.", - "madeWith": "Made with ❤️" - }, - "ics": { - "event": { - "title": "{{date}}のコーディネート", - "description": "こちらからコーディネートをチェック: {{url}}" - } - }, - "wrapped": { - "cta": "Outfitter Wrapped(振り返り)を見る", - "tabs": { - "mostWornItems": "最も着たアイテム", - "mostLikedPost": "一番「いいね」された投稿" - }, - "noLikedPost": "今年はまだコーディネートを投稿していません。", - "noWornItems": "今年はまだコーディネートを着ていません。" - } -} diff --git a/src/lib/i18n/messages/sp.json b/src/lib/i18n/messages/sp.json deleted file mode 100644 index 188b167..0000000 --- a/src/lib/i18n/messages/sp.json +++ /dev/null @@ -1,627 +0,0 @@ -{ - "seo": { - "titleSuffix": "Outfitter", - "defaults": { - "title": "Outfitter", - "description": "Bienvenido a Outfitter, tu generador personal de looks y gestor de armario. Crea conjuntos con estilo a partir de tus prendas.", - "error": { - "title": "Ha ocurrido un error", - "description": "Lo sentimos, ha ocurrido un error inesperado. Por favor, inténtalo de nuevo más tarde." - } - }, - "home": { - "homePage": { - "title": "Inicio", - "description": "Bienvenido a Outfitter, tu generador personal de looks y gestor de armario. Crea conjuntos con estilo a partir de tus prendas." - }, - "legal": { - "tos": { - "title": "Términos de servicio", - "description": "Consulta los términos de servicio para usar Outfitter." - }, - "privacyPolicy": { - "title": "Política de privacidad", - "description": "Lee la política de privacidad para entender cómo Outfitter trata tus datos." - } - } - }, - "auth": { - "logIn": { - "title": "Iniciar sesión", - "description": "Accede a tu cuenta de Outfitter para gestionar tu armario y generar looks adaptados a tus prendas." - }, - "signUp": { - "title": "Registrarse", - "description": "Crea una cuenta en Outfitter para empezar a gestionar tu armario y generar looks personalizados." - }, - "forgotPassword": { - "title": "Recuperar contraseña", - "description": "Restablece la contraseña de tu cuenta Outfitter para recuperar el acceso a tus funciones de armario y generación de looks." - }, - "resetPassword": { - "title": "Restablecer contraseña", - "description": "Establece una nueva contraseña para tu cuenta Outfitter y recupera el acceso a tus funciones." - } - }, - "account": { - "title": "Cuenta", - "description": "Gestiona tu cuenta de Outfitter", - "settings": { - "title": "Ajustes de la cuenta", - "description": "Gestiona los ajustes de tu cuenta: nombre de usuario, correo, contraseña y opciones de seguridad (TOTP, passkeys)." - } - }, - "wardrobe": { - "title": "Armario", - "description": "Gestiona tu armario añadiendo artículos y generando looks estilizados a partir de tu colección.", - "item": { - "title": "Artículo", - "description": "Ver y gestionar los detalles de este artículo en tu armario.", - "edit": { - "title": "Editar artículo", - "description": "Modifica la información de este artículo en tu armario." - } - } - }, - "social": { - "feed": { - "title": "Novedades", - "description": "Explora el feed de la comunidad Outfitter para ver looks y artículos compartidos por otros usuarios." - }, - "post": { - "title": "Publicación", - "description": "Ver un look o artículo compartido por un usuario en la comunidad Outfitter." - }, - "profile": { - "title": "Perfil de {{username}}", - "description": "Explora el perfil de {{username}} en Outfitter para ver su armario, sus looks y sus artículos." - } - } - }, - "errors": { - "auth": { - "badUsername": "No se encontró ningún usuario con ese nombre", - "invalidCredentials": "Credenciales inválidas. Por favor, inténtalo de nuevo.", - "createUser": "Error al crear el usuario", - "usernameTaken": "Ese nombre de usuario ya está en uso. Por favor elige otro.", - "userDoesNotExist": "El usuario no existe.", - "userNotFound": "Usuario no encontrado.", - "userIsRequired": "Se requiere el usuario.", - "usernameTooShort": "El nombre de usuario debe tener al menos 3 caracteres.", - "usernameTooLong": "El nombre de usuario debe tener como máximo 20 caracteres.", - "unauthorized": "No autorizado.", - "emailInUse": "Este correo ya está en uso por una cuenta activa.", - "invalidEmail": "Dirección de correo inválida.", - "createPasskey": "Error al crear la passkey", - "passkeyAlreadyExists": "Esta passkey ya existe.", - "challengeExpired": "El desafío ha expirado. Por favor, inténtalo de nuevo.", - "noPasskey": "No hay passkey registrada para este usuario.", - "verificationFailed": "La verificación ha fallado. Por favor, inténtalo de nuevo.", - "passkeyLoginFailed": "Error en el inicio de sesión con passkey. Por favor, inténtalo de nuevo.", - "passwordTooShort": "La contraseña debe tener al menos 8 caracteres.", - "passwordTooLong": "La contraseña debe tener como máximo 100 caracteres.", - "totp": { - "invalidToken": "Token TOTP inválido.", - "registerFailed": "Error al registrar el TOTP. Por favor, inténtalo de nuevo.", - "invalidCode": "Código TOTP inválido.", - "notEnabled": "Debes activar TOTP para realizar esta acción.", - "needTOTP": "Necesitas proporcionar un código TOTP para realizar esta acción." - }, - "passwordReset": { - "noToken": "¡No se proporcionó token de restablecimiento!", - "expiredToken": "¡La solicitud ha expirado!", - "passwordsDontMatch": "¡Las contraseñas no coinciden!", - "noAccountWithEmail": "¡No hay ninguna cuenta asociada a este correo!", - "wrongCurrentPassword": "¡Contraseña actual incorrecta!" - } - }, - "server": { - "connectionRefused": "Conexión rechazada. Por favor, inténtalo de nuevo más tarde.", - "forbidden": "Prohibido", - "weatherFetchFailed": "Error al obtener los datos del tiempo. Por favor, inténtalo de nuevo más tarde." - }, - "account": { - "settings": { - "cannotChangeUsername": "No se puede cambiar el nombre de usuario.", - "invalidProfilePicture": "Imagen de perfil inválida. Por favor, sube una imagen válida." - } - }, - "clothing": { - "item": { - "requiredField": "El{{s}} campo{{s}} {{is}} requerido: {{field}}", - "classificationFailed": "No se pudo clasificar el artículo. Por favor, introduce los valores manualmente.", - "missingImage": "Se requiere un archivo de imagen.", - "name": "Por favor, introduce un nombre para este artículo (máx. 100 caracteres).", - "description": "Por favor, proporciona una descripción para este artículo (máx. 500 caracteres).", - "type": "Por favor, selecciona un tipo para este artículo.", - "color": "Por favor, selecciona un color para este artículo.", - "pattern": "Por favor, selecciona un estampado para este artículo.", - "image": "Por favor, toma una foto de este artículo.", - "notFound": "Artículo no encontrado." - }, - "outfit": { - "delete": "Error al eliminar el conjunto. Por favor, inténtalo de nuevo más tarde.", - "notAuthorized": "Este conjunto no te pertenece.", - "notFound": "Conjunto no encontrado." - } - }, - "outfitGeneration": { - "notEnoughItems": "No hay suficientes artículos para generar un conjunto. Añade más artículos a tu armario.", - "apiError": "Error al generar el conjunto debido a un error interno. Por favor, inténtalo de nuevo más tarde." - }, - "social": { - "cannotFollowYourself": "No puedes seguirte a ti mismo.", - "searchFailed": "Error al buscar usuarios. Por favor, inténtalo de nuevo más tarde.", - "item": { - "requiredField": "El{{s}} campo{{s}} {{is}} requerido: {{field}}" - }, - "post": { - "notFound": "Publicación no encontrada.", - "deleteFailed": "Error al eliminar la publicación. Por favor, inténtalo de nuevo más tarde.", - "notAuthorized": "No estás autorizado para eliminar esta publicación.", - "comment": { - "notFound": "Comentario no encontrado", - "deleteFailed": "No se pudo eliminar el comentario", - "emptyContent": "El contenido del comentario no puede estar vacío" - } - } - }, - "icsToken": { - "creationFailed": "Error al crear el enlace de calendario. Por favor, inténtalo de nuevo más tarde.", - "revocationFailed": "Error al revocar el enlace de calendario. Por favor, inténtalo de nuevo más tarde.", - "notFound": "Enlace de calendario no encontrado." - } - }, - "successes": { - "passkeyAdded": "Passkey añadida con éxito.", - "passkeyDeleted": "Passkey eliminada con éxito.", - "setUpTOTP": "TOTP configurado con éxito.", - "unlinkTOTP": "TOTP desactivado con éxito.", - "resetPasswordRequest": "¡Revisa tu bandeja de entrada para restablecer tu contraseña!", - "resetPassword": "¡Tu contraseña se ha restablecido con éxito!", - "usernameUpdated": "Nombre de usuario actualizado con éxito.", - "emailUpdated": "Correo actualizado con éxito.", - "profilePictureUpdated": "Foto de perfil actualizada con éxito.", - "ics": { - "created": "Enlace de calendario creado con éxito.", - "revoked": "Enlace de calendario revocado con éxito.", - "copied": "Enlace de calendario copiado al portapapeles." - }, - "clothing": { - "item": { - "created": "Artículo creado con éxito.", - "updated": "Artículo actualizado con éxito." - } - }, - "social": { - "item": { - "created": "Publicado." - }, - "post": { - "edited": "Publicación editada con éxito." - }, - "comment": { - "added": "Comentario añadido con éxito." - } - } - }, - "auth": { - "username": "Nombre de usuario", - "email": "Correo", - "password": "Contraseña", - "confirmPassword": "Confirmar contraseña", - "newPassword": "Nueva contraseña", - "currentPassword": "Contraseña actual", - "rememberMe": "Recuérdame", - "forgotPasswordKeyword": "¿Olvidaste tu contraseña?", - "submit": "Enviar", - "signIn": { - "title": "Inicia sesión en tu cuenta", - "alreadyHaveAnAccount": { - "text": "¿Ya tienes una cuenta?", - "cta": "Iniciar sesión" - } - }, - "logIn": { - "title": "Inicia sesión en tu cuenta", - "dontHaveAnAccount": { - "text": "¿No tienes una cuenta?", - "cta": "Registrarse" - } - }, - "passkey": { - "separator": "o", - "button": "Usar passkey" - }, - "totp": { - "logIn": { - "title": "Iniciar sesión con TOTP", - "description": "Introduce tu código TOTP para iniciar sesión.", - "nextButton": "Siguiente" - }, - "settings": { - "page": { - "enable": { - "description": "La autenticación de dos factores no está activada para tu cuenta.", - "button": "Activar" - }, - "disable": { - "description": "La autenticación de dos factores está activada para tu cuenta.", - "button": "Desactivar" - } - }, - "manage": { - "title": "Gestionar TOTP", - "description": "Puedes gestionar tus ajustes TOTP aquí.", - "button": "Desactivar TOTP", - "secondStep": { - "description": "Confirma tu identidad introduciendo tu código TOTP.", - "cancel": "Cancelar", - "confirm": "Desactivar" - } - }, - "setup": { - "title": "Configurar TOTP", - "description": "Escanea el código QR con tu app de autenticación para configurar el TOTP.", - "button": "Configurar TOTP", - "qrCodeAlt": "Código QR para configurar 2FA", - "unableToScan": "¿No puedes escanear? Puedes mostrar la clave secreta:", - "showHideValue": "Mostrar/ocultar valor", - "confirmCode": "Confirmar código", - "nextButton": "Siguiente" - }, - "success": { - "title": "TOTP configurado con éxito", - "description": "Has configurado el TOTP con éxito para tu cuenta.", - "message": "Tu cuenta ahora está protegida con una capa adicional de seguridad gracias al TOTP.", - "buttons": { - "close": "Cerrar" - } - } - } - }, - "forgotPassword": { - "title": "¿Olvidaste tu contraseña?", - "button": "Enviar enlace de restablecimiento" - }, - "resetPassword": { - "title": "Restablecer tu contraseña", - "button": "Restablecer contraseña", - "successAction": "Iniciar sesión" - }, - "formWrapper": { - "back": "Volver" - } - }, - "errorPage": { - "cta": { - "goHome": "Ir al inicio", - "goBack": "Volver" - } - }, - "account": { - "tabs": { - "settings": "Ajustes", - "legal": "Aviso legal" - }, - "settings": { - "tabs": { - "general": { - "title": "General", - "description": "Actualiza los ajustes generales de tu cuenta.", - "changeUsername": { - "title": "Cambiar nombre de usuario", - "alerts": { - "taken": { - "title": "Nombre no disponible", - "description": "El nombre de usuario {{username}} ya está en uso." - }, - "available": { - "title": "Nombre disponible", - "description": "¡El nombre de usuario {{username}} está disponible!" - } - } - }, - "changeLang": { - "title": "Idioma" - }, - "changeEmail": { - "title": "Cambiar correo" - }, - "theme": { - "theme": "Tema", - "mode": "Modo", - "options": { - "light": "Claro", - "dark": "Scuro", - "system": "Sistema" - } - }, - "logout": "Cerrar sesión", - "danger": { - "title": "Zona de peligro" - }, - "profilePicture": "Foto de perfil" - }, - "password": { - "title": "Contraseña", - "description": "Cambia tu contraseña para mantener tu cuenta segura." - }, - "calendars": { - "title": "Calendarios", - "description": "Gestiona tus integraciones y ajustes de calendario.", - "createButton": "Crear un nuevo enlace de calendario", - "revokeButton": "Revocar" - }, - "TOTP": { - "title": "TOTP", - "description": "La autenticación de dos factores (2FA) añade una capa de seguridad adicional a tu cuenta solicitando una segunda forma de verificación al iniciar sesión." - }, - "passkey": { - "title": "Passkey", - "description": "Una passkey es una forma segura de iniciar sesión sin contraseña. Utiliza claves criptográficas almacenadas en tu dispositivo.", - "register": { - "button": "Registrar passkey" - }, - "remove": { - "button": "Eliminar passkey", - "modal": { - "confirm": "Eliminar", - "title": "Eliminar passkey", - "description": "¿Estás seguro de que deseas eliminar esta passkey? Esta acción no se puede deshacer.", - "cancel": "Cancelar" - } - } - } - } - } - }, - "nav": { - "home": "Inicio", - "about": "Acerca de", - "pricing": "Precios", - "outfits": "Looks", - "account": "Cuenta", - "settings": "Ajustes", - "feed": "Novedades", - "logIn": "Iniciar sesión", - "signUp": "Registrarse", - "wardrobe": "Armario", - "add": { - "title": "Añadir al armario", - "description": "Añade un artículo o look nuevo a tu armario.", - "item": "Artículo", - "outfit": "Look" - } - }, - "wardrobe": { - "createItem": { - "title": "Añadir un artículo", - "description": "Rellena el formulario para añadir una nueva prenda a tu armario.", - "submit": "Crear artículo", - "fields": { - "name": "Nombre", - "type": "Tipo", - "color": "Color", - "pattern": "Estampado", - "description": "Descripción", - "image": { - "label": "Tomar una foto" - } - } - }, - "itemList": { - "items": { - "title": "Artículos", - "empty": { - "title": "Aún no hay prendas", - "description": "No has añadido ninguna prenda todavía. Empieza creando tu primer artículo.", - "createButton": "Crear artículo" - } - }, - "outfits": { - "title": "Looks", - "empty": { - "title": "Aún no hay looks", - "description": "Todavía no has creado ningún look. Empieza mezclando y combinando tus prendas.", - "createButton": "Crear look" - } - } - }, - "outfitGeneration": { - "initialQuestions": { - "style": { - "question": "¿Qué estilo buscas hoy?", - "options": { - "default": "Déjanos elegir", - "comfort": "Comodidad", - "new": "Nuevo", - "style": "Estilizado", - "formal": "Formal" - } - }, - "weather": { - "question": "¿Qué tiempo hace?", - "options": { - "sunny": "Soleado", - "rainy": "Lluvioso", - "cold": "Frío", - "snowy": "Nevado" - } - } - }, - "swiper": { - "loadingText": "Generando tus looks...", - "noMoreCards": { - "title": "No hay más looks", - "subTitle": "Vuelve más tarde.", - "retryButton": "Reintentar generación" - } - }, - "chooseOutfitModal": { - "title": "Elegir este look", - "description": "¡Este será el look que llevarás hoy!", - "confirmButton": "Elegir", - "cancelButton": "He cambiado de opinión" - }, - "choosen": { - "title": "Look del día", - "description": "¡Estás listo/a para hoy! Aquí tienes el look elegido.", - "change": { - "cta": "Cambiar look", - "title": "Cambiar look", - "description": "¿Estás seguro/a de que quieres cambiar tu look para hoy? Esta acción es irreversible.", - "confirm": "Sí, cambiar", - "cancel": "No, mantener" - } - }, - "mixAndMatch": { - "button": "¡Tengo inspiración!", - "title": "Mezclar y combinar", - "createButton": "Crear look" - } - }, - "reminder": { - "noTodayOutfit": "No tienes un look para hoy — ¡crea uno!", - "createButton": "Crear" - }, - "outfitDetails": { - "lastWornOn": "Última vez usado el {{date}}" - }, - "itemPage": { - "delete": { - "title": "Eliminar artículo", - "description": "¿Estás seguro/a de que quieres eliminar este artículo? Esta acción es irreversible.", - "confirm": "Eliminar", - "cancel": "Cancelar" - } - } - }, - "transactional": { - "emails": { - "passwordReset": { - "subject": "Restablecimiento de contraseña Outfitter", - "greeting": "Hola {{username}},", - "body": "Hemos recibido una solicitud para restablecer la contraseña de tu cuenta Outfitter. Haz clic en el enlace siguiente para restablecer tu contraseña:", - "resetLinkText": "Restablecer contraseña", - "footer": "Si no solicitaste un restablecimiento, ignora este correo. Tu contraseña permanecerá sin cambios." - } - } - }, - "social": { - "profile": { - "follow": { - "follow": "Seguir", - "unfollow": "Dejar de seguir" - }, - "nbFollowers": "{{count}} seguidor{{s}}", - "nbFollowing": "{{count}} siguiendo{{s}}", - "isFollowing": "El usuario te sigue", - "notFollowing": "El usuario no te sigue" - }, - "feed": { - "search": { - "placeholder": "Buscar usuarios..." - }, - "addPublication": { - "title": "Crear publicación", - "description": { - "label": "Descripción" - }, - "submit": "Publicar", - "todaysOutfit": "¿Llevas el look que seleccionaste para hoy?" - }, - "loadingMore": "Cargando más publicaciones...", - "noMorePosts": "No hay más publicaciones para mostrar.", - "forYou": "Para ti", - "followedFeed": "Seguidos" - }, - "post": { - "delete": { - "title": "Eliminar publicación", - "description": "¿Estás seguro/a de que quieres eliminar esta publicación? Esta acción es irreversible.", - "confirm": "Eliminar", - "cancel": "Cancelar" - }, - "blurred": "Publica una foto de tu look para ver este contenido", - "comments": { - "noComments": "Aún no hay comentarios. ¡Sé el primero/a en comentar!", - "cancel": "Cancelar", - "addComment": { - "title": "Añadir un comentario", - "placeholder": "Añadir un comentario...", - "submit": "Publicar comentario", - "button": "Añadir comentario" - }, - "reply": { - "button": "Responder", - "placeholder": "Escribir una respuesta...", - "submit": "Publicar respuesta", - "title": "Responder a {{username}}" - } - } - } - }, - "homepage": { - "hero": { - "version": "Presentando Outfitter v{{version}}", - "slogan": "Llévalo, Compártelo, Consérvalo.", - "description": "Outfitter es tu compañero de armario. Organiza tu estilo, recibe recomendaciones y conéctate con una comunidad apasionada. La moda, simplificada.", - "cta": { - "findMyLook": "Encuentra mi look", - "learnMore": "Saber más" - } - }, - "about": { - "badge": "Sobre Outfitter", - "title": "¿Quiénes somos?", - "intro": "Outfitter nació de un ambicioso proyecto universitario en 3º año de Ingeniería Informática en la Université Paul Sabatier de Toulouse. Iniciado por un equipo de estudiantes apasionados, este proyecto refleja nuestro compromiso con la innovación y la creación de soluciones prácticas. Este proyecto académico se ha transformado en una plataforma moderna, combinando experiencia técnica y creatividad para ofrecer una excelente experiencia de usuario.", - "description": "Outfitter es una aplicación web concebida para revolucionar la forma en que gestionas y estilizas tu armario. Nuestra misión es ayudar a los amantes de la moda ofreciendo herramientas innovadoras para organizar prendas, descubrir ideas de looks y conectar con una comunidad dinámica." - }, - "features": { - "badge": "Funcionalidades", - "title": "Lo que ofrecemos", - "items": { - "wardrobeManagement": { - "title": "1. Gestión del armario", - "description": "Catalogar y organizar tus prendas fácilmente con nuestro sistema intuitivo. Añade detalles, fotos y clasifica tus piezas para acceder rápidamente." - }, - "outfitGeneration": { - "title": "2. Generación de looks", - "description": "Recibe recomendaciones personalizadas según tu armario. Nuestro algoritmo propone combinaciones estilizadas para ayudarte a lucir lo mejor cada día." - }, - "exploreShare": { - "title": "3. Explorar & Compartir", - "description": "Únete a una comunidad de amantes de la moda. Comparte tus looks, inspírate en los demás y descubre nuevas tendencias." - } - } - } - }, - "footer": { - "links": { - "home": "Inicio", - "about": "Acerca de", - "features": "Funcionalidades", - "account": "Cuenta", - "logIn": "Iniciar sesión", - "signUp": "Registrarse" - }, - "rights": "© {{year}} Outfitter. Todos los derechos reservados.", - "madeWith": "Hecho con ❤️" - }, - "ics": { - "event": { - "title": "Look para el {{date}}", - "description": "Consulta el look en {{url}}" - } - }, - "wrapped": { - "cta": "Ver tu resumen Outfitter", - "tabs": { - "mostWornItems": "Prendas más usadas", - "mostLikedPost": "Publicación más gustada" - }, - "noLikedPost": "No publicaste ningún look este año.", - "noWornItems": "No has llevado ningún look este año." - } -} diff --git a/src/lib/server/db/ICSToken.ts b/src/lib/server/db/ICSToken.ts index 43f60c4..4e8d981 100755 --- a/src/lib/server/db/ICSToken.ts +++ b/src/lib/server/db/ICSToken.ts @@ -1,5 +1,5 @@ import type { ICSToken, User, UUID } from '$lib/types'; -import pool from '.'; +import { sql } from 'bun'; export interface ICSTokenTable { id: UUID; @@ -14,31 +14,26 @@ export class ICSTokenDAO { } static async getFromToken(token: UUID): Promise { - const result = await pool.query('SELECT * FROM ics_token WHERE id = $1', [ - token, - ]); - if (result.rows.length === 0) { + const rows = await sql`SELECT * FROM ics_token WHERE id = ${token}`; + if (rows.length === 0) { return null; } - return ICSTokenDAO.convertToToken(result.rows[0]); + return ICSTokenDAO.convertToToken(rows[0]); } static async getAllForUser(userId: User['id']): Promise { - const result = await pool.query('SELECT * FROM ics_token WHERE user_id = $1', [ - userId, - ]); - return result.rows.map(ICSTokenDAO.convertToToken); + const rows = await sql`SELECT * FROM ics_token WHERE user_id = ${userId}`; + return rows.map(ICSTokenDAO.convertToToken); } static async createToken(userId: User['id']): Promise { - const result = await pool.query( - 'INSERT INTO ics_token (user_id) VALUES ($1) RETURNING *', - [userId] - ); - return ICSTokenDAO.convertToToken(result.rows[0]); + const rows = await sql< + ICSTokenTable[] + >`INSERT INTO ics_token (user_id) VALUES (${userId}) RETURNING *`; + return ICSTokenDAO.convertToToken(rows[0]); } static async revokeToken(token: UUID) { - await pool.query('DELETE FROM ics_token WHERE id = $1', [token]); + await sql`DELETE FROM ics_token WHERE id = ${token}`; } } diff --git a/src/lib/server/db/clothingItem.ts b/src/lib/server/db/clothingItem.ts index 6965a0e..2faab24 100755 --- a/src/lib/server/db/clothingItem.ts +++ b/src/lib/server/db/clothingItem.ts @@ -1,8 +1,8 @@ import type { ClothingItem, UUID } from '$lib/types'; -import pool from '.'; import { unlink, writeFile } from 'node:fs/promises'; import { getEnv } from '../utils'; import { Caching } from './caching'; +import { sql } from 'bun'; export interface ClothingItemTable { id: UUID; @@ -43,14 +43,13 @@ export class ClothingItemDAO { color: ClothingItem['color'], pattern: ClothingItem['pattern'] ): Promise { - const res = await pool.query( - 'INSERT INTO clothing_item (user_id, name, description, type, color, pattern) VALUES ($1, $2, $3, $4, $5, $6) RETURNING *', - [userId, name, description, type, color, pattern] - ); - if (res.rows.length === 0) { + const [row] = await sql< + ClothingItemTable[] + >`INSERT INTO clothing_item (user_id, name, description, type, color, pattern) VALUES (${userId}, ${name}, ${description}, ${type}, ${color}, ${pattern}) RETURNING *`; + if (!row) { throw new Error('Failed to create clothing item'); } - const clothingItem = ClothingItemDAO.convertToClothingItem(res.rows[0]); + const clothingItem = ClothingItemDAO.convertToClothingItem(row); await ClothingItemDAO.writeClothingItemImage(clothingItem.id, imageBuffer); return clothingItem; @@ -60,13 +59,11 @@ export class ClothingItemDAO { const cached = await Caching.get(`clothingItem:${id}`); if (cached) return cached; - const res = await pool.query('SELECT * FROM clothing_item WHERE id = $1', [ - id, - ]); - if (res.rows.length === 0) { + const res = await sql`SELECT * FROM clothing_item WHERE id = ${id}`; + if (res.length === 0) { return null; } - const item = res.rows[0]; + const item = res[0]; const lastWornAt = await ClothingItemDAO.getLastWornAt(item.id); const clothingItem = ClothingItemDAO.convertToClothingItem(item, lastWornAt); await Caching.set(`clothingItem:${id}`, clothingItem); @@ -74,12 +71,11 @@ export class ClothingItemDAO { } static async getClothingItemsByUserId(userId: UUID): Promise { - const res = await pool.query( - 'SELECT * FROM clothing_item WHERE user_id = $1 ORDER BY created_at DESC', - [userId] - ); + const rows = await sql< + ClothingItemTable[] + >`SELECT * FROM clothing_item WHERE user_id = ${userId} ORDER BY created_at DESC`; const clothingItems: ClothingItem[] = []; - for (const row of res.rows) { + for (const row of rows) { const lastWornAt = await ClothingItemDAO.getLastWornAt(row.id); clothingItems.push(ClothingItemDAO.convertToClothingItem(row, lastWornAt)); } @@ -87,7 +83,7 @@ export class ClothingItemDAO { } static async deleteClothingItem(id: UUID): Promise { - await pool.query('DELETE FROM clothing_item WHERE id = $1', [id]); + await sql`DELETE FROM clothing_item WHERE id = ${id}`; await unlink(`assets/clothing_item/${id}.png`); await Caching.del(`clothingItem:${id}`); } @@ -100,19 +96,17 @@ export class ClothingItemDAO { interface LastWornAtResult { last_worn_at: Date | null; } - const res = await pool.query( - 'SELECT MAX(outfit.created_at) as last_worn_at FROM outfit INNER JOIN outfit_clothing_items ON outfit.id = outfit_clothing_items.outfit_id WHERE outfit_clothing_items.clothing_item_id = $1', - [item] - ); - return res.rows[0].last_worn_at; + const rows = await sql< + LastWornAtResult[] + >`SELECT MAX(outfit.created_at) as last_worn_at FROM outfit INNER JOIN outfit_clothing_items ON outfit.id = outfit_clothing_items.outfit_id WHERE outfit_clothing_items.clothing_item_id = ${item}`; + return rows[0].last_worn_at; } static async updateClothingItem(item: ClothingItem): Promise { - const res = await pool.query( - 'UPDATE clothing_item SET name = $1, description = $2, type = $3, color = $4, pattern = $5 WHERE id = $6 RETURNING *', - [item.name, item.description, item.type, item.color, item.pattern ?? null, item.id] - ); - if (res.rows.length === 0) { + const rows = await sql< + ClothingItemTable[] + >`UPDATE clothing_item SET ${sql(item, 'name', 'description', 'type', 'color', 'pattern')} WHERE id = ${item.id} RETURNING *`; + if (rows.length === 0) { throw new Error('Failed to update clothing item'); } await Caching.del(`clothingItem:${item.id}`); @@ -127,13 +121,12 @@ export class ClothingItemDAO { const cached = await Caching.get(`clothingItem:${clothingItemId}`); if (cached && cached.userId) return cached.userId; - const res = await pool.query<{ user_id: UUID }>( - 'SELECT user_id FROM clothing_item WHERE id = $1', - [clothingItemId] - ); - if (res.rows.length === 0) { + const rows = await sql< + { user_id: UUID }[] + >`SELECT user_id FROM clothing_item WHERE id = ${clothingItemId}`; + if (rows.length === 0) { return null; } - return res.rows[0].user_id; + return rows[0].user_id; } } diff --git a/src/lib/server/db/comment.ts b/src/lib/server/db/comment.ts index e052c01..8cb1e01 100644 --- a/src/lib/server/db/comment.ts +++ b/src/lib/server/db/comment.ts @@ -1,8 +1,8 @@ import { type Comment, type User, type UUID } from '$lib/types'; -import pool from '.'; import { filterText } from '../socialFilter'; import { PublicationDAO } from './publication'; import { UserDAO } from './user'; +import { sql } from 'bun'; export interface CommentTable { id: UUID; @@ -45,24 +45,23 @@ export class CommentDAO { throw new Error('errors.social.post.comments.parentComment.notFound'); } } - const res = await pool.query( - 'INSERT INTO comment (publication_id, user_id, comment_id, content) VALUES ($1, $2, $3, $4) RETURNING *', - [postId ?? null, userId, commentId ?? null, filterText(content)] - ); - return (await this.getComment(res.rows[0].id)) as Comment; + const rows = await sql< + CommentTable[] + >`INSERT INTO comment (publication_id, user_id, comment_id, content) VALUES (${postId ?? null}, ${userId}, ${commentId ?? null}, ${filterText(content)}) RETURNING *`; + return (await this.getComment(rows[0].id)) as Comment; } static async getComment(id: Comment['id'], replies = false): Promise { - const res = await pool.query('SELECT * FROM comment WHERE id = $1', [id]); - if (res.rowCount === 0) { + const rows = await sql`SELECT * FROM comment WHERE id = ${id}`; + if (rows.length === 0) { return null; } - const associatedUser = await UserDAO.getUserById(res.rows[0].user_id); + const associatedUser = await UserDAO.getUserById(rows[0].user_id); if (replies) { const replies = await this.getRepliesToComment(id); - return this.convertToComment(res.rows[0], associatedUser, replies); + return this.convertToComment(rows[0], associatedUser, replies); } else { - return this.convertToComment(res.rows[0], associatedUser); + return this.convertToComment(rows[0], associatedUser); } } @@ -95,12 +94,11 @@ export class CommentDAO { } static async getRepliesToComment(commentId: Comment['id']): Promise { - const res = await pool.query( - ` + const rows = await sql` WITH RECURSIVE comment_tree AS ( SELECT * FROM comment - WHERE comment_id = $1 + WHERE comment_id = ${commentId} UNION ALL SELECT c.* FROM comment c @@ -109,18 +107,15 @@ export class CommentDAO { SELECT * FROM comment_tree ORDER BY created_at ASC - `, - [commentId] - ); - return this.buildCommentTree(res.rows, commentId); + `; + return this.buildCommentTree(rows, commentId); } static async getCommentsForPost(postId: Comment['postId']): Promise { - const res = await pool.query( - ` + const rows = await sql` WITH RECURSIVE comment_tree AS ( SELECT * FROM comment - WHERE publication_id = $1 AND comment_id IS NULL + WHERE publication_id = ${postId} AND comment_id IS NULL UNION ALL SELECT c.* FROM comment c @@ -129,14 +124,12 @@ export class CommentDAO { SELECT * FROM comment_tree ORDER BY created_at ASC - `, - [postId] - ); - return this.buildCommentTree(res.rows, null); + `; + return this.buildCommentTree(rows, null); } static async deleteComment(commentId: Comment['id']): Promise { - await pool.query('DELETE FROM comment WHERE id = $1', [commentId]); + await sql`DELETE FROM comment WHERE id = ${commentId}`; } static async update( @@ -157,6 +150,6 @@ export class CommentDAO { } values.push(commentId); const query = `UPDATE comment SET ${fields.join(', ')} WHERE id = $${idx}`; - await pool.query(query, values); + await sql.unsafe(query, values); } } diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts deleted file mode 100755 index 7b716ad..0000000 --- a/src/lib/server/db/index.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { env } from '$env/dynamic/private'; -import { logger } from '$lib/utils/logger'; -import pg from 'pg'; - -const { Pool } = pg; - -let pool: pg.Pool; - -try { - pool = new Pool({ - host: env.POSTGRES_HOST, - user: env.POSTGRES_USER, - password: env.POSTGRES_PASSWORD, - database: env.POSTGRES_DB, - port: parseInt(env.POSTGRES_PORT ?? '', 10) || 5432, - }); -} catch (error) { - logger.error('Failed to create database pool:', error); - throw error; -} - -export default pool; diff --git a/src/lib/server/db/outfit.ts b/src/lib/server/db/outfit.ts index 73c71cb..78548ec 100755 --- a/src/lib/server/db/outfit.ts +++ b/src/lib/server/db/outfit.ts @@ -1,7 +1,7 @@ import type { ClothingItem, ClothingItemType, Outfit, OutfitPreview, UUID } from '$lib/types'; -import pool from '.'; import { Caching } from './caching'; import { ClothingItemDAO } from './clothingItem'; +import { sql } from 'bun'; export interface OutfitTable { id: UUID; @@ -26,16 +26,15 @@ export class OutfitDAO { static async getOutfitById(id: UUID): Promise { const cached = await Caching.get(`outfit:${id}`); if (cached) return cached; - const res = await pool.query('SELECT * FROM outfit WHERE id = $1', [id]); - if (res.rows.length === 0) { + const rows = await sql`SELECT * FROM outfit WHERE id = ${id}`; + if (rows.length === 0) { return null; } - const itemsRes = await pool.query( - 'SELECT * FROM outfit_clothing_items WHERE outfit_id = $1', - [id] - ); - const clothingItemIds = itemsRes.rows.map((row) => row.clothing_item_id); + const itemsRes = await sql< + OutfitItemTable[] + >`SELECT * FROM outfit_clothing_items WHERE outfit_id = ${id}`; + const clothingItemIds = itemsRes.map((row) => row.clothing_item_id); const clothingItems: ClothingItem[] = []; for (const clothingItemId of clothingItemIds) { @@ -45,24 +44,23 @@ export class OutfitDAO { } } - const outfit = OutfitDAO.convertToOutfit(res.rows[0], clothingItems); + const outfit = OutfitDAO.convertToOutfit(rows[0], clothingItems); await Caching.set(`outfit:${id}`, outfit); return outfit; } - static async getAllUserOutfits(userId: UUID): Promise { - const res = await pool.query( - 'SELECT * FROM outfit WHERE user_id = $1 ORDER BY created_at DESC', - [userId] - ); + static async getAllUserOutfits(userId: UUID, past = false): Promise { + const rows = await sql< + OutfitTable[] + >`SELECT * FROM outfit WHERE user_id = ${userId} AND created_at >= ${past ? new Date(0) : new Date(new Date().setHours(0, 0, 0, 0))} ORDER BY created_at DESC`; + const outfits: Outfit[] = []; - for (const row of res.rows) { - const itemsRes = await pool.query( - 'SELECT * FROM outfit_clothing_items WHERE outfit_id = $1', - [row.id] - ); - const clothingItemIds = itemsRes.rows.map((r) => r.clothing_item_id); + for (const row of rows) { + const itemsRows = await sql< + OutfitItemTable[] + >`SELECT * FROM outfit_clothing_items WHERE outfit_id = ${row.id}`; + const clothingItemIds = itemsRows.map((r) => r.clothing_item_id); const clothingItems: ClothingItem[] = []; for (const clothingItemId of clothingItemIds) { @@ -78,21 +76,20 @@ export class OutfitDAO { return outfits; } - static async createOutfit(userId: UUID, outfit: OutfitPreview): Promise { - const res = await pool.query( - 'INSERT INTO outfit (user_id) VALUES ($1) RETURNING *', - [userId] - ); - if (res.rows.length === 0) { + static async createOutfit( + userId: UUID, + outfit: OutfitPreview & { createdAt?: Date } + ): Promise { + const rows = await sql< + OutfitTable[] + >`INSERT INTO outfit (user_id, created_at) VALUES (${userId}, ${outfit.createdAt ?? new Date()}) RETURNING *`; + if (rows.length === 0) { throw new Error('Failed to create outfit'); } - const outfitRow = res.rows[0]; + const outfitRow = rows[0]; for (const item of outfit.items) { - await pool.query( - 'INSERT INTO outfit_clothing_items (outfit_id, clothing_item_id) VALUES ($1, $2)', - [outfitRow.id, item.id] - ); + await sql`INSERT INTO outfit_clothing_items (outfit_id, clothing_item_id) VALUES (${outfitRow.id}, ${item.id})`; } return this.getOutfitById(outfitRow.id) as Promise; @@ -100,18 +97,17 @@ export class OutfitDAO { static async deleteOutfit(outfitId: UUID): Promise { // Remove references from associated posts - await pool.query('UPDATE publication SET outfit_id = NULL WHERE outfit_id = $1', [outfitId]); - await pool.query('DELETE FROM outfit_clothing_items WHERE outfit_id = $1', [outfitId]); - await pool.query('DELETE FROM outfit WHERE id = $1', [outfitId]); + await sql`UPDATE publication SET outfit_id = NULL WHERE outfit_id = ${outfitId}`; + await sql`DELETE FROM outfit_clothing_items WHERE outfit_id = ${outfitId}`; + await sql`DELETE FROM outfit WHERE id = ${outfitId}outfitId}`; await Caching.del(`outfit:${outfitId}`); } static async outfitBelongsToUser(userId: UUID, outfitId: UUID): Promise { - const res = await pool.query( - 'SELECT * FROM outfit WHERE id = $1 AND user_id = $2', - [outfitId, userId] - ); - if (res.rows.length === 0) throw new Error('errors.clothing.outfit.notAuthorized'); + const rows = await sql< + OutfitTable[] + >`SELECT * FROM outfit WHERE id = ${outfitId} AND user_id = ${userId}`; + if (rows.length === 0) throw new Error('errors.clothing.outfit.notAuthorized'); } static async getTodaysOutfitIdForUser( @@ -120,14 +116,13 @@ export class OutfitDAO { ): Promise { if (!todaysOutfit) return null; - const res = await pool.query( - 'SELECT * FROM outfit WHERE user_id = $1 ORDER BY created_at DESC LIMIT 1', - [userId] - ); - if (res.rows.length === 0) { + const rows = await sql< + OutfitTable[] + >`SELECT * FROM outfit WHERE user_id = ${userId} ORDER BY created_at DESC LIMIT 1`; + if (rows.length === 0) { return null; } - return res.rows[0].id; + return rows[0].id; } } diff --git a/src/lib/server/db/passkey.ts b/src/lib/server/db/passkey.ts index 9a3f722..c920fd5 100755 --- a/src/lib/server/db/passkey.ts +++ b/src/lib/server/db/passkey.ts @@ -4,10 +4,10 @@ import type { CredentialDeviceType, WebAuthnCredential, } from '@simplewebauthn/browser'; -import pool from '.'; import { Caching } from './caching'; import { UserDAO } from './user'; import { getEnv } from '../utils'; +import { sql } from 'bun'; export const rpName = 'Outfitter'; export const rpID = getEnv('ORIGIN', 'localhost:5173').replace(/^https?:\/\//, ''); @@ -43,13 +43,11 @@ export class PasskeyDAO { const cachedPasskey = await Caching.get(`user:${userId}:passkey`); if (cachedPasskey) return cachedPasskey; - const result = await pool.query('SELECT * FROM passkey WHERE user_id = $1', [ - userId, - ]); - if (result.rows.length === 0) { + const rows = await sql`SELECT * FROM passkey WHERE user_id = ${userId}`; + if (rows.length === 0) { return null; } - const passkey = PasskeyDAO.convertToPasskey(result.rows[0]); + const passkey = PasskeyDAO.convertToPasskey(rows[0]); await Caching.set(`user:${userId}:passkey`, passkey); return passkey; } @@ -71,43 +69,33 @@ export class PasskeyDAO { transport: JSON.stringify(credential.transports || []), created_at: new Date().toISOString(), }; - const result = await pool.query( - 'INSERT INTO passkey (id, public_key, user_id, webauthn_id, counter, device_type, backed_up, transport) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING *', - [ - newPasskey.id, - Buffer.from(newPasskey.public_key, 'base64'), - newPasskey.user_id, - newPasskey.webauthn_id, - newPasskey.counter, - newPasskey.device_type, - newPasskey.backed_up, - newPasskey.transport, - ] - ); - if (result.rows.length === 0) { + const rows = await sql< + PasskeyTable[] + >`INSERT INTO passkey (id, public_key, user_id, webauthn_id, counter, device_type, backed_up, transport) VALUES (${newPasskey.id}, ${Buffer.from(newPasskey.public_key, 'base64')}, ${newPasskey.user_id}, ${newPasskey.webauthn_id}, ${newPasskey.counter}, ${newPasskey.device_type}, ${newPasskey.backed_up}, ${newPasskey.transport}) RETURNING *`; + if (rows.length === 0) { throw new Error('errors.auth.createPasskey'); } - const passkey = PasskeyDAO.convertToPasskey(result.rows[0]); + const passkey = PasskeyDAO.convertToPasskey(rows[0]); await Caching.del(`user:${userId}:passkey`); // Invalidate cache await Caching.del(`user:${userId}`); // Invalidate cache return passkey; } static async getPasskeyByCredentialID(credentialID: Passkey['id']): Promise { - const result = await pool.query('SELECT * FROM passkey WHERE webauthn_id = $1', [ - credentialID, - ]); - if (result.rows.length === 0) { + const rows = await sql< + PasskeyTable[] + >`SELECT * FROM passkey WHERE webauthn_id = ${credentialID}`; + if (rows.length === 0) { return null; } - const passkey = PasskeyDAO.convertToPasskey(result.rows[0]); + const passkey = PasskeyDAO.convertToPasskey(rows[0]); return passkey; } static async deletePasskey(userId: User['id']): Promise { - const result = await pool.query('DELETE FROM passkey WHERE user_id = $1 RETURNING *', [userId]); - if (result.rowCount === 0) { + const result = await sql`DELETE FROM passkey WHERE user_id = ${userId} RETURNING *`; + if (result.affectedRows === 0) { throw new Error('errors.auth.deletePasskey'); } await Caching.del(`user:${userId}:passkey`); // Invalidate cache @@ -115,14 +103,13 @@ export class PasskeyDAO { } static async getUserByCredentialID(credentialID: Passkey['id']): Promise { - const result = await pool.query( - 'SELECT user_id FROM passkey WHERE webauthn_id = $1', - [credentialID] - ); - if (result.rows.length === 0) { + const rows = await sql< + PasskeyTable[] + >`SELECT user_id FROM passkey WHERE webauthn_id = ${credentialID}`; + if (rows.length === 0) { return null; } - const userId = result.rows[0].user_id; + const userId = rows[0].user_id; return UserDAO.getUserById(userId); } } diff --git a/src/lib/server/db/publication.ts b/src/lib/server/db/publication.ts index 1eed87b..3b609d2 100644 --- a/src/lib/server/db/publication.ts +++ b/src/lib/server/db/publication.ts @@ -8,7 +8,6 @@ import { type User, type UUID, } from '$lib/types'; -import pool from '.'; import { getEnv } from '../utils'; import { unlink, writeFile } from 'node:fs/promises'; import { UserDAO } from './user'; @@ -17,6 +16,7 @@ import { ReactionDAO } from './reaction'; import { filterText } from '../socialFilter'; import { DateUtils } from '$lib/utils'; import { CommentDAO } from './comment'; +import { sql } from 'bun'; export interface PublicationTable { id: UUID; @@ -71,24 +71,21 @@ export class PublicationDAO { todaysOutfit: boolean ): Promise { const outfitId = await OutfitDAO.getTodaysOutfitIdForUser(userId, todaysOutfit); - const res = await pool.query( - 'INSERT INTO publication (user_id, description, outfit_id) VALUES ($1, $2, $3) RETURNING *', - [userId, filterText(description), outfitId] - ); - if (res.rows.length === 0) { + const rows = await sql< + PublicationTable[] + >`INSERT INTO publication (user_id, description, outfit_id) VALUES (${userId}, ${filterText(description)}, ${outfitId}) RETURNING *`; + if (rows.length === 0) { throw new Error('Failed to create publication'); } - const post = await this.getPublicationById(res.rows[0].id); + const post = await this.getPublicationById(rows[0].id); if (!post) { throw new Error('Failed to retrieve created publication'); } for (const imageBuffer of imagesBuffers.slice(0, PublicationImagesLengths.max)) { - const res = await pool.query( - 'INSERT INTO publication_image (publication_id) VALUES ($1) RETURNING *', - [post.id] - ); - const row = res.rows[0]; + const insertRows = + await sql`INSERT INTO publication_image (publication_id) VALUES (${post.id}) RETURNING *`; + const row = insertRows[0]; await this.writePostImage(row.id, imageBuffer); } @@ -99,13 +96,13 @@ export class PublicationDAO { id: Publication['id'], userId?: User['id'] ): Promise { - const res = await pool.query('SELECT * FROM publication WHERE id = $1', [id]); + const rows = await sql`SELECT * FROM publication WHERE id = ${id}`; - if (res.rows.length === 0) { + if (rows.length === 0) { return null; } - const item = res.rows[0]; + const item = rows[0]; const user = await UserDAO.getUserById(item.user_id); const outfit = (await OutfitDAO.getOutfitById(item.outfit_id as UUID)) ?? undefined; const reactions = await ReactionDAO.getReactionsForPost(item.id); @@ -129,16 +126,15 @@ export class PublicationDAO { limit: number, offset: number ): Promise { - const res = await pool.query( - 'SELECT * FROM publication WHERE user_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3', - [userId, limit, offset] - ); + const rows = await sql< + PublicationTable[] + >`SELECT * FROM publication WHERE user_id = ${userId} ORDER BY created_at DESC LIMIT ${limit} OFFSET ${offset}`; - if (res.rows.length === 0) { + if (rows.length === 0) { return []; } - return await PublicationDAO.publicationTableToPublicationArray(res.rows); + return await PublicationDAO.publicationTableToPublicationArray(rows); } static async getFollowedFeed( @@ -160,7 +156,7 @@ export class PublicationDAO { params.push(limit); const limitPlaceholder = `$${params.length}`; - const res = await pool.query( + const rows = await sql.unsafe( `SELECT p.* FROM publication p JOIN followers f ON f.following_id = p.user_id @@ -171,7 +167,7 @@ export class PublicationDAO { params ); - return await PublicationDAO.publicationTableToPublicationArray(res.rows, userId); + return await PublicationDAO.publicationTableToPublicationArray(rows, userId); } // The algorithm @@ -260,7 +256,7 @@ export class PublicationDAO { const nbDaysPlaceholder = `$${params.length - 1}`; const limitPlaceholder = `$${params.length}`; - const res = await pool.query( + const rows = await sql.unsafe( `SELECT p.* FROM publication p LEFT JOIN reaction r ON p.id = r.post_id AND r.created_at >= NOW() - ${nbDaysPlaceholder}::interval @@ -271,7 +267,7 @@ export class PublicationDAO { params ); - return await PublicationDAO.publicationTableToPublicationArray(res.rows); + return await PublicationDAO.publicationTableToPublicationArray(rows); } static async deletePublication(postId: UUID): Promise { @@ -283,18 +279,17 @@ export class PublicationDAO { } } - await pool.query('DELETE FROM publication WHERE id = $1', [postId]); + await sql`DELETE FROM publication WHERE id = ${postId}`; } static async getOwner(publicationId: UUID): Promise { - const res = await pool.query<{ user_id: UUID }>( - 'SELECT user_id FROM publication WHERE id = $1', - [publicationId] - ); - if (res.rows.length === 0) { + const rows = await sql< + { user_id: UUID }[] + >`SELECT user_id FROM publication WHERE id = ${publicationId}`; + if (rows.length === 0) { return null; } - return res.rows[0].user_id; + return rows[0].user_id; } static async updatePublication( @@ -307,10 +302,7 @@ export class PublicationDAO { if (!existingPublication) { throw new Error('Publication not found'); } - await pool.query('UPDATE publication SET description = $1 WHERE id = $2', [ - filterText(publication.description ?? existingPublication.description), - id, - ]); + await sql`UPDATE publication SET description = ${filterText(publication.description ?? existingPublication.description)} WHERE id = ${id}`; } static async writePostImage(id: UUID, imageBuffer: Buffer): Promise { @@ -319,19 +311,17 @@ export class PublicationDAO { } static async hasUserPostedToday(userId: UUID): Promise { - const res = await pool.query( - 'SELECT * FROM publication WHERE user_id = $1 ORDER BY created_at DESC LIMIT 1', - [userId] - ); - const hasPosted = res.rows.length > 0 && DateUtils.isToday(res.rows[0].created_at); + const rows = await sql< + PublicationTable[] + >`SELECT * FROM publication WHERE user_id = ${userId} ORDER BY created_at DESC LIMIT 1`; + const hasPosted = rows.length > 0 && DateUtils.isToday(rows[0].created_at); return hasPosted; } static async getPostImages(publicationId: UUID): Promise { - const res = await pool.query( - 'SELECT * FROM publication_image WHERE publication_id = $1', - [publicationId] - ); - return res.rows.map((row) => row.id); + const rows = await sql< + PublicationImageTable[] + >`SELECT * FROM publication_image WHERE publication_id = ${publicationId}`; + return rows.map((row) => row.id); } } diff --git a/src/lib/server/db/reaction.ts b/src/lib/server/db/reaction.ts index 11cc81c..586ff88 100755 --- a/src/lib/server/db/reaction.ts +++ b/src/lib/server/db/reaction.ts @@ -1,5 +1,5 @@ import type { PostReactions, Reactions, UUID } from '$lib/types'; -import pool from '.'; +import { sql } from 'bun'; // import { Caching } from './caching'; export interface ReactionTable { @@ -17,23 +17,17 @@ export class ReactionDAO { await this.removeReaction(postId, userId); return null; } - await pool.query( - `INSERT INTO reaction (post_id, user_id, type) - VALUES ($1, $2, $3) + await sql`INSERT INTO reaction (post_id, user_id, type) + VALUES (${postId}, ${userId}, ${type}) ON CONFLICT (post_id, user_id) - DO UPDATE SET type = EXCLUDED.type, created_at = NOW()`, - [postId, userId, type] - ); + DO UPDATE SET type = EXCLUDED.type, created_at = NOW()`; return type; } static async removeReaction(postId: UUID, userId: UUID): Promise { // await Caching.del(`reaction:post:${postId}`); - await pool.query( - `DELETE FROM reaction - WHERE post_id = $1 AND user_id = $2`, - [postId, userId] - ); + await sql`DELETE FROM reaction + WHERE post_id = ${postId} AND user_id = ${userId}`; } static async getReactionsForPost(postId: UUID): Promise { @@ -41,11 +35,8 @@ export class ReactionDAO { // if (cached) { // return cached; // } - const res = await pool.query( - `SELECT * FROM reaction - WHERE post_id = $1`, - [postId] - ); + const rows = await sql`SELECT * FROM reaction + WHERE post_id = ${postId}`; const reactionsCount: PostReactions = { like: 0, love: 0, @@ -54,7 +45,7 @@ export class ReactionDAO { sad: 0, }; - for (const row of res.rows) { + for (const row of rows) { reactionsCount[row.type]++; } // await Caching.set(`reaction:post:${postId}`, reactionsCount); @@ -67,15 +58,12 @@ export class ReactionDAO { // if (cached) { // return cached; // } - const res = await pool.query( - `SELECT * FROM reaction - WHERE post_id = $1 AND user_id = $2`, - [postId, userId] - ); - if (res.rows.length === 0) { + const rows = await sql`SELECT * FROM reaction + WHERE post_id = ${postId} AND user_id = ${userId}`; + if (rows.length === 0) { return null; } - const reaction = res.rows[0].type; + const reaction = rows[0].type; // await Caching.set(`reaction:post:${postId}:user:${userId}`, reaction); return reaction; } diff --git a/src/lib/server/db/social.ts b/src/lib/server/db/social.ts index ce4aa6c..15b9bf7 100755 --- a/src/lib/server/db/social.ts +++ b/src/lib/server/db/social.ts @@ -1,18 +1,14 @@ -import type { User } from '$lib/types'; -import pool from '.'; +import type { User, UUID } from '$lib/types'; +import { sql } from 'bun'; import { UserDAO } from './user'; export class SocialDAO { static async getFollowingUsers(userId: User['id']): Promise { - const result = await pool.query( - `SELECT u.id FROM followers f + const rows = await sql`SELECT u.id FROM followers f JOIN users u ON f.following_id = u.id - WHERE f.follower_id = $1`, - [userId] - ); - + WHERE f.follower_id = ${userId}`; const users: User[] = []; - for (const userId of result.rows) { + for (const userId of rows) { const user = await UserDAO.getUserById(userId); delete user.passwordHash; users.push(user); @@ -21,48 +17,33 @@ export class SocialDAO { } static async getNbFollowers(userId: User['id']): Promise { - const result = await pool.query( - `SELECT COUNT(*) AS nb_followers FROM followers - WHERE following_id = $1`, - [userId] - ); - return parseInt(result.rows[0].nb_followers, 10); + const rows = await sql`SELECT COUNT(*) AS nb_followers FROM followers + WHERE following_id = ${userId}`; + return parseInt(rows[0].nb_followers, 10); } static async followUser(followerId: User['id'], followingId: User['id']): Promise { - await pool.query( - `INSERT INTO followers (follower_id, following_id) - VALUES ($1, $2) - ON CONFLICT (follower_id, following_id) DO NOTHING`, - [followerId, followingId] - ); + await sql`INSERT INTO followers (follower_id, following_id) + VALUES (${followerId}, ${followingId}) + ON CONFLICT (follower_id, following_id) DO NOTHING`; } static async unfollowUser(followerId: User['id'], followingId: User['id']): Promise { - await pool.query( - `DELETE FROM followers - WHERE follower_id = $1 AND following_id = $2`, - [followerId, followingId] - ); + await sql`DELETE FROM followers + WHERE follower_id = ${followerId} AND following_id = ${followingId}`; } static async getFollowingIds(userId: User['id']): Promise { - const result = await pool.query( - `SELECT following_id FROM followers - WHERE follower_id = $1`, - [userId] - ); - return result.rows.map((row) => row.following_id); + const rows = await sql<{ following_id: UUID }[]>`SELECT following_id FROM followers + WHERE follower_id = ${userId}`; + return rows.map((row) => row.following_id); } static async searchUsers(query: string, limit: number = 6): Promise { - const result = await pool.query( - `SELECT id, username, email FROM users - WHERE username ILIKE $1 - LIMIT $2`, - [`%${query}%`, limit] - ); + const rows = await sql`SELECT id, username, email FROM users + WHERE username ILIKE ${'%' + query + '%'} + LIMIT ${limit}`; - return result.rows; + return rows; } } diff --git a/src/lib/server/db/user.ts b/src/lib/server/db/user.ts index c10edab..7643cc3 100755 --- a/src/lib/server/db/user.ts +++ b/src/lib/server/db/user.ts @@ -1,5 +1,5 @@ import type { Passkey, User, UUID } from '$lib/types'; -import pool from '.'; +import { sql } from 'bun'; import { Caching } from './caching'; import { PasskeyDAO } from './passkey'; import { SocialDAO } from './social'; @@ -39,21 +39,20 @@ export class UserDAO { if (await UserDAO.userExists(username)) { throw new Error('errors.auth.usernameTaken'); } - const result = await pool.query( - 'INSERT INTO users (username, email, password_hash) VALUES ($1, $2, $3) RETURNING *', - [username, email, passwordHash] - ); - if (result.rows.length === 0) { + const rows = await sql< + UserTable[] + >`INSERT INTO users (username, email, password_hash) VALUES (${username}, ${email}, ${passwordHash}) RETURNING *`; + if (rows.length === 0) { throw new Error('errors.auth.createUser'); } - return UserDAO.convertToUser(result.rows[0]); + return UserDAO.convertToUser(rows[0]); } static async userExists(username: User['username']): Promise { const cachedValue = await Caching.get(`userExists:${username}`); if (cachedValue) return cachedValue; - const result = await pool.query('SELECT 1 FROM users WHERE username = $1', [username]); - const exists = result.rows.length > 0; + const rows = await sql`SELECT 1 FROM users WHERE username = ${username}`; + const exists = rows.length > 0; await Caching.set(`userExists:${username}`, exists); return exists; } @@ -61,8 +60,8 @@ export class UserDAO { static async isEmailTaken(email: User['email']) { const cachedValue = await Caching.get(`emailTaken:${email}`); if (cachedValue) return cachedValue; - const result = await pool.query('SELECT 1 FROM users WHERE email = $1', [email]); - const taken = result.rows.length > 0; + const rows = await sql`SELECT 1 FROM users WHERE email = ${email}`; + const taken = rows.length > 0; await Caching.set(`emailTaken:${email}`, taken); return taken; } @@ -71,13 +70,13 @@ export class UserDAO { const cachedUser = await Caching.get(`user:${id}`); if (cachedUser) return cachedUser; - const userResult = await pool.query('SELECT * FROM users WHERE id = $1', [id]); - if (userResult.rows.length === 0) { + const rows = await sql`SELECT * FROM users WHERE id = ${id}`; + if (rows.length === 0) { throw new Error('errors.auth.userNotFound'); } const user = UserDAO.convertToUser( - userResult.rows[0], - await PasskeyDAO.getUserPasskey(userResult.rows[0].id), + rows[0], + await PasskeyDAO.getUserPasskey(rows[0].id), await SocialDAO.getFollowingIds(id) ); await Caching.set(`user:${user.id}`, user); @@ -85,40 +84,37 @@ export class UserDAO { } static async getUserByUsername(username: User['username']): Promise { - const userResult = await pool.query('SELECT * FROM users WHERE username = $1', [ - username, - ]); - if (userResult.rows.length === 0) { + const rows = await sql`SELECT * FROM users WHERE username = ${username}`; + if (rows.length === 0) { return null; } const user = UserDAO.convertToUser( - userResult.rows[0], - await PasskeyDAO.getUserPasskey(userResult.rows[0].id), - await SocialDAO.getFollowingIds(userResult.rows[0].id) + rows[0], + await PasskeyDAO.getUserPasskey(rows[0].id), + await SocialDAO.getFollowingIds(rows[0].id) ); return user; } static async getUserByEmail(email: User['email']): Promise { - const userResult = await pool.query('SELECT * FROM users WHERE email = $1', [email]); - if (userResult.rows.length === 0) { + const rows = await sql`SELECT * FROM users WHERE email = ${email}`; + if (rows.length === 0) { throw new Error('errors.auth.userNotFound'); } - const user = userResult.rows[0]; + const user = rows[0]; return UserDAO.convertToUser( - userResult.rows[0], + rows[0], await PasskeyDAO.getUserPasskey(user.id), await SocialDAO.getFollowingIds(user.id) ); } static async credentialsExists(username: User['username'], email: User['email']) { - const result = await pool.query<{ username: string; email: string }>( - 'SELECT username, email FROM users WHERE username = $1 OR email = $2', - [username, email] - ); - if (result.rows.length !== 0) { - const row = result.rows[0]; + const rows = await sql< + { username: string; email: string }[] + >`SELECT username, email FROM users WHERE username = ${username} OR email = ${email}`; + if (rows.length !== 0) { + const row = rows[0]; const isUsernameTaken = row.username === username; if (isUsernameTaken) throw new Error('errors.auth.usernameTaken'); throw new Error('errors.auth.emailInUse'); @@ -128,19 +124,16 @@ export class UserDAO { } static async setTOTPSecret(userId: User['id'], secret: string): Promise { - const result = await pool.query('UPDATE users SET totp_secret = $1 WHERE id = $2', [ - secret, - userId, - ]); - if (result.rowCount === 0) { + const result = await sql`UPDATE users SET totp_secret = ${secret} WHERE id = ${userId};`; + if (result.affectedRows === 0) { throw new Error('errors.auth.setTOTPSecret'); } await Caching.del(`user:${userId}`); } static async unlinkTOTP(userId: User['id']): Promise { - const result = await pool.query('UPDATE users SET totp_secret = NULL WHERE id = $1', [userId]); - if (result.rowCount === 0) { + const result = await sql`UPDATE users SET totp_secret = NULL WHERE id = ${userId}`; + if (result.affectedRows === 0) { throw new Error('errors.auth.unlinkTOTP'); } await Caching.del(`user:${userId}`); @@ -179,11 +172,11 @@ export class UserDAO { const values = Object.values(updates); const setString = mappedFields.map((field, index) => `${field} = $${index + 1}`).join(', '); - const result = await pool.query( + const result = await sql.unsafe( `UPDATE users SET ${setString} WHERE id = $${fields.length + 1}`, [...values, id] ); - if (result.rowCount === 0) { + if (result.affectedRows === 0) { throw new Error('errors.auth.updateUser'); } await Caching.del(`user:${id}`); diff --git a/src/lib/server/db/wrapped.ts b/src/lib/server/db/wrapped.ts index b13931d..9b6b918 100644 --- a/src/lib/server/db/wrapped.ts +++ b/src/lib/server/db/wrapped.ts @@ -1,5 +1,5 @@ import type { ClothingItem, Publication, User } from '$lib/types'; -import pool from '.'; +import { sql } from 'bun'; import { ClothingItemDAO, type ClothingItemTable } from './clothingItem'; import { PublicationDAO } from './publication'; @@ -15,8 +15,7 @@ export class WrappedDAO { static async getWrapped(userId: User['id']): Promise { const getMostWorn = async () => { - const q = await pool.query( - ` + const rows = await sql` SELECT ci.*, COUNT(oci.clothing_item_id) AS appearance_count @@ -27,23 +26,20 @@ export class WrappedDAO { JOIN outfit o ON oci.outfit_id = o.id WHERE - o.user_id = $1 + o.user_id = ${userId} AND EXTRACT(YEAR FROM o.created_at) = EXTRACT(YEAR FROM CURRENT_DATE) GROUP BY ci.id ORDER BY appearance_count DESC LIMIT 5; - `, - [userId] - ); - const mostWorn: ClothingItem[] = q.rows.map((i) => ClothingItemDAO.convertToClothingItem(i)); + `; + const mostWorn: ClothingItem[] = rows.map((i) => ClothingItemDAO.convertToClothingItem(i)); return mostWorn; }; const getMostLikedPost = async () => { - const q = await pool.query<{ post_id: Publication['id']; reaction_count: number }>( - `SELECT + const rows = await sql<{ post_id: Publication['id']; reaction_count: number }[]>`SELECT p.id AS post_id, COUNT(r) AS reaction_count FROM @@ -51,19 +47,17 @@ export class WrappedDAO { LEFT JOIN reaction r ON p.id = r.post_id WHERE - p.user_id = $1 + p.user_id = ${userId} AND EXTRACT(YEAR FROM p.created_at) = EXTRACT(YEAR FROM CURRENT_DATE) GROUP BY p.id ORDER BY reaction_count DESC - LIMIT 1;`, - [userId] - ); - if (q.rows.length === 0) { + LIMIT 1;`; + if (rows.length === 0) { return null; } - const post = PublicationDAO.getPublicationById(q.rows[0].post_id); + const post = PublicationDAO.getPublicationById(rows[0].post_id); return post; }; diff --git a/src/lib/types.ts b/src/lib/types.ts index 3cc721b..a8b4412 100755 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -99,7 +99,7 @@ export const OutfitZ = z.object({ createdAt: DateZ, }); export type Outfit = z.infer; -export const OutfitPreviewZ = OutfitZ.omit({ createdAt: true, id: true }); +export const OutfitPreviewZ = OutfitZ.omit({ id: true, createdAt: true }); export type OutfitPreview = z.infer; // Weather diff --git a/src/lib/utils/date.ts b/src/lib/utils/date.ts index d6969fe..826fb27 100644 --- a/src/lib/utils/date.ts +++ b/src/lib/utils/date.ts @@ -14,29 +14,47 @@ export class DateUtils { return this.isSameDay(date, new Date()); } + static isInFuture(date: Date): boolean { + const today = new Date(); + return ( + date.getFullYear() > today.getFullYear() || + (date.getFullYear() === today.getFullYear() && + (date.getMonth() > today.getMonth() || + (date.getMonth() === today.getMonth() && date.getDate() > today.getDate()))) + ); + } + static distance( date1: Date, date2: Date, unit: 'days' | 'hours' | 'minutes' | 'seconds' ): number { - const diff = Math.abs(date2.getTime() - date1.getTime()); + const diff = date2.getTime() - date1.getTime(); switch (unit) { case 'days': - return Math.floor(diff / (1000 * 60 * 60 * 24)); + return Math.ceil(diff / (1000 * 60 * 60 * 24)); case 'hours': - return Math.floor(diff / (1000 * 60 * 60)); + return Math.ceil(diff / (1000 * 60 * 60)); case 'minutes': - return Math.floor(diff / (1000 * 60)); + return Math.ceil(diff / (1000 * 60)); case 'seconds': - return Math.floor(diff / 1000); + return Math.ceil(diff / 1000); default: throw new Error('Invalid unit for distance calculation'); } } static formatDate = (date: Date, { allowDistance = true }: { allowDistance?: boolean } = {}) => { - if (allowDistance && DateUtils.distance(new Date(), date, 'days') < 6) { - return new DateFormatter(i18n.locale, { weekday: 'long' }).format(date); + if (allowDistance) { + const distanceInDays = DateUtils.distance(new Date(), date, 'days'); + if (distanceInDays === 0) { + return i18n.t('date.today'); + } + if (distanceInDays === 1) return i18n.t('date.tomorrow'); + if (distanceInDays === -1) return i18n.t('date.yesterday'); + + if (distanceInDays < 6) + return new DateFormatter(i18n.locale, { weekday: 'long' }).format(date); } return new DateFormatter(i18n.locale, { day: '2-digit', month: 'short' }).format(date); }; diff --git a/src/routes/api/social/comment/+server.ts b/src/routes/api/social/comment/+server.ts index 7701e5f..efca927 100644 --- a/src/routes/api/social/comment/+server.ts +++ b/src/routes/api/social/comment/+server.ts @@ -1,6 +1,6 @@ import z from 'zod'; import type { RequestHandler } from './$types'; -import { CommentZ, UUID } from '$lib/types'; +import { UUID } from '$lib/types'; import { CommentDAO } from '$lib/server/db/comment'; import { json } from '@sveltejs/kit'; diff --git a/src/routes/api/wardrobe/outfit/generate/+server.ts b/src/routes/api/wardrobe/outfit/generate/+server.ts index 6512d40..22a7bc5 100644 --- a/src/routes/api/wardrobe/outfit/generate/+server.ts +++ b/src/routes/api/wardrobe/outfit/generate/+server.ts @@ -17,12 +17,12 @@ export async function POST({ locals, request }) { const data = schema.safeParse(body); if (!data.success) throw new Error(data.error.issues.map((i) => i.path[0]).join(', ')); const { count, weather, style } = data.data; - logger.debug('Generating outfits', { userId: user.id, count, weather, style }); const outfits = await generateOutfits(user.id, weather, count, { style }); return json(outfits); } catch (error) { const msg = error instanceof Error ? error.message : String(error); + logger.error(`Error generating outfits for user ${user.id}: ${msg}`); return json({ error: msg }, { status: 500 }); } } diff --git a/src/routes/api/wardrobe/outfit/save/+server.ts b/src/routes/api/wardrobe/outfit/save/+server.ts index 1d8d974..7d4e223 100644 --- a/src/routes/api/wardrobe/outfit/save/+server.ts +++ b/src/routes/api/wardrobe/outfit/save/+server.ts @@ -1,10 +1,12 @@ import { json } from '@sveltejs/kit'; import z from 'zod'; -import { OutfitPreviewZ } from '$lib/types'; -import { OutfitDAO } from '$lib/server/db/outfit.js'; +import { OutfitPreviewZ, OutfitZ } from '$lib/types'; +import { OutfitDAO } from '$lib/server/db/outfit'; const schema = z.object({ - outfit: OutfitPreviewZ, + outfit: OutfitPreviewZ.extend({ + createdAt: OutfitZ.shape.createdAt.optional().default(new Date()), + }), }); export async function POST({ locals, request }) { @@ -14,7 +16,6 @@ export async function POST({ locals, request }) { const data = schema.safeParse(body); if (!data.success) throw new Error(data.error.issues.map((i) => i.message).join(', ')); const { outfit } = data.data; - const newOutfit = await OutfitDAO.createOutfit(user.id, outfit); return json({ success: true, id: newOutfit.id }); diff --git a/src/routes/app/+page.svelte b/src/routes/app/+page.svelte index 887b714..8d2793e 100755 --- a/src/routes/app/+page.svelte +++ b/src/routes/app/+page.svelte @@ -6,7 +6,7 @@ import { page } from '$app/state'; import * as Empty from '$lib/components/ui/empty'; import i18n from '$lib/i18n'; - import { ArrowRight, Shirt } from '@lucide/svelte'; + import { ArrowRight, PlusIcon, Shirt, Trash2, XIcon } from '@lucide/svelte'; import { Button } from '$lib/components/ui/button'; import * as Dialog from '$lib/components/ui/dialog'; import { logger } from '$lib/utils/logger'; @@ -14,19 +14,49 @@ import { invalidateAll } from '$app/navigation'; import { resolve } from '$app/paths'; import { OutfitCard } from '$lib/components/wardrobe'; + import { CalendarDate, getLocalTimeZone, today } from '@internationalized/date'; + import * as Carousel from '$lib/components/ui/carousel'; - let chosenOutfit = $derived( - (page.data.outfits as Outfit[]).find((o) => DateUtils.isToday(o.createdAt)) ?? null - ); + let outfits = $derived(page.data.outfits as Outfit[]); + + let todaySOutfit = $derived(outfits.find((o) => DateUtils.isToday(o.createdAt)) ?? null); let changeOutfitConfirmModal = $state({ open: false, loading: false }); + let provisionalOutfits = $derived( + outfits.filter((o) => DateUtils.isInFuture(o.createdAt)).reverse() + ); + let provisionalDate = $state(null); + let provisionalGrid = $derived.by<{ date: Date; outfit?: Outfit }[]>(() => { + // Creates a day by date [tomorrow, Min(furthest outfit, 1year)] array with outfits if they exist + const result: { date: Date; outfit?: Outfit }[] = []; + const todayDate = today(getLocalTimeZone()).toDate(getLocalTimeZone()); + const furthestOutfitDate = provisionalOutfits.reduce((max, outfit) => { + if (!max || outfit.createdAt > max) return outfit.createdAt; + + return max; + }, null); + const endDate = furthestOutfitDate + ? new Date( + Math.min(furthestOutfitDate.getTime(), todayDate.getTime() + 365 * 24 * 60 * 60 * 1000) + ) + : new Date(todayDate.getTime() + 365 * 24 * 60 * 60 * 1000); + for ( + let d = new Date(todayDate.getTime() + 24 * 60 * 60 * 1000); + d <= endDate; + d.setDate(d.getDate() + 1) + ) { + const outfit = provisionalOutfits.find((o) => DateUtils.isSameDay(o.createdAt, d)); + result.push({ date: new Date(d), outfit }); + } + return result; + }); - async function changeOutfit() { + async function changeOutfit(outfitId: Outfit['id']) { changeOutfitConfirmModal.loading = true; try { const res = await fetch('/api/wardrobe/outfit/delete', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ outfitId: chosenOutfit!.id }), + body: JSON.stringify({ outfitId }), }); const data = await res.json(); if (!res.ok) { @@ -63,22 +93,31 @@ disabled={changeOutfitConfirmModal.loading} >{i18n.t('wardrobe.outfitGeneration.choosen.change.cancel')} - + {#if todaySOutfit} + + {/if} -{#if !chosenOutfit} - +{#if !todaySOutfit || provisionalDate} + { + provisionalDate = null; + }} + /> {:else}
@@ -98,12 +137,79 @@ -
- -
+
{/if} + +{#if !provisionalDate} +
+
+

Upcoming Outfits

+ +
+ {#if provisionalOutfits.length > 0} + + + {#each provisionalGrid as { outfit, date } (date.toISOString())} + + {#if outfit} +
+ + +
+ {:else} +
+
+
+

+ No outfit planned for {DateUtils.formatDate(date)}. +

+ +
+
+ {/if} +
+ {/each} +
+
+ {:else} +

You do not have any provisional outfits. Plan them now!

+ + {/if} +
+{/if} diff --git a/src/routes/app/MixAndMatch.svelte b/src/routes/app/MixAndMatch.svelte index 4214952..73b369f 100755 --- a/src/routes/app/MixAndMatch.svelte +++ b/src/routes/app/MixAndMatch.svelte @@ -6,15 +6,17 @@ import i18n from '$lib/i18n'; import { type ClothingItem, clothingItemTypes, type SwiperCard } from '$lib/types'; import { hashStringToNumber } from '$lib/utils'; + import { getLocalTimeZone, type CalendarDate } from '@internationalized/date'; import { ChevronRight } from '@lucide/svelte'; import { onDestroy, onMount } from 'svelte'; import { SvelteSet } from 'svelte/reactivity'; interface Props { onSwiped: (card: SwiperCard, accepted: boolean) => void; + provisionalDate: CalendarDate | null; } - let { onSwiped }: Props = $props(); + let { onSwiped, provisionalDate }: Props = $props(); let items = $state(page.data.items.sort(sortByType)); let selectedItems = $state(new SvelteSet()); @@ -36,6 +38,7 @@ id: 1000, outfit: { items: items.filter((item) => selectedItems.has(item.id)), + createdAt: provisionalDate?.toDate(getLocalTimeZone()) ?? new Date(), }, }; onSwiped(card, true); diff --git a/src/routes/app/Swiper.svelte b/src/routes/app/Swiper.svelte index 1abd6d9..3ba950f 100755 --- a/src/routes/app/Swiper.svelte +++ b/src/routes/app/Swiper.svelte @@ -3,7 +3,13 @@ import * as Dialog from '$lib/components/ui/dialog'; import { Button } from '$lib/components/ui/button'; import { Toaster } from '$lib/components/Toast/toast'; - import { CLOTHING_STYLES, OutfitPreviewZ, type SwiperCard, type Weather } from '$lib/types'; + import { + CLOTHING_STYLES, + OutfitPreviewZ, + type Outfit, + type SwiperCard, + type Weather, + } from '$lib/types'; import { ArrowRight, CloudRainWind, @@ -17,7 +23,7 @@ import confetti from 'canvas-confetti'; import z from 'zod'; import i18n from '$lib/i18n'; - import { cn, hashStringToNumber, getWeather, logger } from '$lib/utils'; + import { cn, hashStringToNumber, getWeather, logger, DateUtils } from '$lib/utils'; import { fade } from 'svelte/transition'; import { onDestroy, onMount } from 'svelte'; import { invalidateAll } from '$app/navigation'; @@ -25,6 +31,19 @@ import Globals from '$lib/globals.svelte'; import { FormalIcon } from '$lib/components/domainIcons'; import Comfort from '$lib/components/domainIcons/Comfort.svelte'; + import Calendar from '$lib/components/ui/calendar/calendar.svelte'; + import * as Popover from '$lib/components/ui/popover'; + import { Label } from '$lib/components/ui/label'; + import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'; + import { CalendarDate, getLocalTimeZone, today } from '@internationalized/date'; + import { page } from '$app/state'; + + interface Props { + provisionalDate: CalendarDate | null; + onSelected?: (outfit: SwiperCard, date: Date) => void; + } + + let { onSelected, provisionalDate = $bindable(null) }: Props = $props(); const cId = $props.id(); // Deterministic client ID for outfit generation @@ -72,10 +91,12 @@ } let chosenOutfit = $state(null); + let outfits = $derived(page.data.outfits as Outfit[]); let outfitId: number = $state(hashStringToNumber(cId)); // Start from a hash of the client ID let wether = $state(null); let cards = $state([]); let loadingCards = $state(false); + let datePopoverOpen = $state(false); let acceptedCard = $state<{ card: SwiperCard | null; open: boolean }>({ card: null, open: false, @@ -83,7 +104,7 @@ let multistageQuestions = $derived({ weather: { options: Object.keys(weatherPresets), - hint: wether ? getClosestWeatherPreset(wether) : null, + hint: wether && !provisionalDate ? getClosestWeatherPreset(wether) : null, icons: { sunny: Sun, rainy: CloudRainWind, @@ -133,7 +154,12 @@ try { const res = await fetch('/api/wardrobe/outfit/save', { method: 'POST', - body: JSON.stringify({ outfit: acceptedCard.card?.outfit }), + body: JSON.stringify({ + outfit: { + ...acceptedCard.card?.outfit, + createdAt: provisionalDate?.toDate(getLocalTimeZone()) || new Date(), + }, + }), }); const data = await res.json(); if (!res.ok) { @@ -174,8 +200,9 @@ spread: 120, startVelocity: 45, }); - acceptedCard = { open: false, card: null }; await invalidateAll(); + onSelected?.(acceptedCard.card!, provisionalDate?.toDate(getLocalTimeZone()) || new Date()); + acceptedCard = { open: false, card: null }; } catch (error) { const msg = error instanceof Error ? error.message : String(error); logger.error('Error saving outfit:', msg); @@ -225,7 +252,7 @@ } async function initialWeatherFetch() { - if (wether) return; + if (wether || provisionalDate) return; try { wether = await getWeather(); } catch (error) { @@ -234,7 +261,7 @@ } onMount(() => { - initialWeatherFetch(); + if (!provisionalDate) initialWeatherFetch(); }); $effect(() => { @@ -259,16 +286,59 @@ {i18n.t('wardrobe.outfitGeneration.chooseOutfitModal.title')} {i18n.t('wardrobe.outfitGeneration.chooseOutfitModal.description')}{i18n.t('wardrobe.outfitGeneration.chooseOutfitModal.description', { + date: DateUtils.formatDate(provisionalDate?.toDate(getLocalTimeZone()) ?? new Date(), { + allowDistance: true, + }), + })} + {#if provisionalDate} +
+ + + + {#snippet child({ props })} + + {/snippet} + + + { + return outfits.some((outfit) => + DateUtils.isSameDay(outfit.createdAt, date.toDate(getLocalTimeZone())) + ); + }} + onValueChange={() => { + datePopoverOpen = false; + }} + minValue={today(getLocalTimeZone())} + /> + + +
+ {/if} + { + if (provisionalDate) { + provisionalDate = null; + } + acceptedCard = { open: false, card: null }; + }}>{i18n.t('wardrobe.outfitGeneration.chooseOutfitModal.cancelButton')}