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;
}
}