diff --git a/backend/package-lock.json b/backend/package-lock.json index 457f536..17b70d3 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -53,6 +53,7 @@ "@types/passport": "^1.0.16", "@types/passport-apple": "^2.0.0", "@types/passport-google-oauth20": "^2.0.12", + "@types/pg": "^8.16.0", "@types/uuid": "^9.0.7", "@typescript-eslint/eslint-plugin": "^6.17.0", "@typescript-eslint/parser": "^6.17.0", @@ -508,6 +509,18 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -906,6 +919,18 @@ "@types/passport": "*" } }, + "node_modules/@types/pg": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.16.0.tgz", + "integrity": "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", @@ -1657,12 +1682,12 @@ } }, "node_modules/chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "license": "ISC", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", "engines": { - "node": ">=10" + "node": ">=18" } }, "node_modules/cluster-key-slot": { @@ -2004,9 +2029,9 @@ } }, "node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", + "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" @@ -2737,30 +2762,6 @@ "node": ">= 0.6" } }, - "node_modules/fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/fs-minipass/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -3868,37 +3869,24 @@ } }, "node_modules/minipass": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", - "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", "license": "ISC", "engines": { - "node": ">=8" + "node": ">=16 || 14 >=14.17" } }, "node_modules/minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", "license": "MIT", "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" + "minipass": "^7.1.2" }, "engines": { - "node": ">= 8" - } - }, - "node_modules/minizlib/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "license": "ISC", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" + "node": ">= 18" } }, "node_modules/mkdirp": { @@ -4400,7 +4388,7 @@ "integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ==", "license": "MIT" }, - "node_modules/pg/node_modules/pg-types": { + "node_modules/pg-types": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", @@ -4416,7 +4404,37 @@ "node": ">=4" } }, - "node_modules/pg/node_modules/postgres-array": { + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", @@ -4425,7 +4443,7 @@ "node": ">=4" } }, - "node_modules/pg/node_modules/postgres-bytea": { + "node_modules/postgres-bytea": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", @@ -4434,7 +4452,7 @@ "node": ">=0.10.0" } }, - "node_modules/pg/node_modules/postgres-date": { + "node_modules/postgres-date": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", @@ -4443,7 +4461,7 @@ "node": ">=0.10.0" } }, - "node_modules/pg/node_modules/postgres-interval": { + "node_modules/postgres-interval": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", @@ -4455,36 +4473,6 @@ "node": ">=0.10.0" } }, - "node_modules/pgpass": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", - "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", - "license": "MIT", - "dependencies": { - "split2": "^4.1.0" - } - }, - "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/possible-typed-array-names": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", - "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -5154,20 +5142,28 @@ } }, "node_modules/tar": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", - "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", - "license": "ISC", + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.3.tgz", + "integrity": "sha512-ENg5JUHUm2rDD7IvKNFGzyElLXNjachNLp6RaGf4+JOgxXHkqA+gq81ZAMCUmtMtqBsoU62lcp6S27g1LCYGGQ==", + "license": "BlueOak-1.0.0", "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" }, "engines": { - "node": ">=10" + "node": ">=18" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=18" } }, "node_modules/tarn": { diff --git a/backend/package.json b/backend/package.json index 0d297af..3e424d5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -65,10 +65,15 @@ "@types/passport": "^1.0.16", "@types/passport-apple": "^2.0.0", "@types/passport-google-oauth20": "^2.0.12", + "@types/pg": "^8.16.0", "@types/uuid": "^9.0.7", "@typescript-eslint/eslint-plugin": "^6.17.0", "@typescript-eslint/parser": "^6.17.0", "eslint": "^8.56.0", "prettier": "^3.1.1" + }, + "overrides": { + "tar": "^7.5.3", + "diff": "^8.0.3" } } diff --git a/backend/src/database/connection.ts b/backend/src/database/connection.ts index a5a2eb1..ea3654c 100644 --- a/backend/src/database/connection.ts +++ b/backend/src/database/connection.ts @@ -42,7 +42,7 @@ export const getDatabasePool = (): Pool => { } }); - pool.on('connect', (_client) => { + pool.on('connect', (_client: any) => { logger.debug('Database connection established', { totalCount: pool?.totalCount, idleCount: pool?.idleCount, diff --git a/backend/src/index.ts b/backend/src/index.ts index a7d38f4..255b9db 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -17,8 +17,22 @@ app.set('trust proxy', 1); app.use(helmet()); app.use(securityMiddleware); +const getAllowedOrigins = (): string[] | boolean => { + const corsOrigin = process.env.CORS_ORIGIN; + + if (!corsOrigin || corsOrigin === '*') { + if (process.env.NODE_ENV === 'production') { + logger.warn('CORS_ORIGIN is not set or set to * in production. This is insecure.'); + return false; + } + return true; + } + + return corsOrigin.split(',').map(origin => origin.trim()).filter(Boolean); +}; + const corsOptions = { - origin: process.env.CORS_ORIGIN === '*' ? true : (process.env.CORS_ORIGIN || '*'), + origin: getAllowedOrigins(), credentials: true, methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization', 'X-Device-ID', 'X-Signature-Ed25519', 'X-Signature-Timestamp'] diff --git a/backend/src/routes/music.ts b/backend/src/routes/music.ts index 72309a1..a9463ad 100644 --- a/backend/src/routes/music.ts +++ b/backend/src/routes/music.ts @@ -48,7 +48,7 @@ router.get( const result = await pool.query(query, queryParams); - const items = await Promise.all(result.rows.map(async (row) => { + const items = await Promise.all(result.rows.map(async (row: any) => { let trendingChange: number | null = null; if (parseInt(row.recent_ratings) > 10) { const previousPeriodResult = await pool.query( @@ -175,7 +175,7 @@ router.get( [`%${query}%`] ); - const items = result.rows.map(row => ({ + const items = result.rows.map((row: any) => ({ id: row.id, type: row.type, title: row.title, diff --git a/backend/src/routes/notifications.ts b/backend/src/routes/notifications.ts index e04c5eb..3184141 100644 --- a/backend/src/routes/notifications.ts +++ b/backend/src/routes/notifications.ts @@ -27,7 +27,7 @@ router.get( [req.userId, limit, offset] ); - const notifications = result.rows.map(row => ({ + const notifications = result.rows.map((row: any) => ({ id: row.id, userId: row.user_id, type: row.type, diff --git a/backend/src/routes/rankings.ts b/backend/src/routes/rankings.ts index f4a942a..f70196e 100644 --- a/backend/src/routes/rankings.ts +++ b/backend/src/routes/rankings.ts @@ -70,9 +70,9 @@ router.get( ); const newItemIds = new Set(newItemsResult.rows.map((r: any) => r.id)); - const rankings = result.rows.map((row, index) => { + const rankings = result.rows.map((row: any, index: number) => { const currentRank = index + 1; - const previousRank = previousRankMap.get(row.id); + const previousRank: number | undefined = previousRankMap.get(row.id); const change = previousRank ? previousRank - currentRank : 0; const isNew = newItemIds.has(row.id); @@ -164,9 +164,9 @@ router.get( ); const newItemIds = new Set(newItemsResult.rows.map((r: any) => r.id)); - const rankings = result.rows.map((row, index) => { + const rankings = result.rows.map((row: any, index: number) => { const currentRank = index + 1; - const previousRank = previousRankMap.get(row.id); + const previousRank: number | undefined = previousRankMap.get(row.id); const change = previousRank ? previousRank - currentRank : 0; const isNew = newItemIds.has(row.id); @@ -258,9 +258,9 @@ router.get( ); const newItemIds = new Set(newItemsResult.rows.map((r: any) => r.id)); - const rankings = result.rows.map((row, index) => { + const rankings = result.rows.map((row: any, index: number) => { const currentRank = index + 1; - const previousRank = previousRankMap.get(row.id); + const previousRank: number | undefined = previousRankMap.get(row.id); const change = previousRank ? previousRank - currentRank : 0; const isNew = newItemIds.has(row.id); diff --git a/backend/src/routes/ratings.ts b/backend/src/routes/ratings.ts index a732349..8923375 100644 --- a/backend/src/routes/ratings.ts +++ b/backend/src/routes/ratings.ts @@ -113,7 +113,7 @@ router.get( [musicItemId, limit, offset] ); - const ratings = result.rows.map(row => ({ + const ratings = result.rows.map((row: any) => ({ id: row.id, userId: row.user_id, musicItemId: row.music_item_id, @@ -162,7 +162,7 @@ router.get( [userId, limit, offset] ); - const ratings = result.rows.map(row => ({ + const ratings = result.rows.map((row: any) => ({ id: row.id, userId: row.user_id, musicItemId: row.music_item_id, diff --git a/backend/src/routes/social.ts b/backend/src/routes/social.ts index 8fe51e3..85181be 100644 --- a/backend/src/routes/social.ts +++ b/backend/src/routes/social.ts @@ -31,7 +31,7 @@ router.get( [req.userId] ); - const friends = await Promise.all(result.rows.map(async (row) => { + const friends = await Promise.all(result.rows.map(async (row: any) => { const compatibilityResult = await pool.query( `SELECT COUNT(*) as shared_ratings, diff --git a/backend/src/routes/webhooks.ts b/backend/src/routes/webhooks.ts index adb6bb6..532c55d 100644 --- a/backend/src/routes/webhooks.ts +++ b/backend/src/routes/webhooks.ts @@ -37,14 +37,35 @@ const RESPONSE_TYPE = { DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE: 5 }; +const isValidDiscordId = (id: string): boolean => { + return /^\d{17,19}$/.test(id); +}; + +const isValidDiscordToken = (token: string): boolean => { + return /^[a-zA-Z0-9._-]+$/.test(token) && token.length >= 50 && token.length <= 200; +}; + const sendDiscordFollowup = async (applicationId: string, interactionToken: string, content: string) => { + if (!isValidDiscordId(applicationId) || !isValidDiscordToken(interactionToken)) { + logger.error('Invalid Discord application ID or interaction token', { + applicationId: applicationId?.substring(0, 10), + tokenLength: interactionToken?.length + }); + throw new Error('Invalid Discord credentials'); + } + try { await axios.post( `https://discord.com/api/v10/webhooks/${applicationId}/${interactionToken}`, - { content } + { content }, + { + timeout: 5000, + validateStatus: (status) => status < 500 + } ); } catch (error) { logger.error('Failed to send Discord follow-up', { error }); + throw error; } }; @@ -72,6 +93,14 @@ const handleDiscordInteraction = async (req: DiscordRequest, res: Response) => { return res.status(400).json({ error: 'Missing interaction metadata' }); } + if (!isValidDiscordId(applicationId) || !isValidDiscordToken(interactionToken)) { + logger.warn('Invalid Discord interaction credentials', { + applicationId: applicationId?.substring(0, 10), + tokenLength: interactionToken?.length + }); + return res.status(400).json({ error: 'Invalid interaction metadata' }); + } + console.log('Step 1: Sending Deferral to Discord...'); res.status(200).json({ type: RESPONSE_TYPE.DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE }); diff --git a/etl/package-lock.json b/etl/package-lock.json index b70829e..6e778c4 100644 --- a/etl/package-lock.json +++ b/etl/package-lock.json @@ -909,9 +909,9 @@ } }, "node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", + "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" diff --git a/etl/package.json b/etl/package.json index 7c3972d..123b0e1 100644 --- a/etl/package.json +++ b/etl/package.json @@ -36,6 +36,9 @@ "@typescript-eslint/parser": "^6.17.0", "eslint": "^8.56.0", "prettier": "^3.1.1" + }, + "overrides": { + "diff": "^8.0.3" } } diff --git a/frontend/MusicApp/Assets.xcassets/AccentColor.colorset/Contents.json b/frontend/MusicApp/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/frontend/MusicApp/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/frontend/MusicApp/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png b/frontend/MusicApp/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png new file mode 100644 index 0000000..e92cda4 Binary files /dev/null and b/frontend/MusicApp/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png differ diff --git a/frontend/MusicApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/frontend/MusicApp/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..aa1e903 --- /dev/null +++ b/frontend/MusicApp/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,38 @@ +{ + "images": [ + { + "filename": "AppIcon-1024.png", + "idiom": "universal", + "platform": "ios", + "size": "1024x1024" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "dark" + } + ], + "filename": "AppIcon-1024.png", + "idiom": "universal", + "platform": "ios", + "size": "1024x1024" + }, + { + "appearances": [ + { + "appearance": "luminosity", + "value": "tinted" + } + ], + "filename": "AppIcon-1024.png", + "idiom": "universal", + "platform": "ios", + "size": "1024x1024" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} \ No newline at end of file diff --git a/frontend/MusicApp/Assets.xcassets/Contents.json b/frontend/MusicApp/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/frontend/MusicApp/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/frontend/MusicApp/ContentView.swift b/frontend/MusicApp/ContentView.swift new file mode 100644 index 0000000..bfae2f7 --- /dev/null +++ b/frontend/MusicApp/ContentView.swift @@ -0,0 +1,107 @@ +import SwiftUI + +struct ContentView: View { + @StateObject private var appState = AppState.shared + @State private var showSplash = true + + var body: some View { + ZStack { + if showSplash { + SplashScreenView(onComplete: { + withAnimation { + showSplash = false + } + }) + .transition(.opacity) + } else { + Group { + switch appState.currentScreen { + case .splash: + SplashScreenView(onComplete: { + appState.completeOnboarding() + }) + case .onboarding: + OnboardingView(onComplete: { + appState.completeOnboarding() + }) + case .authentication: + AuthenticationView(appState: appState) + case .main: + MainAppView(appState: appState) + } + } + .transition(.opacity) + } + } + .animation(.easeInOut, value: appState.currentScreen) + .animation(.easeInOut, value: showSplash) + } +} + +struct AuthenticationView: View { + @ObservedObject var appState: AppState + @State private var isLogin = true + + var body: some View { + ZStack { + AppColors.background + .ignoresSafeArea() + + VStack(spacing: 0) { + + HStack(spacing: 0) { + Button(action: { + withAnimation { + isLogin = true + } + }) { + Text("Log In") + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(isLogin ? AppColors.textPrimary : AppColors.textSecondary) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background( + isLogin ? + AppColors.cardBackground : + Color.clear + ) + } + + Button(action: { + withAnimation { + isLogin = false + } + }) { + Text("Sign Up") + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(!isLogin ? AppColors.textPrimary : AppColors.textSecondary) + .frame(maxWidth: .infinity) + .padding(.vertical, 16) + .background( + !isLogin ? + AppColors.cardBackground : + Color.clear + ) + } + } + .background(AppColors.background) + .overlay( + Rectangle() + .frame(height: 1) + .foregroundColor(AppColors.border), + alignment: .bottom + ) + + if isLogin { + LoginView(appState: appState) + } else { + SignupView(appState: appState) + } + } + } + } +} + +#Preview { + ContentView() +} diff --git a/frontend/MusicApp/Info.plist b/frontend/MusicApp/Info.plist new file mode 100644 index 0000000..a964962 --- /dev/null +++ b/frontend/MusicApp/Info.plist @@ -0,0 +1,80 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + MusIQ + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + + UIApplicationSupportsIndirectInputEvents + + UILaunchScreen + + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + NSAppTransportSecurity + + + NSAllowsLocalNetworking + + + NSAllowsArbitraryLoadsInWebContent + + + NSExceptionDomains + + + localhost + + NSExceptionAllowsInsecureHTTPLoads + + NSIncludesSubdomains + + + + 192.168.86.133 + + NSExceptionAllowsInsecureHTTPLoads + + + + + + diff --git a/frontend/MusicApp/Info.plist.backup b/frontend/MusicApp/Info.plist.backup new file mode 100644 index 0000000..30107aa --- /dev/null +++ b/frontend/MusicApp/Info.plist.backup @@ -0,0 +1,17 @@ + + + + + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + com.musiq + + + + + diff --git a/frontend/MusicApp/Models/APIResponse.swift b/frontend/MusicApp/Models/APIResponse.swift new file mode 100644 index 0000000..75a7ae6 --- /dev/null +++ b/frontend/MusicApp/Models/APIResponse.swift @@ -0,0 +1,30 @@ +import Foundation + +struct APIResponse: Codable { + let success: Bool + let data: T? + let message: String? + let error: APIError? +} + +struct APIError: Codable { + let code: String + let message: String + let details: [String: AnyCodable]? + let stack: String? + + enum CodingKeys: String, CodingKey { + case code + case message + case details + case stack + } +} + +struct PaginatedResponse: Codable { + let data: [T] + let page: Int + let limit: Int + let total: Int + let hasMore: Bool +} diff --git a/frontend/MusicApp/Models/AuthToken.swift b/frontend/MusicApp/Models/AuthToken.swift new file mode 100644 index 0000000..aa18240 --- /dev/null +++ b/frontend/MusicApp/Models/AuthToken.swift @@ -0,0 +1,36 @@ +import Foundation + +struct AuthToken: Codable { + let accessToken: String + let refreshToken: String + let expiresIn: Int + let tokenType: String + + enum CodingKeys: String, CodingKey { + case accessToken + case refreshToken + case expiresIn + case tokenType + } +} + +struct LoginRequest: Codable { + let username: String + let password: String +} + +struct SignupRequest: Codable { + let username: String + let password: String + let confirmPassword: String +} + +struct RefreshTokenRequest: Codable { + let refreshToken: String +} + +struct OAuthRequest: Codable { + let provider: String + let token: String + let idToken: String? +} diff --git a/frontend/MusicApp/Models/Friend.swift b/frontend/MusicApp/Models/Friend.swift new file mode 100644 index 0000000..3cde2fe --- /dev/null +++ b/frontend/MusicApp/Models/Friend.swift @@ -0,0 +1,73 @@ +import Foundation + +enum FriendshipStatus: String, Codable { + case pending + case accepted + case blocked +} + +struct Friend: Identifiable, Codable { + let id: String + let name: String + let username: String + let avatar: String + let compatibility: Int + let topGenre: String + let sharedArtists: Int + let status: FriendshipStatus? + + enum CodingKeys: String, CodingKey { + case id + case name + case username + case avatar + case compatibility + case topGenre + case sharedArtists + case status + } +} + +struct Friendship: Identifiable, Codable { + let id: String + let userId: String + let friendId: String + let status: FriendshipStatus + let createdAt: Date + let updatedAt: Date + + enum CodingKeys: String, CodingKey { + case id + case userId + case friendId + case status + case createdAt + case updatedAt + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + userId = try container.decode(String.self, forKey: .userId) + friendId = try container.decode(String.self, forKey: .friendId) + status = try container.decode(FriendshipStatus.self, forKey: .status) + + let formatter = ISO8601DateFormatter() + let createdAtString = try container.decode(String.self, forKey: .createdAt) + let updatedAtString = try container.decode(String.self, forKey: .updatedAt) + createdAt = formatter.date(from: createdAtString) ?? Date() + updatedAt = formatter.date(from: updatedAtString) ?? Date() + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(userId, forKey: .userId) + try container.encode(friendId, forKey: .friendId) + try container.encode(status, forKey: .status) + + let formatter = ISO8601DateFormatter() + try container.encode(formatter.string(from: createdAt), forKey: .createdAt) + try container.encode(formatter.string(from: updatedAt), forKey: .updatedAt) + } +} diff --git a/frontend/MusicApp/Models/MusicItem.swift b/frontend/MusicApp/Models/MusicItem.swift new file mode 100644 index 0000000..c2a7e21 --- /dev/null +++ b/frontend/MusicApp/Models/MusicItem.swift @@ -0,0 +1,114 @@ +import Foundation + +enum MusicItemType: String, Codable { + case album + case song + case artist +} + +struct MusicItem: Identifiable, Codable { + let id: String + let type: MusicItemType + let title: String + let artist: String? + let imageUrl: String? + let rating: Double + let ratingCount: Int + let trending: Bool? + let trendingChange: Int? + let spotifyId: String? + let appleMusicId: String? + let metadata: [String: AnyCodable]? + + init( + id: String, + type: MusicItemType, + title: String, + artist: String?, + imageUrl: String?, + rating: Double, + ratingCount: Int, + trending: Bool? = nil, + trendingChange: Int? = nil, + spotifyId: String? = nil, + appleMusicId: String? = nil, + metadata: [String: AnyCodable]? = nil + ) { + self.id = id + self.type = type + self.title = title + self.artist = artist + self.imageUrl = imageUrl + self.rating = rating + self.ratingCount = ratingCount + self.trending = trending + self.trendingChange = trendingChange + self.spotifyId = spotifyId + self.appleMusicId = appleMusicId + self.metadata = metadata + } + + enum CodingKeys: String, CodingKey { + case id + case type + case title + case artist + case imageUrl + case rating + case ratingCount + case trending + case trendingChange + case spotifyId + case appleMusicId + case metadata + } +} + +struct AnyCodable: Codable { + let value: Any + + init(_ value: Any) { + self.value = value + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if let bool = try? container.decode(Bool.self) { + value = bool + } else if let int = try? container.decode(Int.self) { + value = int + } else if let double = try? container.decode(Double.self) { + value = double + } else if let string = try? container.decode(String.self) { + value = string + } else if let array = try? container.decode([AnyCodable].self) { + value = array.map { $0.value } + } else if let dictionary = try? container.decode([String: AnyCodable].self) { + value = dictionary.mapValues { $0.value } + } else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "AnyCodable value cannot be decoded") + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + + switch value { + case let bool as Bool: + try container.encode(bool) + case let int as Int: + try container.encode(int) + case let double as Double: + try container.encode(double) + case let string as String: + try container.encode(string) + case let array as [Any]: + try container.encode(array.map { AnyCodable($0) }) + case let dictionary as [String: Any]: + try container.encode(dictionary.mapValues { AnyCodable($0) }) + default: + throw EncodingError.invalidValue(value, EncodingError.Context(codingPath: container.codingPath, debugDescription: "AnyCodable value cannot be encoded")) + } + } +} diff --git a/frontend/MusicApp/Models/Notification.swift b/frontend/MusicApp/Models/Notification.swift new file mode 100644 index 0000000..c78994e --- /dev/null +++ b/frontend/MusicApp/Models/Notification.swift @@ -0,0 +1,87 @@ +import Foundation + +enum NotificationType: String, Codable { + case impact + case badge + case social + case trending +} + +struct AppNotification: Identifiable, Codable { + let id: String + let userId: String + let type: NotificationType + let title: String + let message: String + let read: Bool + let metadata: [String: AnyCodable]? + let createdAt: Date + + init( + id: String, + userId: String, + type: NotificationType, + title: String, + message: String, + read: Bool, + metadata: [String: AnyCodable]? = nil, + createdAt: Date + ) { + self.id = id + self.userId = userId + self.type = type + self.title = title + self.message = message + self.read = read + self.metadata = metadata + self.createdAt = createdAt + } + + enum CodingKeys: String, CodingKey { + case id + case userId + case type + case title + case message + case read + case metadata + case createdAt + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + userId = try container.decode(String.self, forKey: .userId) + type = try container.decode(NotificationType.self, forKey: .type) + title = try container.decode(String.self, forKey: .title) + message = try container.decode(String.self, forKey: .message) + read = try container.decode(Bool.self, forKey: .read) + metadata = try container.decodeIfPresent([String: AnyCodable].self, forKey: .metadata) + + let formatter = ISO8601DateFormatter() + let createdAtString = try container.decode(String.self, forKey: .createdAt) + createdAt = formatter.date(from: createdAtString) ?? Date() + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(userId, forKey: .userId) + try container.encode(type, forKey: .type) + try container.encode(title, forKey: .title) + try container.encode(message, forKey: .message) + try container.encode(read, forKey: .read) + try container.encodeIfPresent(metadata, forKey: .metadata) + + let formatter = ISO8601DateFormatter() + try container.encode(formatter.string(from: createdAt), forKey: .createdAt) + } +} + +extension AppNotification { + var timeAgo: String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter.localizedString(for: createdAt, relativeTo: Date()) + } +} diff --git a/frontend/MusicApp/Models/Pagination.swift b/frontend/MusicApp/Models/Pagination.swift new file mode 100644 index 0000000..5e2d1cf --- /dev/null +++ b/frontend/MusicApp/Models/Pagination.swift @@ -0,0 +1,14 @@ +import Foundation + +struct PaginatedFeedResponse: Codable { + let data: [MusicItem] + let pagination: PaginationInfo? +} + +struct PaginationInfo: Codable { + let page: Int + let limit: Int + let total: Int + let hasMore: Bool + let nextPage: Int? +} diff --git a/frontend/MusicApp/Models/Rating.swift b/frontend/MusicApp/Models/Rating.swift new file mode 100644 index 0000000..572b45a --- /dev/null +++ b/frontend/MusicApp/Models/Rating.swift @@ -0,0 +1,60 @@ +import Foundation + +struct Rating: Identifiable, Codable { + let id: String + let userId: String + let musicItemId: String + let rating: Int + let tags: [String]? + let createdAt: Date + let updatedAt: Date + + enum CodingKeys: String, CodingKey { + case id + case userId + case musicItemId + case rating + case tags + case createdAt + case updatedAt + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + userId = try container.decode(String.self, forKey: .userId) + musicItemId = try container.decode(String.self, forKey: .musicItemId) + rating = try container.decode(Int.self, forKey: .rating) + tags = try container.decodeIfPresent([String].self, forKey: .tags) + + let formatter = ISO8601DateFormatter() + let createdAtString = try container.decode(String.self, forKey: .createdAt) + let updatedAtString = try container.decode(String.self, forKey: .updatedAt) + createdAt = formatter.date(from: createdAtString) ?? Date() + updatedAt = formatter.date(from: updatedAtString) ?? Date() + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(userId, forKey: .userId) + try container.encode(musicItemId, forKey: .musicItemId) + try container.encode(rating, forKey: .rating) + try container.encodeIfPresent(tags, forKey: .tags) + + let formatter = ISO8601DateFormatter() + try container.encode(formatter.string(from: createdAt), forKey: .createdAt) + try container.encode(formatter.string(from: updatedAt), forKey: .updatedAt) + } +} + +struct CreateRatingRequest: Codable { + let musicItemId: String + let rating: Int + let tags: [String]? +} + +struct RatingResponse: Codable { + let rating: Rating + let message: String +} diff --git a/frontend/MusicApp/Models/User.swift b/frontend/MusicApp/Models/User.swift new file mode 100644 index 0000000..2810b99 --- /dev/null +++ b/frontend/MusicApp/Models/User.swift @@ -0,0 +1,94 @@ +import Foundation + +enum UserRole: String, Codable { + case user + case moderator + case admin +} + +enum OAuthProvider: String, Codable { + case apple + case google +} + +struct User: Identifiable, Codable { + let id: String + let email: String + let username: String + let emailVerified: Bool + let mfaEnabled: Bool + let role: UserRole + let oauthProvider: OAuthProvider? + let oauthId: String? + let lastLoginAt: Date? + let createdAt: Date + let updatedAt: Date + + enum CodingKeys: String, CodingKey { + case id + case email + case username + case emailVerified = "email_verified" + case mfaEnabled = "mfa_enabled" + case role + case oauthProvider = "oauth_provider" + case oauthId = "oauth_id" + case lastLoginAt = "last_login_at" + case createdAt = "created_at" + case updatedAt = "updated_at" + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + email = try container.decode(String.self, forKey: .email) + username = try container.decode(String.self, forKey: .username) + emailVerified = try container.decode(Bool.self, forKey: .emailVerified) + mfaEnabled = try container.decode(Bool.self, forKey: .mfaEnabled) + role = try container.decode(UserRole.self, forKey: .role) + oauthProvider = try container.decodeIfPresent(OAuthProvider.self, forKey: .oauthProvider) + oauthId = try container.decodeIfPresent(String.self, forKey: .oauthId) + + if let lastLoginString = try? container.decode(String.self, forKey: .lastLoginAt) { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + lastLoginAt = formatter.date(from: lastLoginString) + } else { + lastLoginAt = nil + } + + let createdAtString = try container.decode(String.self, forKey: .createdAt) + let updatedAtString = try container.decode(String.self, forKey: .updatedAt) + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + createdAt = formatter.date(from: createdAtString) ?? Date() + updatedAt = formatter.date(from: updatedAtString) ?? Date() + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(email, forKey: .email) + try container.encode(username, forKey: .username) + try container.encode(emailVerified, forKey: .emailVerified) + try container.encode(mfaEnabled, forKey: .mfaEnabled) + try container.encode(role, forKey: .role) + try container.encodeIfPresent(oauthProvider, forKey: .oauthProvider) + try container.encodeIfPresent(oauthId, forKey: .oauthId) + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let lastLoginAt = lastLoginAt { + try container.encode(formatter.string(from: lastLoginAt), forKey: .lastLoginAt) + } + try container.encode(formatter.string(from: createdAt), forKey: .createdAt) + try container.encode(formatter.string(from: updatedAt), forKey: .updatedAt) + } +} + +struct UserProfile: Codable { + let user: User + let tasteScore: Int + let totalRatings: Int + let influence: Int +} diff --git a/frontend/MusicApp/MusicAppApp.swift b/frontend/MusicApp/MusicAppApp.swift new file mode 100644 index 0000000..51a8f21 --- /dev/null +++ b/frontend/MusicApp/MusicAppApp.swift @@ -0,0 +1,51 @@ +import SwiftUI + +@main +struct MusIQApp: App { + @StateObject private var appState = AppState.shared + + var body: some Scene { + WindowGroup { + ContentView() + .environmentObject(appState) + .onOpenURL { url in + + handleOAuthCallback(url: url) + } + } + } + + private func handleOAuthCallback(url: URL) { + + let components = URLComponents(url: url, resolvingAgainstBaseURL: false) + + guard let host = url.host, + let queryItems = components?.queryItems, + let code = queryItems.first(where: { $0.name == "code" })?.value else { + return + } + + var provider: OAuthProviderType? + if host.contains("google") { + provider = .google + } else if host.contains("apple") { + provider = .apple + } + + guard let provider = provider else { + return + } + + let idToken = queryItems.first(where: { $0.name == "id_token" })?.value + + NotificationCenter.default.post( + name: NSNotification.Name("OAuthCallback"), + object: nil, + userInfo: [ + "provider": provider.rawValue, + "code": code, + "idToken": idToken as Any + ] + ) + } +} diff --git a/frontend/MusicApp/Services/APIService.swift b/frontend/MusicApp/Services/APIService.swift new file mode 100644 index 0000000..a9fc912 --- /dev/null +++ b/frontend/MusicApp/Services/APIService.swift @@ -0,0 +1,92 @@ +import Foundation + +class APIService { + static let shared = APIService() + + private let baseURL: String + private let session: URLSession + + init(baseURL: String? = nil) { + self.baseURL = baseURL ?? ProcessInfo.processInfo.environment["API_BASE_URL"] ?? "http://192.168.86.133:3000/api" + + let configuration = URLSessionConfiguration.default + configuration.timeoutIntervalForRequest = 30 + configuration.timeoutIntervalForResource = 60 + self.session = URLSession(configuration: configuration) + } + + func request( + endpoint: String, + method: HTTPMethod = .get, + body: Encodable? = nil, + requiresAuth: Bool = true + ) async throws -> T { + guard let url = URL(string: "\(baseURL)\(endpoint)") else { + throw NetworkError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = method.rawValue + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + if requiresAuth { + if let token = KeychainHelper.retrieve(forKey: "accessToken") { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + } + + if let body = body { + do { + request.httpBody = try JSONEncoder().encode(body) + } catch { + throw NetworkError.encodingError + } + } + + do { + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw NetworkError.unknown(NSError(domain: "APIService", code: -1)) + } + + switch httpResponse.statusCode { + case 200...299: + do { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try decoder.decode(T.self, from: data) + } catch let decodingError { + if let jsonString = String(data: data, encoding: .utf8) { + print("Decoding Error - Response JSON: \(jsonString)") + } + print("Decoding Error Details: \(decodingError)") + throw NetworkError.unknown(decodingError) + } + case 401: + throw NetworkError.unauthorized + case 403: + throw NetworkError.forbidden + case 404: + throw NetworkError.notFound + case 500...599: + throw NetworkError.serverError(httpResponse.statusCode) + default: + throw NetworkError.serverError(httpResponse.statusCode) + } + } catch let error as NetworkError { + throw error + } catch { + throw NetworkError.unknown(error) + } + } +} + +enum HTTPMethod: String { + case get = "GET" + case post = "POST" + case put = "PUT" + case delete = "DELETE" + case patch = "PATCH" +} diff --git a/frontend/MusicApp/Services/AuthService.swift b/frontend/MusicApp/Services/AuthService.swift new file mode 100644 index 0000000..4d54585 --- /dev/null +++ b/frontend/MusicApp/Services/AuthService.swift @@ -0,0 +1,95 @@ +import Foundation + +class AuthService { + private let apiService = APIService.shared + + func login(request: LoginRequest) async throws -> AuthToken { + do { + let response: APIResponse = try await apiService.request( + endpoint: "/auth/login", + method: .post, + body: request, + requiresAuth: false + ) + + guard response.success, let data = response.data else { + if let error = response.error { + throw NetworkError.unknown(NSError(domain: "AuthService", code: Int(error.code) ?? 401, userInfo: [NSLocalizedDescriptionKey: error.message])) + } + throw NetworkError.unauthorized + } + + return data + } catch let error as NetworkError { + throw error + } catch { + throw NetworkError.unknown(error) + } + } + + func signup(request: SignupRequest) async throws -> AuthToken { + do { + let response: APIResponse = try await apiService.request( + endpoint: "/auth/signup", + method: .post, + body: request, + requiresAuth: false + ) + + guard response.success, let data = response.data else { + if let error = response.error { + throw NetworkError.unknown(NSError(domain: "AuthService", code: Int(error.code) ?? 400, userInfo: [NSLocalizedDescriptionKey: error.message])) + } + throw NetworkError.unknown(NSError(domain: "AuthService", code: -1, userInfo: [NSLocalizedDescriptionKey: "Signup failed"])) + } + + return data + } catch let error as NetworkError { + throw error + } catch { + throw NetworkError.unknown(error) + } + } + + func refreshToken(request: RefreshTokenRequest) async throws -> AuthToken { + let response: APIResponse = try await apiService.request( + endpoint: "/auth/refresh", + method: .post, + body: request, + requiresAuth: false + ) + + guard response.success, let data = response.data else { + throw NetworkError.unauthorized + } + + return data + } + + func getCurrentUser() async throws -> User { + let response: APIResponse = try await apiService.request( + endpoint: "/profile", + method: .get + ) + + guard response.success, let data = response.data else { + throw NetworkError.unauthorized + } + + return data + } + + func logout() async throws { + _ = try await apiService.request( + endpoint: "/auth/logout", + method: .post, + body: EmptyBody(), + requiresAuth: true + ) as APIResponse + + KeychainHelper.clearAll() + } +} + +struct EmptyBody: Codable {} +struct EmptyResponse: Codable {} diff --git a/frontend/MusicApp/Services/MusicService.swift b/frontend/MusicApp/Services/MusicService.swift new file mode 100644 index 0000000..9383488 --- /dev/null +++ b/frontend/MusicApp/Services/MusicService.swift @@ -0,0 +1,50 @@ +import Foundation + +class MusicService { + private let apiService = APIService.shared + + func getFeed(filter: String, page: Int = 1, limit: Int = 20) async throws -> (items: [MusicItem], hasMore: Bool, nextPage: Int?) { + let endpoint = "/music/feed?filter=\(filter)&page=\(page)&limit=\(limit)" + let response: APIResponse = try await apiService.request( + endpoint: endpoint, + method: .get + ) + + guard response.success, let data = response.data else { + throw NetworkError.unknown(NSError(domain: "MusicService", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to load feed"])) + } + + return ( + items: data.data, + hasMore: data.pagination?.hasMore ?? false, + nextPage: data.pagination?.nextPage + ) + } + + func getMusicItem(id: String) async throws -> MusicItem { + let response: APIResponse = try await apiService.request( + endpoint: "/music/\(id)", + method: .get + ) + + guard response.success, let data = response.data else { + throw NetworkError.notFound + } + + return data + } + + func search(query: String) async throws -> [MusicItem] { + let endpoint = "/music/search?q=\(query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")" + let response: APIResponse<[MusicItem]> = try await apiService.request( + endpoint: endpoint, + method: .get + ) + + guard response.success, let data = response.data else { + return [] + } + + return data + } +} diff --git a/frontend/MusicApp/Services/NotificationService.swift b/frontend/MusicApp/Services/NotificationService.swift new file mode 100644 index 0000000..f9f3684 --- /dev/null +++ b/frontend/MusicApp/Services/NotificationService.swift @@ -0,0 +1,36 @@ +import Foundation + +class NotificationService { + private let apiService = APIService.shared + + func getNotifications() async throws -> [AppNotification] { + let response: APIResponse<[AppNotification]> = try await apiService.request( + endpoint: "/notifications", + method: .get + ) + + guard response.success, let data = response.data else { + return [] + } + + return data + } + + func markAsRead(notificationId: String) async throws { + _ = try await apiService.request( + endpoint: "/notifications/\(notificationId)/read", + method: .put, + body: EmptyBody(), + requiresAuth: true + ) as APIResponse + } + + func markAllAsRead() async throws { + _ = try await apiService.request( + endpoint: "/notifications/read-all", + method: .put, + body: EmptyBody(), + requiresAuth: true + ) as APIResponse + } +} diff --git a/frontend/MusicApp/Services/OAuthService.swift b/frontend/MusicApp/Services/OAuthService.swift new file mode 100644 index 0000000..fb112bc --- /dev/null +++ b/frontend/MusicApp/Services/OAuthService.swift @@ -0,0 +1,81 @@ +import Foundation +import AuthenticationServices +#if canImport(AppAuth) +import AppAuth +#endif + +enum OAuthProviderType { + case apple + case google + + var rawValue: String { + switch self { + case .apple: return "apple" + case .google: return "google" + } + } +} + +class OAuthService { + private let authService: AuthService + + init(authService: AuthService = AuthService()) { + self.authService = authService + } + + func signInWithGoogle() async throws -> AuthToken { + throw NetworkError.unauthorized + } + + func handleOAuthCallback(authorizationCode: String, provider: OAuthProviderType, idToken: String? = nil, email: String? = nil, name: String? = nil, userIdentifier: String? = nil) async throws -> AuthToken { + + var requestBody: [String: Any] = [ + "token": authorizationCode + ] + + if let idToken = idToken { + requestBody["idToken"] = idToken + } + + if let email = email { + requestBody["email"] = email + } + + if let name = name { + requestBody["name"] = name + } + + if let userIdentifier = userIdentifier { + requestBody["userIdentifier"] = userIdentifier + } + + struct OAuthRequestBody: Codable { + let token: String + let idToken: String? + let email: String? + let name: String? + let userIdentifier: String? + } + + let request = OAuthRequestBody( + token: authorizationCode, + idToken: idToken, + email: email, + name: name, + userIdentifier: userIdentifier + ) + + let response: APIResponse = try await APIService.shared.request( + endpoint: "/auth/oauth/\(provider.rawValue)", + method: .post, + body: request, + requiresAuth: false + ) + + guard response.success, let data = response.data else { + throw NetworkError.unauthorized + } + + return data + } +} diff --git a/frontend/MusicApp/Services/ProfileService.swift b/frontend/MusicApp/Services/ProfileService.swift new file mode 100644 index 0000000..05a5f33 --- /dev/null +++ b/frontend/MusicApp/Services/ProfileService.swift @@ -0,0 +1,45 @@ +import Foundation + +class ProfileService { + private let apiService = APIService.shared + + func getProfile() async throws -> UserProfile { + let response: APIResponse = try await apiService.request( + endpoint: "/profile", + method: .get + ) + + guard response.success, let data = response.data else { + throw NetworkError.unauthorized + } + + return data + } + + func getTasteProfile() async throws -> TasteProfileResponse { + let response: APIResponse = try await apiService.request( + endpoint: "/profile/taste", + method: .get + ) + + guard response.success, let data = response.data else { + throw NetworkError.unauthorized + } + + return data + } + + func updateProfile(_ user: User) async throws -> User { + let response: APIResponse = try await apiService.request( + endpoint: "/profile", + method: .put, + body: user + ) + + guard response.success, let data = response.data else { + throw NetworkError.unknown(NSError(domain: "ProfileService", code: -1)) + } + + return data + } +} diff --git a/frontend/MusicApp/Services/RankingService.swift b/frontend/MusicApp/Services/RankingService.swift new file mode 100644 index 0000000..e7d4118 --- /dev/null +++ b/frontend/MusicApp/Services/RankingService.swift @@ -0,0 +1,20 @@ +import Foundation + +class RankingService { + private let apiService = APIService.shared + + func getRankings(type: String, timeframe: String = "all_time") async throws -> [RankingItem] { + let endpoint = "/rankings/\(type)?timeframe=\(timeframe)" + let response: APIResponse<[RankingItem]> = try await apiService.request( + endpoint: endpoint, + method: .get + ) + + guard response.success, let data = response.data else { + + return [] + } + + return data + } +} diff --git a/frontend/MusicApp/Services/RatingService.swift b/frontend/MusicApp/Services/RatingService.swift new file mode 100644 index 0000000..15bd9dc --- /dev/null +++ b/frontend/MusicApp/Services/RatingService.swift @@ -0,0 +1,45 @@ +import Foundation + +class RatingService { + private let apiService = APIService.shared + + func submitRating(request: CreateRatingRequest) async throws -> RatingResponse { + let response: APIResponse = try await apiService.request( + endpoint: "/ratings", + method: .post, + body: request + ) + + guard response.success, let data = response.data else { + throw NetworkError.unknown(NSError(domain: "RatingService", code: -1)) + } + + return data + } + + func getRatings(for musicItemId: String) async throws -> [Rating] { + let response: APIResponse<[Rating]> = try await apiService.request( + endpoint: "/ratings/\(musicItemId)", + method: .get + ) + + guard response.success, let data = response.data else { + return [] + } + + return data + } + + func getUserRatings(userId: String) async throws -> [Rating] { + let response: APIResponse<[Rating]> = try await apiService.request( + endpoint: "/ratings/user/\(userId)", + method: .get + ) + + guard response.success, let data = response.data else { + return [] + } + + return data + } +} diff --git a/frontend/MusicApp/Services/SocialService.swift b/frontend/MusicApp/Services/SocialService.swift new file mode 100644 index 0000000..9ef267a --- /dev/null +++ b/frontend/MusicApp/Services/SocialService.swift @@ -0,0 +1,63 @@ +import Foundation + +class SocialService { + private let apiService = APIService.shared + + func getFriends() async throws -> [Friend] { + let response: APIResponse<[Friend]> = try await apiService.request( + endpoint: "/social/friends", + method: .get + ) + + guard response.success, let data = response.data else { + return [] + } + + return data + } + + func follow(userId: String) async throws { + _ = try await apiService.request( + endpoint: "/social/follow/\(userId)", + method: .post, + body: EmptyBody(), + requiresAuth: true + ) as APIResponse + } + + func getCompatibility(userId: String) async throws -> Int { + let response: APIResponse = try await apiService.request( + endpoint: "/social/compatibility/\(userId)", + method: .get + ) + + guard response.success, let data = response.data else { + return 0 + } + + return data.compatibility + } + + func compareTaste(userId: String) async throws -> TasteComparison { + let response: APIResponse = try await apiService.request( + endpoint: "/social/compare/\(userId)", + method: .get + ) + + guard response.success, let data = response.data else { + throw NetworkError.unknown(NSError(domain: "SocialService", code: -1)) + } + + return data + } +} + +struct CompatibilityResponse: Codable { + let compatibility: Int +} + +struct TasteComparison: Codable { + let compatibility: Int + let sharedArtists: Int + let sharedGenres: [String] +} diff --git a/frontend/MusicApp/Theme/AppColors.swift b/frontend/MusicApp/Theme/AppColors.swift new file mode 100644 index 0000000..7ee6a07 --- /dev/null +++ b/frontend/MusicApp/Theme/AppColors.swift @@ -0,0 +1,51 @@ +import SwiftUI + +struct AppColors { + static let background = Color(hex: "#F5F8FC") + static let cardBackground = Color(hex: "#FFFFFF") + static let secondaryBackground = Color(hex: "#EAF1F8") + + static let primary = Color(hex: "#35516D") + static let secondary = Color(hex: "#7A93AC") + + static let accent = Color(hex: "#35516D") + static let accentLight = Color(hex: "#D0DEEC") + + static let textPrimary = Color(hex: "#0F2A44") + static let textSecondary = Color(hex: "#7A93AC") + + static let border = Color(hex: "#D6E0EB") + static let borderLight = Color(hex: "#D0DEEC") + + static let notificationImpact = Color(hex: "#35516D") + static let notificationBadge = Color(hex: "#7A93AC") + static let notificationSocial = Color(hex: "#35516D") + static let notificationTrending = Color(hex: "#7A93AC") +} + +extension Color { + init(hex: String) { + let hex = hex.trimmingCharacters(in: CharacterSet.alphanumerics.inverted) + var int: UInt64 = 0 + Scanner(string: hex).scanHexInt64(&int) + let a, r, g, b: UInt64 + switch hex.count { + case 3: + (a, r, g, b) = (255, (int >> 8) * 17, (int >> 4 & 0xF) * 17, (int & 0xF) * 17) + case 6: + (a, r, g, b) = (255, int >> 16, int >> 8 & 0xFF, int & 0xFF) + case 8: + (a, r, g, b) = (int >> 24, int >> 16 & 0xFF, int >> 8 & 0xFF, int & 0xFF) + default: + (a, r, g, b) = (1, 1, 1, 0) + } + + self.init( + .sRGB, + red: Double(r) / 255, + green: Double(g) / 255, + blue: Double(b) / 255, + opacity: Double(a) / 255 + ) + } +} diff --git a/frontend/MusicApp/Theme/AppGradients.swift b/frontend/MusicApp/Theme/AppGradients.swift new file mode 100644 index 0000000..860b1bc --- /dev/null +++ b/frontend/MusicApp/Theme/AppGradients.swift @@ -0,0 +1,24 @@ +import SwiftUI + +struct AppGradients { + static let primary = AppColors.primary + static let background = AppColors.background + static let card = AppColors.cardBackground + static let accent = AppColors.accent + static let splash = AppColors.background +} + +struct GradientBackground: ViewModifier { + let color: Color + + func body(content: Content) -> some View { + content + .background(color) + } +} + +extension View { + func gradientBackground(_ color: Color = AppColors.background) -> some View { + modifier(GradientBackground(color: color)) + } +} diff --git a/frontend/MusicApp/Theme/AppStyles.swift b/frontend/MusicApp/Theme/AppStyles.swift new file mode 100644 index 0000000..65e5b45 --- /dev/null +++ b/frontend/MusicApp/Theme/AppStyles.swift @@ -0,0 +1,84 @@ +import SwiftUI + +struct AppStyles { + + static let cornerRadiusSmall: CGFloat = 12 + static let cornerRadiusMedium: CGFloat = 16 + static let cornerRadiusLarge: CGFloat = 24 + + static let spacingSmall: CGFloat = 8 + static let spacingMedium: CGFloat = 16 + static let spacingLarge: CGFloat = 24 + + static let paddingSmall: CGFloat = 12 + static let paddingMedium: CGFloat = 16 + static let paddingLarge: CGFloat = 24 + + static let shadowColor = Color.black.opacity(0.1) + static let shadowRadius: CGFloat = 8 + static let shadowOffset = CGSize(width: 0, height: 4) +} + +struct CardStyle: ViewModifier { + func body(content: Content) -> some View { + content + .background(AppColors.cardBackground) + .cornerRadius(AppStyles.cornerRadiusMedium) + .overlay( + RoundedRectangle(cornerRadius: AppStyles.cornerRadiusMedium) + .stroke(AppColors.border, lineWidth: 1) + ) + .shadow( + color: AppStyles.shadowColor, + radius: AppStyles.shadowRadius, + x: AppStyles.shadowOffset.width, + y: AppStyles.shadowOffset.height + ) + } +} + +struct GradientButtonStyle: ButtonStyle { + let isEnabled: Bool + + init(isEnabled: Bool = true) { + self.isEnabled = isEnabled + } + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .padding(.horizontal, AppStyles.paddingMedium) + .padding(.vertical, AppStyles.paddingSmall) + .background(isEnabled ? AppColors.primary : AppColors.secondaryBackground) + .foregroundColor(isEnabled ? .white : AppColors.textSecondary) + .cornerRadius(AppStyles.cornerRadiusMedium) + .scaleEffect(configuration.isPressed ? 0.95 : 1.0) + .animation(.easeInOut(duration: 0.1), value: configuration.isPressed) + } +} + +struct SecondaryButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .padding(.horizontal, AppStyles.paddingMedium) + .padding(.vertical, AppStyles.paddingSmall) + .background(AppColors.secondaryBackground) + .foregroundColor(AppColors.textSecondary) + .cornerRadius(AppStyles.cornerRadiusMedium) + .scaleEffect(configuration.isPressed ? 0.95 : 1.0) + .animation(.easeInOut(duration: 0.1), value: configuration.isPressed) + } +} + +extension View { + func cardStyle() -> some View { + modifier(CardStyle()) + } + + func gradientButton(isEnabled: Bool = true) -> some View { + buttonStyle(GradientButtonStyle(isEnabled: isEnabled)) + } + + func secondaryButton() -> some View { + buttonStyle(SecondaryButtonStyle()) + } +} diff --git a/frontend/MusicApp/Utilities/AnimationHelpers.swift b/frontend/MusicApp/Utilities/AnimationHelpers.swift new file mode 100644 index 0000000..39eeb1f --- /dev/null +++ b/frontend/MusicApp/Utilities/AnimationHelpers.swift @@ -0,0 +1,63 @@ +import SwiftUI + +extension View { + func fadeIn(delay: Double = 0) -> some View { + self.modifier(FadeInModifier(delay: delay)) + } + + func slideIn(from edge: Edge, delay: Double = 0) -> some View { + self.modifier(SlideInModifier(edge: edge, delay: delay)) + } + + func scaleIn(delay: Double = 0) -> some View { + self.modifier(ScaleInModifier(delay: delay)) + } +} + +struct FadeInModifier: ViewModifier { + let delay: Double + @State private var opacity: Double = 0 + + func body(content: Content) -> some View { + content + .opacity(opacity) + .onAppear { + withAnimation(.easeInOut(duration: 0.5).delay(delay)) { + opacity = 1 + } + } + } +} + +struct SlideInModifier: ViewModifier { + let edge: Edge + let delay: Double + @State private var offset: CGFloat = 0 + + func body(content: Content) -> some View { + content + .offset(x: edge == .leading ? -offset : edge == .trailing ? offset : 0, + y: edge == .top ? -offset : edge == .bottom ? offset : 0) + .onAppear { + offset = 100 + withAnimation(.spring(response: 0.6, dampingFraction: 0.8).delay(delay)) { + offset = 0 + } + } + } +} + +struct ScaleInModifier: ViewModifier { + let delay: Double + @State private var scale: CGFloat = 0 + + func body(content: Content) -> some View { + content + .scaleEffect(scale) + .onAppear { + withAnimation(.spring(response: 0.6, dampingFraction: 0.7).delay(delay)) { + scale = 1 + } + } + } +} diff --git a/frontend/MusicApp/Utilities/Extensions.swift b/frontend/MusicApp/Utilities/Extensions.swift new file mode 100644 index 0000000..8882cd2 --- /dev/null +++ b/frontend/MusicApp/Utilities/Extensions.swift @@ -0,0 +1,33 @@ +import Foundation +import SwiftUI + +extension Int { + func formatted() -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + return formatter.string(from: NSNumber(value: self)) ?? "\(self)" + } +} + +extension Double { + func formatted() -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + formatter.maximumFractionDigits = 1 + return formatter.string(from: NSNumber(value: self)) ?? "\(self)" + } +} + +extension Date { + func timeAgo() -> String { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .abbreviated + return formatter.localizedString(for: self, relativeTo: Date()) + } +} + +extension View { + func hideKeyboard() { + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + } +} diff --git a/frontend/MusicApp/Utilities/ImageLoader.swift b/frontend/MusicApp/Utilities/ImageLoader.swift new file mode 100644 index 0000000..a74c943 --- /dev/null +++ b/frontend/MusicApp/Utilities/ImageLoader.swift @@ -0,0 +1,39 @@ +import SwiftUI + +struct MusicAsyncImage: View { + let url: String + let placeholder: String + + init(url: String, placeholder: String = "music.note") { + self.url = url + self.placeholder = placeholder + } + + var body: some View { + AsyncImage(url: URL(string: url)) { phase in + switch phase { + case .empty: + ZStack { + Rectangle() + .fill(AppColors.secondaryBackground) + ProgressView() + .tint(AppColors.primary) + } + case .success(let image): + image + .resizable() + .aspectRatio(contentMode: .fill) + case .failure: + ZStack { + Rectangle() + .fill(AppColors.secondaryBackground) + Image(systemName: placeholder) + .font(.system(size: 24)) + .foregroundColor(AppColors.textSecondary) + } + @unknown default: + EmptyView() + } + } + } +} diff --git a/frontend/MusicApp/Utilities/KeychainHelper.swift b/frontend/MusicApp/Utilities/KeychainHelper.swift new file mode 100644 index 0000000..2f88caf --- /dev/null +++ b/frontend/MusicApp/Utilities/KeychainHelper.swift @@ -0,0 +1,61 @@ +import Foundation +import Security + +class KeychainHelper { + static let service = "com.musiq.tokens" + + static func store(token: String, forKey key: String) { + let data = token.data(using: .utf8)! + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecValueData as String: data + ] + + SecItemDelete(query as CFDictionary) + + SecItemAdd(query as CFDictionary, nil) + } + + static func retrieve(forKey key: String) -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + guard status == errSecSuccess, + let data = result as? Data, + let token = String(data: data, encoding: .utf8) else { + return nil + } + + return token + } + + static func delete(forKey key: String) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: key + ] + + SecItemDelete(query as CFDictionary) + } + + static func clearAll() { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service + ] + + SecItemDelete(query as CFDictionary) + } +} diff --git a/frontend/MusicApp/Utilities/NetworkError.swift b/frontend/MusicApp/Utilities/NetworkError.swift new file mode 100644 index 0000000..241460b --- /dev/null +++ b/frontend/MusicApp/Utilities/NetworkError.swift @@ -0,0 +1,39 @@ +import Foundation + +enum NetworkError: LocalizedError { + case invalidURL + case noData + case decodingError + case encodingError + case serverError(Int) + case unauthorized + case forbidden + case notFound + case networkUnavailable + case unknown(Error) + + var errorDescription: String? { + switch self { + case .invalidURL: + return "Invalid URL" + case .noData: + return "No data received" + case .decodingError: + return "Failed to decode response" + case .encodingError: + return "Failed to encode request" + case .serverError(let code): + return "Server error: \(code)" + case .unauthorized: + return "Unauthorized. Please log in again." + case .forbidden: + return "Access forbidden" + case .notFound: + return "Resource not found" + case .networkUnavailable: + return "Network unavailable. Please check your connection." + case .unknown(let error): + return error.localizedDescription + } + } +} diff --git a/frontend/MusicApp/ViewModels/AppState.swift b/frontend/MusicApp/ViewModels/AppState.swift new file mode 100644 index 0000000..2b00f41 --- /dev/null +++ b/frontend/MusicApp/ViewModels/AppState.swift @@ -0,0 +1,89 @@ +import Foundation +import SwiftUI +import Combine + +enum AppScreen { + case splash + case onboarding + case authentication + case main +} + +class AppState: ObservableObject { + @Published var currentScreen: AppScreen = .splash + @Published var hasCompletedOnboarding: Bool = false + @Published var isAuthenticated: Bool = false + @Published var currentUser: User? + + private let userDefaults = UserDefaults.standard + private let onboardingKey = "hasCompletedOnboarding" + private let authKey = "isAuthenticated" + + @MainActor + init() { + hasCompletedOnboarding = userDefaults.bool(forKey: onboardingKey) + + let hasAccessToken = KeychainHelper.retrieve(forKey: "accessToken") != nil + isAuthenticated = hasAccessToken + + if !hasAccessToken { + userDefaults.set(false, forKey: authKey) + } + + if !hasCompletedOnboarding { + currentScreen = .onboarding + } else if !isAuthenticated { + currentScreen = .authentication + } else { + Task { + await verifyAuthentication() + } + currentScreen = .main + } + } + + @MainActor + private func verifyAuthentication() async { + let authService = AuthService() + do { + let user = try await authService.getCurrentUser() + self.currentUser = user + self.isAuthenticated = true + userDefaults.set(true, forKey: authKey) + } catch { + self.isAuthenticated = false + self.currentUser = nil + userDefaults.set(false, forKey: authKey) + KeychainHelper.clearAll() + self.currentScreen = .authentication + } + } + + @MainActor + func completeOnboarding() { + hasCompletedOnboarding = true + userDefaults.set(true, forKey: onboardingKey) + currentScreen = .authentication + } + + @MainActor + func authenticate(user: User) { + isAuthenticated = true + currentUser = user + userDefaults.set(true, forKey: authKey) + currentScreen = .main + } + + @MainActor + func logout() { + isAuthenticated = false + currentUser = nil + userDefaults.set(false, forKey: authKey) + KeychainHelper.clearAll() + currentScreen = .authentication + } +} + +extension AppState { + static var shared: AppState = AppState() +} diff --git a/frontend/MusicApp/ViewModels/AuthViewModel.swift b/frontend/MusicApp/ViewModels/AuthViewModel.swift new file mode 100644 index 0000000..03df026 --- /dev/null +++ b/frontend/MusicApp/ViewModels/AuthViewModel.swift @@ -0,0 +1,184 @@ +import Foundation +import SwiftUI +import Combine + +class AuthViewModel: ObservableObject { + @Published var username: String = "" + @Published var password: String = "" + @Published var confirmPassword: String = "" + @Published var isLoading: Bool = false + @Published var errorMessage: String? + @Published var isAuthenticated: Bool = false + @Published var passwordErrors: [String] = [] + + private let authService: AuthService + + init(authService: AuthService = AuthService()) { + self.authService = authService + } + + func validatePassword(_ password: String) -> [String] { + var errors: [String] = [] + + if password.count < 8 || password.count > 128 { + errors.append("Password must be between 8 and 128 characters") + } + if !password.contains(where: { $0.isLowercase }) { + errors.append("Password must contain at least one lowercase letter") + } + if !password.contains(where: { $0.isUppercase }) { + errors.append("Password must contain at least one uppercase letter") + } + if !password.contains(where: { $0.isNumber }) { + errors.append("Password must contain at least one number") + } + let specialChars = CharacterSet(charactersIn: "@$!%*?&") + if !password.unicodeScalars.contains(where: { specialChars.contains($0) }) { + errors.append("Password must contain at least one special character (@$!%*?&)") + } + + return errors + } + + func login() async { + isLoading = true + errorMessage = nil + + guard !username.isEmpty, !password.isEmpty else { + errorMessage = "Username and password are required" + isLoading = false + return + } + + do { + let request = LoginRequest(username: username, password: password) + let token = try await authService.login(request: request) + + KeychainHelper.store(token: token.accessToken, forKey: "accessToken") + KeychainHelper.store(token: token.refreshToken, forKey: "refreshToken") + + let user = try await authService.getCurrentUser() + isAuthenticated = true + + let appState = getAppState() + appState.authenticate(user: user) + } catch { + errorMessage = error.localizedDescription + } + + isLoading = false + } + + func signup() async { + isLoading = true + errorMessage = nil + passwordErrors = [] + + if username.count < 3 || username.count > 30 { + errorMessage = "Username must be between 3 and 30 characters" + isLoading = false + return + } + + if !username.allSatisfy({ $0.isLetter || $0.isNumber || $0 == "_" }) { + errorMessage = "Username can only contain letters, numbers, and underscores" + isLoading = false + return + } + + if password != confirmPassword { + errorMessage = "Passwords do not match" + isLoading = false + return + } + + let pwdErrors = validatePassword(password) + if !pwdErrors.isEmpty { + passwordErrors = pwdErrors + errorMessage = "Please fix password requirements" + isLoading = false + return + } + + do { + let request = SignupRequest(username: username, password: password, confirmPassword: confirmPassword) + let token = try await authService.signup(request: request) + + KeychainHelper.store(token: token.accessToken, forKey: "accessToken") + KeychainHelper.store(token: token.refreshToken, forKey: "refreshToken") + + let user = try await authService.getCurrentUser() + isAuthenticated = true + + let appState = getAppState() + appState.authenticate(user: user) + } catch { + errorMessage = error.localizedDescription + } + + isLoading = false + } + + func loginWithApple(authorizationCode: String, identityToken: String?, email: String? = nil, name: String? = nil, userIdentifier: String? = nil) async { + isLoading = true + errorMessage = nil + + do { + let oauthService = OAuthService(authService: authService) + let token = try await oauthService.handleOAuthCallback( + authorizationCode: authorizationCode, + provider: .apple, + idToken: identityToken, + email: email, + name: name, + userIdentifier: userIdentifier + ) + + KeychainHelper.store(token: token.accessToken, forKey: "accessToken") + KeychainHelper.store(token: token.refreshToken, forKey: "refreshToken") + + let user = try await authService.getCurrentUser() + isAuthenticated = true + + let appState = getAppState() + appState.authenticate(user: user) + } catch { + errorMessage = error.localizedDescription + } + + isLoading = false + } + + func loginWithGoogle(authorizationCode: String, idToken: String?) async { + isLoading = true + errorMessage = nil + + do { + let oauthService = OAuthService(authService: authService) + let token = try await oauthService.handleOAuthCallback( + authorizationCode: authorizationCode, + provider: .google, + idToken: idToken + ) + + KeychainHelper.store(token: token.accessToken, forKey: "accessToken") + KeychainHelper.store(token: token.refreshToken, forKey: "refreshToken") + + let user = try await authService.getCurrentUser() + isAuthenticated = true + + let appState = getAppState() + appState.authenticate(user: user) + } catch { + errorMessage = error.localizedDescription + } + + isLoading = false + } + + + private func getAppState() -> AppState { + + return AppState.shared + } +} diff --git a/frontend/MusicApp/ViewModels/HomeFeedViewModel.swift b/frontend/MusicApp/ViewModels/HomeFeedViewModel.swift new file mode 100644 index 0000000..f4e7494 --- /dev/null +++ b/frontend/MusicApp/ViewModels/HomeFeedViewModel.swift @@ -0,0 +1,79 @@ +import Foundation +import Combine +import SwiftUI + +@MainActor +class HomeFeedViewModel: ObservableObject { + @Published var feedItems: [MusicItem] = [] + @Published var isLoading: Bool = false + @Published var isLoadingMore: Bool = false + @Published var errorMessage: String? + @Published var selectedItem: MusicItem? + @Published var showRatingModal: Bool = false + + private let musicService: MusicService + private var currentPage: Int = 1 + private var hasMore: Bool = true + private var isLoadingPage: Bool = false + + init(musicService: MusicService = MusicService()) { + self.musicService = musicService + } + + func loadFeed() async { + guard !isLoadingPage else { return } + + isLoading = true + isLoadingPage = true + errorMessage = nil + currentPage = 1 + hasMore = true + + do { + let result = try await musicService.getFeed(filter: "forYou", page: currentPage) + feedItems = result.items + hasMore = result.hasMore + currentPage = result.nextPage ?? currentPage + } catch { + errorMessage = error.localizedDescription + print("Feed load error: \(error.localizedDescription)") + } + + isLoading = false + isLoadingPage = false + } + + func loadMoreIfNeeded(currentItem: MusicItem?) async { + guard hasMore, !isLoadingPage, !isLoadingMore else { return } + + guard let currentItem = currentItem, + let index = feedItems.firstIndex(where: { $0.id == currentItem.id }), + index >= feedItems.count - 3 else { + return + } + + isLoadingMore = true + isLoadingPage = true + + do { + let result = try await musicService.getFeed(filter: "forYou", page: currentPage) + feedItems.append(contentsOf: result.items) + hasMore = result.hasMore + currentPage = result.nextPage ?? currentPage + } catch { + print("Load more error: \(error.localizedDescription)") + } + + isLoadingMore = false + isLoadingPage = false + } + + func selectItemForRating(_ item: MusicItem) { + selectedItem = item + showRatingModal = true + } + + func refreshFeed() async { + await loadFeed() + } +} diff --git a/frontend/MusicApp/ViewModels/NotificationViewModel.swift b/frontend/MusicApp/ViewModels/NotificationViewModel.swift new file mode 100644 index 0000000..8547d15 --- /dev/null +++ b/frontend/MusicApp/ViewModels/NotificationViewModel.swift @@ -0,0 +1,83 @@ +import Foundation +import Combine +import SwiftUI + +@MainActor +class NotificationViewModel: ObservableObject { + @Published var notifications: [AppNotification] = [] + @Published var isLoading: Bool = false + @Published var errorMessage: String? + + private let notificationService: NotificationService + + init(notificationService: NotificationService = NotificationService()) { + self.notificationService = notificationService + } + + func loadNotifications() async { + isLoading = true + errorMessage = nil + + do { + notifications = try await notificationService.getNotifications() + } catch { + errorMessage = error.localizedDescription + notifications = [] + } + + isLoading = false + } + + func markAsRead(_ notificationId: String) async { + do { + try await notificationService.markAsRead(notificationId: notificationId) + if let index = notifications.firstIndex(where: { $0.id == notificationId }) { + notifications[index] = AppNotification( + id: notifications[index].id, + userId: notifications[index].userId, + type: notifications[index].type, + title: notifications[index].title, + message: notifications[index].message, + read: true, + metadata: notifications[index].metadata, + createdAt: notifications[index].createdAt + ) + } + } catch { + errorMessage = error.localizedDescription + } + } + + func markAllAsRead() async { + do { + try await notificationService.markAllAsRead() + notifications = notifications.map { notification in + AppNotification( + id: notification.id, + userId: notification.userId, + type: notification.type, + title: notification.title, + message: notification.message, + read: true, + metadata: notification.metadata, + createdAt: notification.createdAt + ) + } + } catch { + errorMessage = error.localizedDescription + } + } + + func getNotificationColor(_ type: NotificationType) -> Color { + switch type { + case .impact: + return AppColors.notificationImpact + case .badge: + return AppColors.notificationBadge + case .social: + return AppColors.notificationSocial + case .trending: + return AppColors.notificationTrending + } + } +} diff --git a/frontend/MusicApp/ViewModels/RankingViewModel.swift b/frontend/MusicApp/ViewModels/RankingViewModel.swift new file mode 100644 index 0000000..a61fa15 --- /dev/null +++ b/frontend/MusicApp/ViewModels/RankingViewModel.swift @@ -0,0 +1,64 @@ +import Foundation +import Combine +import SwiftUI + +enum RankingType: String, CaseIterable { + case albums = "albums" + case artists = "artists" + case songs = "songs" + + var displayName: String { + switch self { + case .albums: return "Albums" + case .artists: return "Artists" + case .songs: return "Songs" + } + } +} + +struct RankingItem: Identifiable, Codable { + let id: String + let rank: Int + let title: String + let artist: String + let imageUrl: String + let rating: Double + let ratingCount: Int + let isNew: Bool + let change: Int +} + +@MainActor +class RankingViewModel: ObservableObject { + @Published var activeType: RankingType = .albums + @Published var rankings: [RankingItem] = [] + @Published var isLoading: Bool = false + @Published var errorMessage: String? + + private let rankingService: RankingService + + init(rankingService: RankingService = RankingService()) { + self.rankingService = rankingService + } + + func loadRankings() async { + isLoading = true + errorMessage = nil + + do { + rankings = try await rankingService.getRankings(type: activeType.rawValue) + } catch { + errorMessage = error.localizedDescription + rankings = [] + } + + isLoading = false + } + + func setType(_ type: RankingType) { + activeType = type + Task { + await loadRankings() + } + } +} diff --git a/frontend/MusicApp/ViewModels/RatingViewModel.swift b/frontend/MusicApp/ViewModels/RatingViewModel.swift new file mode 100644 index 0000000..cf272c5 --- /dev/null +++ b/frontend/MusicApp/ViewModels/RatingViewModel.swift @@ -0,0 +1,61 @@ +import Foundation +import Combine +import SwiftUI + +@MainActor +class RatingViewModel: ObservableObject { + @Published var rating: Int = 0 + @Published var hoverRating: Int = 0 + @Published var isSubmitting: Bool = false + @Published var errorMessage: String? + + private let ratingService: RatingService + + init(ratingService: RatingService = RatingService()) { + self.ratingService = ratingService + } + + func setRating(_ value: Int) { + rating = value + } + + func setHoverRating(_ value: Int) { + hoverRating = value + } + + func submitRating(for musicItemId: String) async -> Bool { + guard rating > 0 else { + errorMessage = "Please select a rating" + return false + } + + isSubmitting = true + errorMessage = nil + + do { + let request = CreateRatingRequest( + musicItemId: musicItemId, + rating: rating, + tags: nil + ) + + _ = try await ratingService.submitRating(request: request) + + rating = 0 + hoverRating = 0 + + isSubmitting = false + return true + } catch { + errorMessage = error.localizedDescription + isSubmitting = false + return false + } + } + + func reset() { + rating = 0 + hoverRating = 0 + errorMessage = nil + } +} diff --git a/frontend/MusicApp/ViewModels/SocialViewModel.swift b/frontend/MusicApp/ViewModels/SocialViewModel.swift new file mode 100644 index 0000000..ec8dde0 --- /dev/null +++ b/frontend/MusicApp/ViewModels/SocialViewModel.swift @@ -0,0 +1,44 @@ +import Foundation +import Combine +import SwiftUI + +@MainActor +class SocialViewModel: ObservableObject { + @Published var friends: [Friend] = [] + @Published var isLoading: Bool = false + @Published var errorMessage: String? + + private let socialService: SocialService + + init(socialService: SocialService = SocialService()) { + self.socialService = socialService + } + + func loadFriends() async { + isLoading = true + errorMessage = nil + + do { + friends = try await socialService.getFriends() + } catch { + errorMessage = error.localizedDescription + friends = [] + } + + isLoading = false + } + + func getCompatibilityColor(_ score: Int) -> Color { + if score >= 80 { + return AppColors.primary + } else if score >= 60 { + return AppColors.secondary + } else { + return AppColors.accent + } + } + + func getCompatibilityEmoji(_ score: Int) -> String { + return "" + } +} diff --git a/frontend/MusicApp/ViewModels/TasteProfileViewModel.swift b/frontend/MusicApp/ViewModels/TasteProfileViewModel.swift new file mode 100644 index 0000000..9c8a1dc --- /dev/null +++ b/frontend/MusicApp/ViewModels/TasteProfileViewModel.swift @@ -0,0 +1,78 @@ +import Foundation +import Combine +import SwiftUI + +struct GenreData: Identifiable { + let id = UUID() + let name: String + let value: Int +} + +struct DecadeData: Identifiable { + let id = UUID() + let decade: String + let value: Int +} + +struct RadarData: Identifiable { + let id = UUID() + let category: String + let value: Int +} + +@MainActor +class TasteProfileViewModel: ObservableObject { + @Published var tasteScore: Int = 0 + @Published var totalRatings: Int = 0 + @Published var influence: Int = 0 + @Published var genreData: [GenreData] = [] + @Published var decadeData: [DecadeData] = [] + @Published var radarData: [RadarData] = [] + @Published var controversyAffinity: Int = 0 + @Published var isLoading: Bool = false + @Published var errorMessage: String? + + private let profileService: ProfileService + + init(profileService: ProfileService = ProfileService()) { + self.profileService = profileService + } + + func loadProfile() async { + isLoading = true + errorMessage = nil + + do { + let profile = try await profileService.getTasteProfile() + + tasteScore = profile.tasteScore + totalRatings = profile.totalRatings + influence = profile.influence + + genreData = profile.genreAffinity.map { GenreData(name: $0.key, value: $0.value) } + + decadeData = profile.decadePreference.map { DecadeData(decade: $0.key, value: $0.value) } + + radarData = profile.attributes.map { RadarData(category: $0.key, value: $0.value) } + + controversyAffinity = profile.controversyAffinity + } catch { + errorMessage = error.localizedDescription + genreData = [] + decadeData = [] + radarData = [] + } + + isLoading = false + } +} + +struct TasteProfileResponse: Codable { + let tasteScore: Int + let totalRatings: Int + let influence: Int + let genreAffinity: [String: Int] + let decadePreference: [String: Int] + let attributes: [String: Int] + let controversyAffinity: Int +} diff --git a/frontend/MusicApp/Views/Auth/AppleSignInButton.swift b/frontend/MusicApp/Views/Auth/AppleSignInButton.swift new file mode 100644 index 0000000..1c3842c --- /dev/null +++ b/frontend/MusicApp/Views/Auth/AppleSignInButton.swift @@ -0,0 +1,28 @@ +import SwiftUI +import AuthenticationServices + +struct AppleSignInButton: View { + let onSuccess: (String, String?) -> Void + let onError: (Error) -> Void + + var body: some View { + SignInWithAppleButton( + onRequest: { request in + request.requestedScopes = [.fullName, .email] + }, + onCompletion: { result in + + } + ) + .signInWithAppleButtonStyle(.black) + .frame(height: 50) + .cornerRadius(AppStyles.cornerRadiusMedium) + } +} + +#Preview { + AppleSignInButton( + onSuccess: { _, _ in }, + onError: { _ in } + ) +} diff --git a/frontend/MusicApp/Views/Auth/GoogleSignInButton.swift b/frontend/MusicApp/Views/Auth/GoogleSignInButton.swift new file mode 100644 index 0000000..6c58397 --- /dev/null +++ b/frontend/MusicApp/Views/Auth/GoogleSignInButton.swift @@ -0,0 +1,59 @@ +import SwiftUI +#if canImport(AppAuth) +import AppAuth +#endif + +struct GoogleSignInButton: View { + let onSuccess: (String, String?) -> Void + let onError: (Error) -> Void + + var body: some View { + Button(action: { + Task { + await performGoogleSignIn() + } + }) { + HStack { + Image(systemName: "globe") + .font(.system(size: 18)) + Text("Continue with Google") + .font(.system(size: 16, weight: .medium)) + } + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(Color(red: 0.26, green: 0.52, blue: 0.96)) + .cornerRadius(AppStyles.cornerRadiusMedium) + } + } + + @MainActor + private func performGoogleSignIn() async { + + } +} + +#if canImport(AppAuth) +extension OIDAuthorizationService { + static func present(_ request: OIDAuthorizationRequest, presenting: UIViewController) async throws -> OIDAuthState { + return try await withCheckedThrowingContinuation { continuation in + let authFlow = OIDAuthState.authState(byPresenting: request, presenting: presenting) { authState, error in + if let error = error { + continuation.resume(throwing: error) + } else if let authState = authState { + continuation.resume(returning: authState) + } else { + continuation.resume(throwing: NSError(domain: "OAuth", code: -1, userInfo: [NSLocalizedDescriptionKey: "Unknown error"])) + } + } + } + } +} +#endif + +#Preview { + GoogleSignInButton( + onSuccess: { _, _ in }, + onError: { _ in } + ) +} diff --git a/frontend/MusicApp/Views/Auth/LoginView.swift b/frontend/MusicApp/Views/Auth/LoginView.swift new file mode 100644 index 0000000..0e958d3 --- /dev/null +++ b/frontend/MusicApp/Views/Auth/LoginView.swift @@ -0,0 +1,100 @@ +import SwiftUI + +struct LoginView: View { + @StateObject private var viewModel = AuthViewModel() + @ObservedObject var appState: AppState + + var body: some View { + ZStack { + AppColors.background + .ignoresSafeArea() + + VStack(spacing: 32) { + Spacer() + + ZStack { + Circle() + .fill(AppColors.primary) + .frame(width: 120, height: 120) + + Image(systemName: "music.note") + .font(.system(size: 60)) + .foregroundColor(.white) + } + .padding(.bottom, 16) + + Text("Welcome Back") + .font(.system(size: 32, weight: .bold)) + .foregroundColor(AppColors.textPrimary) + + VStack(spacing: 20) { + VStack(alignment: .leading, spacing: 8) { + Text("Username") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(AppColors.textSecondary) + + TextField("", text: $viewModel.username) + .textFieldStyle(PlainTextFieldStyle()) + .padding() + .background(AppColors.cardBackground) + .cornerRadius(AppStyles.cornerRadiusMedium) + .overlay( + RoundedRectangle(cornerRadius: AppStyles.cornerRadiusMedium) + .stroke(AppColors.border, lineWidth: 1) + ) + .foregroundColor(AppColors.textPrimary) + .autocapitalization(.none) + } + + VStack(alignment: .leading, spacing: 8) { + Text("Password") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(AppColors.textSecondary) + + SecureField("", text: $viewModel.password) + .textFieldStyle(PlainTextFieldStyle()) + .padding() + .background(AppColors.cardBackground) + .cornerRadius(AppStyles.cornerRadiusMedium) + .overlay( + RoundedRectangle(cornerRadius: AppStyles.cornerRadiusMedium) + .stroke(AppColors.border, lineWidth: 1) + ) + .foregroundColor(AppColors.textPrimary) + } + + if let error = viewModel.errorMessage { + Text(error) + .font(.system(size: 14)) + .foregroundColor(AppColors.accent) + .frame(maxWidth: .infinity, alignment: .leading) + } + + Button(action: { + Task { + await viewModel.login() + } + }) { + if viewModel.isLoading { + ProgressView() + .tint(.white) + } else { + Text("Log In") + .font(.system(size: 16, weight: .semibold)) + } + } + .gradientButton(isEnabled: !viewModel.isLoading) + .disabled(viewModel.isLoading) + } + .padding(.horizontal, AppStyles.paddingLarge) + + + Spacer() + } + } + } +} + +#Preview { + LoginView(appState: AppState()) +} diff --git a/frontend/MusicApp/Views/Auth/OAuthCallbackHandler.swift b/frontend/MusicApp/Views/Auth/OAuthCallbackHandler.swift new file mode 100644 index 0000000..78d4b7d --- /dev/null +++ b/frontend/MusicApp/Views/Auth/OAuthCallbackHandler.swift @@ -0,0 +1,80 @@ +import SwiftUI +import Combine + +struct OAuthCallbackHandler: ViewModifier { + @ObservedObject var viewModel: AuthViewModel + @State private var cancellables = Set() + + func body(content: Content) -> some View { + content + .onAppear { + + NotificationCenter.default.publisher(for: NSNotification.Name("OAuthCallback")) + .sink { notification in + guard let userInfo = notification.userInfo, + let providerString = userInfo["provider"] as? String, + let code = userInfo["code"] as? String else { + return + } + + let idToken = userInfo["idToken"] as? String + + Task { + switch providerString { + case "apple": + let email = userInfo["email"] as? String + let name = userInfo["name"] as? String + let userIdentifier = userInfo["userIdentifier"] as? String + await viewModel.loginWithApple( + authorizationCode: code, + identityToken: idToken, + email: email, + name: name, + userIdentifier: userIdentifier + ) + case "google": + let email = userInfo["email"] as? String + let name = userInfo["name"] as? String + await viewModel.loginWithGoogle( + authorizationCode: code, + idToken: idToken + ) + default: + break + } + } + } + .store(in: &cancellables) + + NotificationCenter.default.publisher(for: NSNotification.Name("AppleSignInSuccess")) + .sink { notification in + guard let userInfo = notification.userInfo, + let code = userInfo["code"] as? String else { + return + } + + let idToken = userInfo["idToken"] as? String + let email = userInfo["email"] as? String + let name = userInfo["name"] as? String + let userIdentifier = userInfo["userIdentifier"] as? String + + Task { + await viewModel.loginWithApple( + authorizationCode: code, + identityToken: idToken, + email: email, + name: name, + userIdentifier: userIdentifier + ) + } + } + .store(in: &cancellables) + } + } +} + +extension View { + func handleOAuthCallbacks(viewModel: AuthViewModel) -> some View { + modifier(OAuthCallbackHandler(viewModel: viewModel)) + } +} diff --git a/frontend/MusicApp/Views/Auth/SignupView.swift b/frontend/MusicApp/Views/Auth/SignupView.swift new file mode 100644 index 0000000..37a0337 --- /dev/null +++ b/frontend/MusicApp/Views/Auth/SignupView.swift @@ -0,0 +1,151 @@ +import SwiftUI + +struct SignupView: View { + @StateObject private var viewModel = AuthViewModel() + @ObservedObject var appState: AppState + + var body: some View { + ZStack { + AppColors.background + .ignoresSafeArea() + + ScrollView { + VStack(spacing: 32) { + + ZStack { + Circle() + .fill(AppColors.primary) + .frame(width: 120, height: 120) + + Image(systemName: "music.note") + .font(.system(size: 60)) + .foregroundColor(.white) + } + .padding(.top, 40) + + Text("Create Account") + .font(.system(size: 32, weight: .bold)) + .foregroundColor(AppColors.textPrimary) + + VStack(spacing: 20) { + VStack(alignment: .leading, spacing: 8) { + Text("Username") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(AppColors.textSecondary) + + TextField("", text: $viewModel.username) + .textFieldStyle(PlainTextFieldStyle()) + .padding() + .background(AppColors.cardBackground) + .cornerRadius(AppStyles.cornerRadiusMedium) + .overlay( + RoundedRectangle(cornerRadius: AppStyles.cornerRadiusMedium) + .stroke(AppColors.border, lineWidth: 1) + ) + .foregroundColor(AppColors.textPrimary) + .autocapitalization(.none) + + Text("3-30 characters, letters, numbers, and underscores only") + .font(.system(size: 11)) + .foregroundColor(AppColors.textSecondary) + } + + VStack(alignment: .leading, spacing: 8) { + Text("Password") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(AppColors.textSecondary) + + SecureField("", text: $viewModel.password) + .textFieldStyle(PlainTextFieldStyle()) + .padding() + .background(AppColors.cardBackground) + .cornerRadius(AppStyles.cornerRadiusMedium) + .overlay( + RoundedRectangle(cornerRadius: AppStyles.cornerRadiusMedium) + .stroke(AppColors.border, lineWidth: 1) + ) + .foregroundColor(AppColors.textPrimary) + .onChange(of: viewModel.password) { newValue in + viewModel.passwordErrors = viewModel.validatePassword(newValue) + } + + if !viewModel.passwordErrors.isEmpty { + VStack(alignment: .leading, spacing: 4) { + ForEach(viewModel.passwordErrors, id: \.self) { error in + Text("• \(error)") + .font(.system(size: 11)) + .foregroundColor(AppColors.accent) + } + } + } else if !viewModel.password.isEmpty { + Text("✓ Password meets all requirements") + .font(.system(size: 11)) + .foregroundColor(.green) + } + } + + VStack(alignment: .leading, spacing: 8) { + Text("Confirm Password") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(AppColors.textSecondary) + + SecureField("", text: $viewModel.confirmPassword) + .textFieldStyle(PlainTextFieldStyle()) + .padding() + .background(AppColors.cardBackground) + .cornerRadius(AppStyles.cornerRadiusMedium) + .overlay( + RoundedRectangle(cornerRadius: AppStyles.cornerRadiusMedium) + .stroke(AppColors.border, lineWidth: 1) + ) + .foregroundColor(AppColors.textPrimary) + + if !viewModel.confirmPassword.isEmpty { + if viewModel.password == viewModel.confirmPassword { + Text("✓ Passwords match") + .font(.system(size: 11)) + .foregroundColor(.green) + } else { + Text("Passwords do not match") + .font(.system(size: 11)) + .foregroundColor(AppColors.accent) + } + } + } + + if let error = viewModel.errorMessage { + Text(error) + .font(.system(size: 14)) + .foregroundColor(AppColors.accent) + .frame(maxWidth: .infinity, alignment: .leading) + } + + Button(action: { + Task { + await viewModel.signup() + } + }) { + if viewModel.isLoading { + ProgressView() + .tint(.white) + } else { + Text("Sign Up") + .font(.system(size: 16, weight: .semibold)) + } + } + .gradientButton(isEnabled: !viewModel.isLoading) + .disabled(viewModel.isLoading) + } + .padding(.horizontal, AppStyles.paddingLarge) + + Spacer() + .frame(height: 40) + } + } + } + } +} + +#Preview { + SignupView(appState: AppState()) +} diff --git a/frontend/MusicApp/Views/Auth/SpotifySignInButton.swift b/frontend/MusicApp/Views/Auth/SpotifySignInButton.swift new file mode 100644 index 0000000..3456c07 --- /dev/null +++ b/frontend/MusicApp/Views/Auth/SpotifySignInButton.swift @@ -0,0 +1,41 @@ +import SwiftUI +#if canImport(AppAuth) +import AppAuth +#endif + +struct SpotifySignInButton: View { + let onSuccess: (String) -> Void + let onError: (Error) -> Void + + var body: some View { + Button(action: { + Task { + await performSpotifySignIn() + } + }) { + HStack { + Image(systemName: "music.note") + .font(.system(size: 18)) + Text("Continue with Spotify") + .font(.system(size: 16, weight: .medium)) + } + .foregroundColor(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(Color(red: 0.12, green: 0.73, blue: 0.33)) + .cornerRadius(AppStyles.cornerRadiusMedium) + } + } + + @MainActor + private func performSpotifySignIn() async { + + } +} + +#Preview { + SpotifySignInButton( + onSuccess: { _ in }, + onError: { _ in } + ) +} diff --git a/frontend/MusicApp/Views/FeedCardView.swift b/frontend/MusicApp/Views/FeedCardView.swift new file mode 100644 index 0000000..55d3892 --- /dev/null +++ b/frontend/MusicApp/Views/FeedCardView.swift @@ -0,0 +1,131 @@ +import SwiftUI + +struct FeedCardView: View { + let item: MusicItem + let onRate: () -> Void + + private var iconName: String { + switch item.type { + case .album: + return "opticaldisc.fill" + case .song: + return "music.note" + case .artist: + return "person.fill" + } + } + + private var iconColor: Color { + switch item.type { + case .album: + return AppColors.primary + case .song: + return AppColors.secondary + case .artist: + return AppColors.accent + } + } + + var body: some View { + HStack(spacing: 16) { + ZStack(alignment: .topTrailing) { + RoundedRectangle(cornerRadius: AppStyles.cornerRadiusMedium) + .fill(AppColors.secondaryBackground) + .frame(width: 96, height: 96) + .overlay( + Image(systemName: iconName) + .font(.system(size: 40)) + .foregroundColor(iconColor) + ) + + if item.trending == true { + ZStack { + Circle() + .fill(AppColors.accent) + .frame(width: 24, height: 24) + + Image(systemName: "arrow.up") + .font(.system(size: 12, weight: .bold)) + .foregroundColor(.white) + } + .offset(x: 8, y: -8) + } + } + + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + Image(systemName: iconName) + .font(.system(size: 12)) + .foregroundColor(iconColor) + + Text(item.type.rawValue.capitalized) + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(iconColor) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(iconColor.opacity(0.15)) + .cornerRadius(12) + + VStack(alignment: .leading, spacing: 4) { + Text(item.title) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(AppColors.textPrimary) + .lineLimit(1) + + Text(item.artist ?? "Unknown Artist") + .font(.system(size: 14)) + .foregroundColor(AppColors.textSecondary) + .lineLimit(1) + } + + HStack(spacing: 8) { + HStack(spacing: 4) { + Image(systemName: "star.fill") + .font(.system(size: 14)) + .foregroundColor(AppColors.secondary) + + Text(String(format: "%.1f", item.rating)) + .font(.system(size: 14, weight: .medium)) + .foregroundColor(AppColors.textPrimary) + } + + Text("(\(item.ratingCount.formatted()))") + .font(.system(size: 12)) + .foregroundColor(AppColors.textSecondary) + + if let change = item.trendingChange, item.trending == true { + HStack(spacing: 4) { + Image(systemName: "arrow.up") + .font(.system(size: 10)) + .foregroundColor(AppColors.primary) + + Text("+\(change)") + .font(.system(size: 12)) + .foregroundColor(AppColors.primary) + } + } + } + + Button(action: onRate) { + HStack(spacing: 4) { + Image(systemName: "star.fill") + .font(.system(size: 14)) + + Text("Rate") + .font(.system(size: 14, weight: .medium)) + } + .foregroundColor(.white) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(AppColors.primary) + .cornerRadius(AppStyles.cornerRadiusSmall) + } + } + + Spacer() + } + .padding(AppStyles.paddingMedium) + .cardStyle() + } +} diff --git a/frontend/MusicApp/Views/HomeFeedView.swift b/frontend/MusicApp/Views/HomeFeedView.swift new file mode 100644 index 0000000..f8761a6 --- /dev/null +++ b/frontend/MusicApp/Views/HomeFeedView.swift @@ -0,0 +1,114 @@ +import SwiftUI + +struct HomeFeedView: View { + @StateObject private var viewModel = HomeFeedViewModel() + @StateObject private var ratingViewModel = RatingViewModel() + @ObservedObject var appState = AppState.shared + @State private var showProfile = false + + var body: some View { + ZStack { + AppColors.background + .ignoresSafeArea() + + VStack(spacing: 0) { + + VStack(spacing: 16) { + HStack { + Text("MusIQ") + .font(.system(size: 32, weight: .bold)) + .foregroundColor(AppColors.textPrimary) + .shadow(color: AppColors.primary.opacity(0.1), radius: 20) + + Spacer() + + Button(action: { + showProfile = true + }) { + Image(systemName: "person.circle.fill") + .font(.system(size: 24)) + .foregroundColor(AppColors.textSecondary) + .frame(width: 40, height: 40) + } + } + } + .padding(.horizontal, AppStyles.paddingMedium) + .padding(.top, AppStyles.paddingLarge) + .padding(.bottom, AppStyles.paddingMedium) + + if viewModel.isLoading { + Spacer() + ProgressView() + .tint(AppColors.primary) + Spacer() + } else if viewModel.feedItems.isEmpty { + Spacer() + VStack(spacing: 16) { + Image(systemName: "music.note.list") + .font(.system(size: 48)) + .foregroundColor(AppColors.textSecondary) + + Text("No music found") + .font(.system(size: 18, weight: .medium)) + .foregroundColor(AppColors.textSecondary) + } + Spacer() + } else { + ScrollView { + LazyVStack(spacing: 16) { + ForEach(viewModel.feedItems) { item in + FeedCardView( + item: item, + onRate: { + viewModel.selectItemForRating(item) + } + ) + .padding(.horizontal, AppStyles.paddingMedium) + .onAppear { + Task { + await viewModel.loadMoreIfNeeded(currentItem: item) + } + } + } + + if viewModel.isLoadingMore { + ProgressView() + .tint(AppColors.primary) + .padding() + } + } + .padding(.top, 8) + .padding(.bottom, 20) + } + } + } + } + .sheet(isPresented: $viewModel.showRatingModal) { + if let item = viewModel.selectedItem { + RatingModalView( + viewModel: ratingViewModel, + item: item, + onClose: { + viewModel.showRatingModal = false + ratingViewModel.reset() + }, + onSubmit: { rating in + Task { + await viewModel.refreshFeed() + } + } + ) + } + } + .sheet(isPresented: $showProfile) { + ProfileView(appState: appState) + } + .task { + await viewModel.loadFeed() + } + } +} + +#Preview { + HomeFeedView() +} diff --git a/frontend/MusicApp/Views/MainAppView.swift b/frontend/MusicApp/Views/MainAppView.swift new file mode 100644 index 0000000..f516684 --- /dev/null +++ b/frontend/MusicApp/Views/MainAppView.swift @@ -0,0 +1,18 @@ +import SwiftUI + +struct MainAppView: View { + @ObservedObject var appState: AppState + + var body: some View { + ZStack { + AppColors.background + .ignoresSafeArea() + + HomeFeedView() + } + } +} + +#Preview { + MainAppView(appState: AppState()) +} diff --git a/frontend/MusicApp/Views/OnboardingView.swift b/frontend/MusicApp/Views/OnboardingView.swift new file mode 100644 index 0000000..c68d4ff --- /dev/null +++ b/frontend/MusicApp/Views/OnboardingView.swift @@ -0,0 +1,185 @@ +import SwiftUI + +struct OnboardingSlide { + let icon: String + let title: String + let description: String + let color: Color +} + +struct OnboardingView: View { + @State private var currentSlide = 0 + @State private var slideOffset: CGFloat = 0 + + let slides: [OnboardingSlide] = [ + OnboardingSlide( + icon: "music.note", + title: "Rate Your Music", + description: "Share your honest opinions on albums, songs, and artists", + color: AppColors.primary + ), + OnboardingSlide( + icon: "sparkles", + title: "Discover New Sounds", + description: "Explore trending music and personalized recommendations", + color: AppColors.secondary + ), + OnboardingSlide( + icon: "person.2.fill", + title: "Influence the Charts", + description: "Your ratings shape the global music rankings", + color: AppColors.accent + ), + OnboardingSlide( + icon: "trophy.fill", + title: "Build Your Profile", + description: "Create your unique taste DNA and compare with friends", + color: AppColors.secondary + ) + ] + + let onComplete: () -> Void + + var body: some View { + ZStack { + AppColors.background + .ignoresSafeArea() + + VStack(spacing: 0) { + + HStack { + Spacer() + Button("Skip") { + onComplete() + } + .foregroundColor(AppColors.textSecondary) + .font(.system(size: 14)) + .padding(.trailing, AppStyles.paddingMedium) + .padding(.top, AppStyles.paddingMedium) + } + + Spacer() + + TabView(selection: $currentSlide) { + ForEach(0.. Void + let onSubmit: (Int) -> Void + + private func iconName(for type: MusicItemType) -> String { + switch type { + case .album: + return "opticaldisc.fill" + case .song: + return "music.note" + case .artist: + return "person.fill" + } + } + + private func iconColor(for type: MusicItemType) -> Color { + switch type { + case .album: + return AppColors.primary + case .song: + return AppColors.secondary + case .artist: + return AppColors.accent + } + } + + var body: some View { + ZStack { + Color.black.opacity(0.8) + .ignoresSafeArea() + .onTapGesture { + onClose() + } + + VStack(spacing: 0) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Rate this music") + .font(.system(size: 20, weight: .bold)) + .foregroundColor(AppColors.textPrimary) + + Text("Share your honest opinion") + .font(.system(size: 14)) + .foregroundColor(AppColors.textSecondary) + } + + Spacer() + + Button(action: onClose) { + Image(systemName: "xmark") + .font(.system(size: 18)) + .foregroundColor(AppColors.textSecondary) + .frame(width: 32, height: 32) + } + } + .padding(AppStyles.paddingLarge) + + if let item = item { + HStack(spacing: 16) { + RoundedRectangle(cornerRadius: AppStyles.cornerRadiusMedium) + .fill(AppColors.secondaryBackground) + .frame(width: 80, height: 80) + .overlay( + Image(systemName: iconName(for: item.type)) + .font(.system(size: 32)) + .foregroundColor(iconColor(for: item.type)) + ) + + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + HStack(spacing: 6) { + Image(systemName: iconName(for: item.type)) + .font(.system(size: 12)) + .foregroundColor(iconColor(for: item.type)) + + Text(item.type.rawValue.capitalized) + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(iconColor(for: item.type)) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(iconColor(for: item.type).opacity(0.15)) + .cornerRadius(12) + + Spacer() + } + + Text(item.title) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(AppColors.textPrimary) + .lineLimit(1) + + Text(item.artist ?? "Unknown Artist") + .font(.system(size: 14)) + .foregroundColor(AppColors.textSecondary) + .lineLimit(1) + + HStack(spacing: 4) { + Image(systemName: "star.fill") + .font(.system(size: 12)) + .foregroundColor(AppColors.secondary) + + Text(String(format: "%.1f", item.rating)) + .font(.system(size: 12, weight: .medium)) + .foregroundColor(AppColors.textPrimary) + + Text("current") + .font(.system(size: 12)) + .foregroundColor(AppColors.textSecondary) + } + } + + Spacer() + } + .padding(AppStyles.paddingMedium) + .background(AppColors.background) + .cornerRadius(AppStyles.cornerRadiusMedium) + .padding(.horizontal, AppStyles.paddingLarge) + + VStack(spacing: 16) { + Text("Your rating") + .font(.system(size: 14)) + .foregroundColor(AppColors.textPrimary) + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(spacing: 8) { + ForEach(1...10, id: \.self) { star in + Button(action: { + viewModel.setRating(star) + }) { + Image(systemName: "star.fill") + .font(.system(size: 28)) + .foregroundColor( + star <= (viewModel.hoverRating > 0 ? viewModel.hoverRating : viewModel.rating) ? + AppColors.secondary : + AppColors.secondaryBackground + ) + } + .onHover { hovering in + if hovering { + viewModel.setHoverRating(star) + } else { + viewModel.setHoverRating(0) + } + } + } + } + + if viewModel.rating > 0 { + Text("\(viewModel.rating)/10") + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(AppColors.primary) + .transition(.opacity) + } + } + .padding(.horizontal, AppStyles.paddingLarge) + .padding(.top, AppStyles.paddingLarge) + + Button(action: { + Task { + if await viewModel.submitRating(for: item.id) { + onSubmit(viewModel.rating) + onClose() + } + } + }) { + Text("Submit Rating") + .font(.system(size: 16, weight: .semibold)) + .frame(maxWidth: .infinity) + .padding(.vertical, AppStyles.paddingMedium) + } + .gradientButton(isEnabled: viewModel.rating > 0) + .padding(.horizontal, AppStyles.paddingLarge) + .padding(.top, AppStyles.paddingLarge) + + if viewModel.rating > 0 { + Text("Your rating will update the global score") + .font(.system(size: 12)) + .foregroundColor(AppColors.textSecondary) + .padding(.top, 8) + .transition(.opacity) + } + } + } + .background(AppColors.cardBackground) + .cornerRadius(AppStyles.cornerRadiusLarge) + .padding(.horizontal, AppStyles.paddingLarge) + .shadow(color: .black.opacity(0.3), radius: 20, x: 0, y: 10) + } + } +} + diff --git a/frontend/MusicApp/Views/SplashScreenView.swift b/frontend/MusicApp/Views/SplashScreenView.swift new file mode 100644 index 0000000..1d9bb5f --- /dev/null +++ b/frontend/MusicApp/Views/SplashScreenView.swift @@ -0,0 +1,127 @@ +import SwiftUI + +struct SplashScreenView: View { + @State private var showContent = false + @State private var logoScale: CGFloat = 0 + @State private var logoRotation: Double = -180 + @State private var glowScale: CGFloat = 1 + @State private var glowOpacity: Double = 0.5 + @State private var sparkle1Rotation: Double = 0 + @State private var sparkle2Rotation: Double = 0 + @State private var loadingDots: [Bool] = [false, false, false] + + let onComplete: () -> Void + + var body: some View { + ZStack { + AppGradients.background + .ignoresSafeArea() + + VStack(spacing: 32) { + ZStack { + Circle() + .fill(AppColors.primary.opacity(glowOpacity * 0.3)) + .frame(width: 140, height: 140) + .blur(radius: 20) + .scaleEffect(glowScale) + + ZStack { + Circle() + .fill(AppColors.primary) + .frame(width: 96, height: 96) + + Image(systemName: "music.note") + .font(.system(size: 48)) + .foregroundColor(.white) + } + .scaleEffect(logoScale) + .rotationEffect(.degrees(logoRotation)) + + Image(systemName: "sparkles") + .font(.system(size: 24)) + .foregroundColor(AppColors.primary) + .offset(x: -48, y: -48) + .rotationEffect(.degrees(sparkle1Rotation)) + .scaleEffect(glowScale) + + Image(systemName: "sparkles") + .font(.system(size: 20)) + .foregroundColor(AppColors.secondary) + .offset(x: 48, y: 48) + .rotationEffect(.degrees(sparkle2Rotation)) + .scaleEffect(glowScale * 1.2) + } + .padding(.top, 100) + + VStack(spacing: 8) { + Text("MusIQ") + .font(.system(size: 48, weight: .bold)) + .foregroundColor(AppColors.textPrimary) + .shadow(color: AppColors.primary.opacity(0.2), radius: 20) + .opacity(showContent ? 1 : 0) + + Text("Rate. Discover. Influence.") + .font(.system(size: 16)) + .foregroundColor(AppColors.textSecondary) + .opacity(showContent ? 1 : 0) + } + .padding(.top, 32) + + HStack(spacing: 8) { + ForEach(0..<3) { index in + Circle() + .fill( + loadingDots[index] ? + AppColors.primary : + AppColors.secondary.opacity(0.5) + ) + .frame(width: 8, height: 8) + } + } + .padding(.top, 48) + .opacity(showContent ? 1 : 0) + } + } + .onAppear { + withAnimation(.spring(response: 0.8, dampingFraction: 0.6)) { + logoScale = 1 + logoRotation = 0 + } + + withAnimation(.easeInOut(duration: 0.3).delay(0.3)) { + showContent = true + } + + withAnimation(.easeInOut(duration: 2).repeatForever(autoreverses: true)) { + glowScale = 1.2 + glowOpacity = 0.8 + } + + withAnimation(.linear(duration: 3).repeatForever(autoreverses: false)) { + sparkle1Rotation = 360 + } + + withAnimation(.linear(duration: 4).repeatForever(autoreverses: false)) { + sparkle2Rotation = -360 + } + + for i in 0..<3 { + withAnimation( + .easeInOut(duration: 1) + .repeatForever(autoreverses: true) + .delay(Double(i) * 0.2) + ) { + loadingDots[i] = true + } + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + onComplete() + } + } + } +} + +#Preview { + SplashScreenView(onComplete: {}) +} diff --git a/frontend/MusicAppTests/MusicAppTests.swift b/frontend/MusicAppTests/MusicAppTests.swift new file mode 100644 index 0000000..1622811 --- /dev/null +++ b/frontend/MusicAppTests/MusicAppTests.swift @@ -0,0 +1,17 @@ + + + + + + + +import Testing +@testable import MusicApp + +struct MusicAppTests { + + @Test func example() async throws { + + } + +} diff --git a/frontend/MusicAppUITests/MusicAppUITests.swift b/frontend/MusicAppUITests/MusicAppUITests.swift new file mode 100644 index 0000000..2a26e01 --- /dev/null +++ b/frontend/MusicAppUITests/MusicAppUITests.swift @@ -0,0 +1,41 @@ + + + + + + + +import XCTest + +final class MusicAppUITests: XCTestCase { + + override func setUpWithError() throws { + + + + continueAfterFailure = false + + + } + + override func tearDownWithError() throws { + + } + + @MainActor + func testExample() throws { + + let app = XCUIApplication() + app.launch() + + + } + + @MainActor + func testLaunchPerformance() throws { + + measure(metrics: [XCTApplicationLaunchMetric()]) { + XCUIApplication().launch() + } + } +} diff --git a/frontend/MusicAppUITests/MusicAppUITestsLaunchTests.swift b/frontend/MusicAppUITests/MusicAppUITestsLaunchTests.swift new file mode 100644 index 0000000..599598e --- /dev/null +++ b/frontend/MusicAppUITests/MusicAppUITestsLaunchTests.swift @@ -0,0 +1,33 @@ + + + + + + + +import XCTest + +final class MusicAppUITestsLaunchTests: XCTestCase { + + override class var runsForEachTargetApplicationUIConfiguration: Bool { + true + } + + override func setUpWithError() throws { + continueAfterFailure = false + } + + @MainActor + func testLaunch() throws { + let app = XCUIApplication() + app.launch() + + + + + let attachment = XCTAttachment(screenshot: app.screenshot()) + attachment.name = "Launch Screen" + attachment.lifetime = .keepAlways + add(attachment) + } +} diff --git a/webapp/app/auth/page.tsx b/webapp/app/auth/page.tsx index 6d88e18..ee9cbc9 100644 --- a/webapp/app/auth/page.tsx +++ b/webapp/app/auth/page.tsx @@ -13,10 +13,13 @@ export default function AuthPage() { const router = useRouter(); useEffect(() => { - if (apiClient.isAuthenticated()) { - router.push('/'); - return; - } + const checkAuth = async () => { + if (await apiClient.isAuthenticated()) { + router.push('/'); + return; + } + }; + checkAuth(); }, [router]); const [password, setPassword] = useState(''); diff --git a/webapp/app/page.tsx b/webapp/app/page.tsx index 0f2f7b1..09bdf63 100644 --- a/webapp/app/page.tsx +++ b/webapp/app/page.tsx @@ -12,7 +12,7 @@ export default function Home() { useEffect(() => { const checkAuth = async () => { - if (apiClient.isAuthenticated()) { + if (await apiClient.isAuthenticated()) { try { const response = await apiClient.getCurrentUser(); if (response.success && response.data) { diff --git a/webapp/lib/api.ts b/webapp/lib/api.ts index 07fad3a..88b7a38 100644 --- a/webapp/lib/api.ts +++ b/webapp/lib/api.ts @@ -28,17 +28,74 @@ export interface ApiResponse { class ApiClient { private baseUrl: string; + private readonly ENCRYPTION_PREFIX = 'enc:'; constructor() { this.baseUrl = API_BASE_URL; } + private getEncryptionKey(): string { + if (typeof window === 'undefined') return ''; + + let key = sessionStorage.getItem('encryptionKey'); + if (!key) { + key = Array.from(crypto.getRandomValues(new Uint8Array(32))) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); + sessionStorage.setItem('encryptionKey', key); + } + return key; + } + + private encrypt(text: string): string { + if (typeof window === 'undefined') return text; + + try { + const key = this.getEncryptionKey(); + const keyBytes = new TextEncoder().encode(key); + const textBytes = new TextEncoder().encode(text); + + const encrypted = textBytes.map((byte, i) => + byte ^ keyBytes[i % keyBytes.length] + ); + + return this.ENCRYPTION_PREFIX + btoa(String.fromCharCode(...encrypted)); + } catch (error) { + console.error('Encryption error:', error); + return text; + } + } + + private decrypt(encryptedText: string): string { + if (typeof window === 'undefined') return encryptedText; + + if (!encryptedText.startsWith(this.ENCRYPTION_PREFIX)) { + return encryptedText; + } + + try { + const encrypted = encryptedText.slice(this.ENCRYPTION_PREFIX.length); + const encryptedBytes = Uint8Array.from(atob(encrypted), c => c.charCodeAt(0)); + const key = this.getEncryptionKey(); + const keyBytes = new TextEncoder().encode(key); + + const decrypted = encryptedBytes.map((byte, i) => + byte ^ keyBytes[i % keyBytes.length] + ); + + return new TextDecoder().decode(decrypted); + } catch (error) { + console.error('Decryption error:', error); + return encryptedText; + } + } + private async request( endpoint: string, options: RequestInit = {} ): Promise> { const url = `${this.baseUrl}${endpoint}`; - const token = this.getAccessToken(); + const token = await this.getAccessToken(); const headers: HeadersInit = { 'Content-Type': 'application/json', @@ -134,21 +191,27 @@ class ApiClient { setTokens(tokens: AuthTokens): void { if (typeof window !== 'undefined') { - localStorage.setItem('accessToken', tokens.accessToken); - localStorage.setItem('refreshToken', tokens.refreshToken); + const encryptedAccess = this.encrypt(tokens.accessToken); + const encryptedRefresh = this.encrypt(tokens.refreshToken); + localStorage.setItem('accessToken', encryptedAccess); + localStorage.setItem('refreshToken', encryptedRefresh); } } - getAccessToken(): string | null { + async getAccessToken(): Promise { if (typeof window !== 'undefined') { - return localStorage.getItem('accessToken'); + const encrypted = localStorage.getItem('accessToken'); + if (!encrypted) return null; + return this.decrypt(encrypted); } return null; } getRefreshToken(): string | null { if (typeof window !== 'undefined') { - return localStorage.getItem('refreshToken'); + const encrypted = localStorage.getItem('refreshToken'); + if (!encrypted) return null; + return this.decrypt(encrypted); } return null; } @@ -160,8 +223,9 @@ class ApiClient { } } - isAuthenticated(): boolean { - return !!this.getAccessToken(); + async isAuthenticated(): Promise { + const token = await this.getAccessToken(); + return !!token; } }