diff --git a/README.md b/README.md
index 0e1211217..1317a557d 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,15 @@
-# [your app name here]
+# Sona
CodePath WEB103 Final Project
-Designed and developed by: [your names here]
+Designed and developed by:
+
+- Gustave Ntwali
+- Abdul Khan
+- Carolina Herrera
+- Chau Cao
+- Dev Goswami
+- Armin Erika Polanco
π Link to deployed app:
@@ -10,39 +17,86 @@ Designed and developed by: [your names here]
### Description and Purpose
-[text goes here]
+**Sona** is a full-stack web app where users can browse artist profiles, follow their favorite artists, view upcoming concert dates, and shop artist merch all in one place.
+
+Music fans often have to juggle multiple platforms to keep up with an artist. From social media for updates, ticketing sites for shows, separate stores for merch. **Sona** exists to bring that experience together in one app, making it easier for fans to stay connected and for artists to reach their community directly.
### Inspiration
-[text goes here]
+This project builds on ideas from several of our previous coursework projects. Multiple members of our group have worked on music-themed projects earlier in the course that explored artist/music reference content, vinyl and merch customization, and community-oriented spaces. **Sona** brings these ideas together into a single hub where fans can follow artists, engage with a community built around music, and shop artist merch. Furthermore, we take inspiration from existing platforms, such as Ticketmaster and Bandcamp, to build from.
## Tech Stack
-Frontend:
-
-Backend:
+- Frontend: React (Vite), React Router
+- Backend: Express, Node.js, PostgreSQL
## Features
-### [Name of Feature 1]
+### β
Artist Directory
+
+- Browse all artists with genre, bio, and profile image. Search bar w/ Filter by genre.
+
+- 
+
+### β
Follow Artists
+
+- Users can follow/unfollow artists to build a personalized list of who they're keeping up with, with an optional "notify me on release" toggle.
+
+- 
+
+### β
Artist Posts
+
+Artist Admins can post updates like new merch drops, show announcements, news, etc that will be visible to their followers.
+
+- 
+
+### β
Artist Quick-View Panel
+
+- Slide-out modal showing artist details and discography without navigating away from the current page.
+
+- 
+
+### Merch Shop with Filtering
+
+- Browse merch (vinyl, CD, apparel) filtered by artist, genre, or format.
+
+- [gif goes here]
+
+### Order System
+
+- Add merch to a cart and place an order; view order history.
+
+- [gif goes here]
+
+### Concert Listings
+
+- View upcoming shows per artist, filterable by city and/or date.
+
+- [gif goes here]
+
+### Following Feed
+
+- A personalized feed combining posts, upcoming shows, and merch drops from every artist a fan follows.
+
+- [gif goes here]
-[short description goes here]
+### (Stretch) Spotify Connect
-[gif goes here]
+- Link a Spotify account to import playlists and generate personalized recommendations.
-### [Name of Feature 2]
+- [gif goes here]
-[short description goes here]
+### (Stretch) Sona Mix
-[gif goes here]
+- Auto-generate a custom playlist from the artists you follow, featured right on your profile.
-### [Name of Feature 3]
+- [gif goes here]
-[short description goes here]
+### (Stretch) Real Payment Integration
-[gif goes here]
+- Real checkout system built on top of the existing order system.
-### [ADDITIONAL FEATURES GO HERE - ADD ALL FEATURES HERE IN THE FORMAT ABOVE; you will check these off and add gifs as you complete them]
+- [gif goes here]
## Installation Instructions
diff --git a/milestones/milestone1.md b/milestones/milestone1.md
index 52b9b0038..6770d0ca8 100644
--- a/milestones/milestone1.md
+++ b/milestones/milestone1.md
@@ -6,27 +6,27 @@ This document should be completed and submitted during **Unit 5** of this course
This unit, be sure to complete all tasks listed below. To complete a task, place an `x` between the brackets.
-- [ ] Read and understand all required features
- - [ ] Understand you **must** implement **all** baseline features and **two** custom features
-- [ ] In `readme.md`: update app name to your app's name
-- [ ] In `readme.md`: add all group members' names
-- [ ] In `readme.md`: complete the **Description and Purpose** section
-- [ ] In `readme.md`: complete the **Inspiration** section
-- [ ] In `readme.md`: list a name and description for all features (minimum 6 for full points) you intend to include in your app (in future units, you will check off features as you complete them and add GIFs demonstrating the features)
-- [ ] In `planning/user_stories.md`: add all user stories (minimum 10 for full points)
-- [ ] In `planning/user_stories.md`: use 1-3 unique user roles in your user stories
-- [ ] In this document, complete all thre questions in the **Reflection** section below
+- [x] Read and understand all required features
+ - [x] Understand you **must** implement **all** baseline features and **two** custom features
+- [x] In `readme.md`: update app name to your app's name
+- [x] In `readme.md`: add all group members' names
+- [x] In `readme.md`: complete the **Description and Purpose** section
+- [x] In `readme.md`: complete the **Inspiration** section
+- [x] In `readme.md`: list a name and description for all features (minimum 6 for full points) you intend to include in your app (in future units, you will check off features as you complete them and add GIFs demonstrating the features)
+- [x] In `planning/user_stories.md`: add all user stories (minimum 10 for full points)
+- [x] In `planning/user_stories.md`: use 1-3 unique user roles in your user stories
+- [x] In this document, complete all thre questions in the **Reflection** section below
## Reflection
### 1. What went well during this unit?
-[ππΎππΎππΎ your answer here]
+We agreed on a concept quickly.
### 2. What were some challenges your group faced in this unit?
-[ππΎππΎππΎ your answer here]
+Finalizing details across all members can be challenging but we will hopefully implement a better system going forward.
### 3. What additional support will you need in upcoming units as you continue to work on your final project?
-[ππΎππΎππΎ your answer here]
+General guidance on keeping the team coordinated and on track as we move into the more technical milestones.
diff --git a/milestones/milestone2.md b/milestones/milestone2.md
index e3178cd81..0c1dc8fcf 100644
--- a/milestones/milestone2.md
+++ b/milestones/milestone2.md
@@ -6,24 +6,24 @@ This document should be completed and submitted during **Unit 6** of this course
This unit, be sure to complete all tasks listed below. To complete a task, place an `x` between the brackets.
-- [ ] In `planning/wireframes.md`: add wireframes for at least three pages in your web app.
- - [ ] Include a list of pages in your app
-- [ ] In `planning/entity_relationship_diagram.md`: add the entity relationship diagram you developed for your database.
- - [ ] Your entity relationship diagram should include the tables in your database.
-- [ ] Prepare your three-minute pitch presentation, to be presented during Unit 7 (the next unit).
- - [ ] You do **not** need to submit any materials in advance of your pitch.
-- [ ] In this document, complete all three questions in the **Reflection** section below
+- [x] In `planning/wireframes.md`: add wireframes for at least three pages in your web app.
+ - [x] Include a list of pages in your app
+- [x] In `planning/entity_relationship_diagram.md`: add the entity relationship diagram you developed for your database.
+ - [x] Your entity relationship diagram should include the tables in your database.
+- [x] Prepare your three-minute pitch presentation, to be presented during Unit 7 (the next unit).
+ - [x] You do **not** need to submit any materials in advance of your pitch.
+- [x] In this document, complete all three questions in the **Reflection** section below
## Reflection
### 1. What went well during this unit?
-[ππΎππΎππΎ your answer here]
+Our team successesfuly collaborated and made decisions as a group.
### 2. What were some challenges your group faced in this unit?
-[ππΎππΎππΎ your answer here]
+It can be difficult to find a time where everyone is available.
### 3. What additional support will you need in upcoming units as you continue to work on your final project?
-[ππΎππΎππΎ your answer here]
+Delegating the coding portion between all group members moving forward.
diff --git a/milestones/milestone3.md b/milestones/milestone3.md
index b45ad8561..8fc7692eb 100644
--- a/milestones/milestone3.md
+++ b/milestones/milestone3.md
@@ -8,44 +8,45 @@ This unit, be sure to complete all tasks listed below. To complete a task, place
You will need to reference the GitHub Project Management guide in the course portal for more information about how to complete each of these steps.
-- [ ] In your repo, create a project board.
- - *Please be sure to share your project board with the grading team's GitHub **codepathreview**. This is separate from your repository's sharing settings.*
-- [ ] In your repo, create at least 5 issues from the features on your feature list.
+- [x] In your repo, create a project board.
+ - _Please be sure to share your project board with the grading team's GitHub **codepathreview**. This is separate from your repository's sharing settings._
+- [x] In your repo, create at least 5 issues from the features on your feature list.
- List the title of each issue you created:
- 1.
- 2.
- 3.
- 4.
- 5.
-- [ ] In your repo, update the status of issues in your project board.
-- [ ] In your repo, create a GitHub Milestone for each final project unit, corresponding to each of the 5 milestones in your `milestones/` directory.
+ > 1. Set up Express + PostgreSQL project structure & schema
+ > 2. Artist CRUD + Directory + Detail Page
+ > 3. Follow / Unfollow
+ > 4. Artist Posts
+ > 5. Quick View Slide-Out Panel
+- [x] In your repo, update the status of issues in your project board.
+- [x] In your repo, create a GitHub Milestone for each final project unit, corresponding to each of the 5 milestones in your `milestones/` directory.
- List the name of each milestone you created:
- 1.
- 2.
- 3.
- - [ ] Set the completion percentage of each milestone. The GitHub Milestone for this unit (Milestone 3 - Unit 7) should be 100% completed when you submit for full points.
-- [ ] In `readme.md`, check off the features you have completed in this unit by adding a β
emoji in front of the feature's name.
- - [ ] Under each feature you have completed, include a GIF showing feature functionality.
-- [ ] In this documents, complete all five questions in the **Reflection** section below.
+ > 1. Milestone 3
+ > 2. Milestone 4
+ > 3. Milestone 5
+ > 4. Stretch Goals
+ - [x] Set the completion percentage of each milestone. The GitHub Milestone for this unit (Milestone 3 - Unit 7) should be 100% completed when you submit for full points.
+- [x] In `readme.md`, check off the features you have completed in this unit by adding a β
emoji in front of the feature's name.
+ - [x] Under each feature you have completed, include a GIF showing feature functionality.
+- [x] In this documents, complete all five questions in the **Reflection** section below.
## Reflection
### 1. What went well during this unit?
-[ππΎππΎππΎ your answer here]
+> Learned alot about project management and organization for coding projects and had a succesful first sprint.
### 2. What were some challenges your group faced in this unit?
-[ππΎππΎππΎ your answer here]
+> Coordinating around issue dependencies took some time to get right with planning and thinking ahead.
### Did you finish all of your tasks in your sprint plan for this week? If you did not finish all of the planned tasks, how would you prioritize the remaining tasks on your list?
-[ππΎππΎππΎ your answer here]
+> Yes. We set up our GitHub Project board, issues, and milestones, and got real backend/database work started, including both of our required one-to-one relationships and our full-CRUD entity.
### Which features and user stories would you consider βat riskβ? How will you change your plan if those items remain βat riskβ?
-[ππΎππΎππΎ your answer here]
+> Nothing is currently at risk, everything planned for this unit was finished. Going forward, the next sprints will have more complicated features so hoping the best for that.
### 5. What additional support will you need in upcoming units as you continue to work on your final project?
-[ππΎππΎππΎ your answer here]
+> Guidance on authentication once we reach that unit β several features (Artist Admin permissions, a real "who's logged in" concept) are currently using temporary placeholders until real login is covered in class.
diff --git a/milestones/progress_report_3.md b/milestones/progress_report_3.md
new file mode 100644
index 000000000..e302db14e
--- /dev/null
+++ b/milestones/progress_report_3.md
@@ -0,0 +1,69 @@
+# Milestone 3 Progress Report
+
+## GitHub Project Setup
+
+Project board created:
+
+> YES
+
+Project board shared with codepathreview:
+
+> YES
+
+## Issues Created
+
+List the title of each issue you created, one per line:
+
+> - Issue 0: Local Dev Environment Setup
+> - Issue 1: Set up Express + PostgreSQL project structure & schema
+> - Issue 2: Artist CRUD + Directory + Detail Page
+> - Issue 3: Follow / Unfollow
+> - Issue 4: Artist Posts
+> - Issue 5: Quick View Slide-Out Panel
+
+## GitHub Milestones Created
+
+List the name of each GitHub Milestone you created, one per line:
+
+> - Milestone 3
+> - Milestone 4
+> - Milestone 5
+> - Stretch Goals
+
+## Milestone 3 Completion
+
+Replace the blank before the percent sign with a number from 0 to 100.
+
+> Completion percentage for Milestone 3: 100%
+
+List each issue you completed this unit and the main file or folder where that work lives, one per line:
+
+- Example: User login - server/routes/auth.js
+
+> - Project Setup + Schema
+> - server/config/
+> - Artist CRUD + Directory + Detail Page
+> - server/routes/artists.js
+> - client/src/pages/Directory.jsx
+> - client/src/pages/ArtistDetail.jsx
+> - Follow / Unfollow
+> - server/routes/follows.jsx
+> - client/src/components/FollowButton.jsx
+> - Artist Posts
+> - server/routes/posts.js
+> - client/src/pages/PostCreateForm.jsx
+> - client/src/pages/PostEditForm.jsx
+> - client/src/components/ArtistPost.jsx
+> - Quick View Slide-Out Panel
+> - client/src/components/QuickViewPanel.jsx
+
+## Features Completed This Unit
+
+List each feature you completed and checked off in readme.md this unit, one per line:
+
+- Example: Search events by category
+
+> - Artist Directory (browse, search, genre filter)
+> - Follow / Unfollow artists
+> - Artist Posts
+> - Quick View Slide-Out Panel
diff --git a/planning/entity_relationship_diagram.md b/planning/entity_relationship_diagram.md
index 12c25f62c..3bdbc4964 100644
--- a/planning/entity_relationship_diagram.md
+++ b/planning/entity_relationship_diagram.md
@@ -4,14 +4,16 @@ Reference the Creating an Entity Relationship Diagram final project guide in the
## Create the List of Tables
-[ππΎππΎππΎ List each table in your diagram]
+1. users
+2. artists
+3. admin
+4. profile
+5. follows
+6. concerts
+7. merch
+8. albums
+9. purchases
## Add the Entity Relationship Diagram
-[ππΎππΎππΎ Include an image or images of the diagram below. You may also wish to use the following markdown syntax to outline each table, as per your preference.]
-
-| Column Name | Type | Description |
-|-------------|------|-------------|
-| id | integer | primary key |
-| name | text | name of the shoe model |
-| ... | ... | ... |
+
diff --git a/planning/user_stories.md b/planning/user_stories.md
index 1e55ecbcd..d009a6103 100644
--- a/planning/user_stories.md
+++ b/planning/user_stories.md
@@ -4,10 +4,25 @@ Reference the Writing User Stories final project guide in the course portal for
## Outline User Roles
-[ππΎππΎππΎ Include at least at least 1, but no more than 3, user roles.]
+- Fan
+- Artist Admin
## Draft User Stories
-[ππΎππΎππΎ Include at least at least 10 user stories in this format:]
+Include at least at least 10 user stories in this format:
-1. As a [user role], I want to [what], so that [why].
+_As a [user role], I want to [what], so that [why]._
+
+1. As a Fan, I want to create an account and log in, so that I can save my preferences and see order history.
+2. As a Fan, I want to browse a list of artists, so that I can discover new music.
+3. As a Fan, I want to view an individual artist's profile page, so that I can see their bio, socials, and Spotify link.
+4. As a Fan, I want to follow an artist, so that I get updates when they release new merch or announce shows.
+5. As a Fan, I want to unfollow an artist, so that I can curate who I see updates from.
+6. As a Fan, I want to browse merch filtered by artist or format, so that I can find what I want quickly.
+7. As a Fan, I want to add items to a cart and place an order, so that I can purchase merch.
+8. As a Fan, I want to view my order history, so that I can track past purchases.
+9. As a Fan, I want to see upcoming concerts for an artist I follow, filtered by city, so that I can find shows near me.
+10. As a Fan, I want to see a quick-view panel when I click an artist, so that I can preview their info without leaving the page.
+11. As an Artist Admin, I want to create/edit my artist profile, so that fans have accurate bio and social info.
+12. As an Artist Admin, I want to add new merch items to my store, so that fans can purchase them.
+13. As an Artist Admin, I want to post upcoming concert dates, so that fans know where to see me live.
diff --git a/planning/wireframes.md b/planning/wireframes.md
index fbcd15a0c..5793d52f5 100644
--- a/planning/wireframes.md
+++ b/planning/wireframes.md
@@ -4,18 +4,38 @@ Reference the Creating an Entity Relationship Diagram final project guide in the
## List of Pages
-[ππΎππΎππΎ List the pages you expect to have in your app, with a β next to pages you have wireframed]
+- Login
+- Edit User
+- Edit Artist_User
+- Artist Directory β
+- Artist Directory Side-Panel β
+- Artist Detailed β
+- Merch Shop β
+- User's Profile > Following β
+- User's Profile > Order History β
+- Checkout Cart
+- Error Default
-## Wireframe 1: [page title]
+## Wireframe 1: Artist Directory
-[ππΎππΎππΎ include wireframe 1]
+
-## Wireframe 2: [page title]
+## Wireframe 2: Artist Directory Side-Panel
-[ππΎππΎππΎ include wireframe 2]
+
-## Wireframe 3: [page title]
+## Wireframe 3: Artist Detailed
-[ππΎππΎππΎ include wireframe 3]
+
-[ππΎππΎππΎ include more wireframes as desired]
+## Wireframe 4: Merch Shop
+
+
+
+## Wireframe 5: User's Profile > Following Tab
+
+
+
+## Wireframe 6: User's Profile > Order History Tab
+
+
diff --git a/sona-app/.gitignore b/sona-app/.gitignore
new file mode 100644
index 000000000..91ffe1b28
--- /dev/null
+++ b/sona-app/.gitignore
@@ -0,0 +1,27 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+
+# Credentials
+.env
\ No newline at end of file
diff --git a/sona-app/client/.oxlintrc.json b/sona-app/client/.oxlintrc.json
new file mode 100644
index 000000000..125507823
--- /dev/null
+++ b/sona-app/client/.oxlintrc.json
@@ -0,0 +1,8 @@
+{
+ "$schema": "./node_modules/oxlint/configuration_schema.json",
+ "plugins": ["react", "oxc"],
+ "rules": {
+ "react/rules-of-hooks": "error",
+ "react/only-export-components": ["warn", { "allowConstantExport": true }]
+ }
+}
diff --git a/sona-app/client/index.html b/sona-app/client/index.html
new file mode 100644
index 000000000..a9e01e819
--- /dev/null
+++ b/sona-app/client/index.html
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+ Sona
+
+
+
+
+
+
diff --git a/sona-app/client/package-lock.json b/sona-app/client/package-lock.json
new file mode 100644
index 000000000..e8ba721f0
--- /dev/null
+++ b/sona-app/client/package-lock.json
@@ -0,0 +1,1390 @@
+{
+ "name": "client",
+ "version": "0.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "client",
+ "version": "0.0.0",
+ "dependencies": {
+ "react": "^19.2.7",
+ "react-dom": "^19.2.7",
+ "react-router-dom": "^7.18.1"
+ },
+ "devDependencies": {
+ "@types/react": "^19.2.17",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^6.0.3",
+ "oxlint": "^1.71.0",
+ "vite": "^8.1.1"
+ }
+ },
+ "node_modules/@emnapi/core": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
+ "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.2",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
+ "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
+ "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
+ "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@tybys/wasm-util": "^0.10.3"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "peerDependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.139.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
+ "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "node_modules/@oxlint/binding-android-arm-eabi": {
+ "version": "1.74.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.74.0.tgz",
+ "integrity": "sha512-+gHd12muVI9ZLBaWLPkHt3Fj7jihFjgQ1MGtBaRL8vWrWrI0P7dLUty/cHrHS0oqPYIRgQUJsPu2CExQuMcwNw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-android-arm64": {
+ "version": "1.74.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.74.0.tgz",
+ "integrity": "sha512-xjKdoMB+H+RCOByv/7l7nfIGW9mlOisqYdcyC75UqYuQecLpReAeEYUf2CNeDEI3KtmUgxpRw/+c63y4AeF/Bw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-darwin-arm64": {
+ "version": "1.74.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.74.0.tgz",
+ "integrity": "sha512-iUK7wvc6sejMKsC+Pt67mntoF5weFcyEunhZfLJceU6gL419mexz5wBkSx/EnkFBExMLNtOi9fnDSc5xfK0IzQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-darwin-x64": {
+ "version": "1.74.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.74.0.tgz",
+ "integrity": "sha512-ggKc/tn5SJ1u2yG2izC6VKODfYKV8MQ2AicJlNzOjuyrC29udvOef6/JzK2r32xqCnBDLFouR1VCkjzEI0/N9Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-freebsd-x64": {
+ "version": "1.74.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.74.0.tgz",
+ "integrity": "sha512-u++dH/43jy9hTLbneaWlS0gla/Bp1JdwJ2zgevCl8nDFUh6qRCGMxcL0f0lb7By3A9p/LfFr+7cG4HU1hG856g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-arm-gnueabihf": {
+ "version": "1.74.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.74.0.tgz",
+ "integrity": "sha512-Sj1zmtFDVTPeIbIz4ZfcXAbFHqCmKCXdCUlAJzvTF7I20NTH1RDpoF2PhkqNODutJzVhJYmm3oz0GwgY+tvE2g==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-arm-musleabihf": {
+ "version": "1.74.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.74.0.tgz",
+ "integrity": "sha512-//PKyQb/tQXcHArx2f7z+oVI/eMS2Jpv+edNuAtOrgIhWdGcpHxogveAxzmF2rpH1AIHp4Hq04RF/rgJdiICnQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-arm64-gnu": {
+ "version": "1.74.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.74.0.tgz",
+ "integrity": "sha512-/k1Me+aX2tjuH10K62mLS0y8cLkJBHX6Ce0xPK+eWeel4bSdEGZ8dv4+hYMzg0GrSmjwy4yAYsDPeEeKBft/2w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-arm64-musl": {
+ "version": "1.74.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.74.0.tgz",
+ "integrity": "sha512-3tFSjBxc5D8/zvjEuLvOqcA8ZXKD0+6NuaVO/edeamNc49MoAsbfaC9s1UiwODwgF6slGaF8yJA2TPkukd77tg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-ppc64-gnu": {
+ "version": "1.74.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.74.0.tgz",
+ "integrity": "sha512-9QggtPkSPXOCTu8Szis7auOK/sC7KdQaN+/TujP7YVVhzCAOhgdRfgv8uEz0r2tk5xdgus5rLYUrCDoZNtiRUw==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-riscv64-gnu": {
+ "version": "1.74.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.74.0.tgz",
+ "integrity": "sha512-VM5VPUJ4DJIWiK+AZn8FScUqMr6OFrCAYybMYjEEi7W13ParI64MByiXTkKMqZpBmvQ9zxl9Ebq2VUOiZRJYUg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-riscv64-musl": {
+ "version": "1.74.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.74.0.tgz",
+ "integrity": "sha512-SaDY1gh9rOA592J54g+gu5hkOFFQBZsMmIYHs+NRHG+Uq0OxtuuCXMWQ3vu1830Eugv5uMXyjG+bv2Z9y4IXjw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-s390x-gnu": {
+ "version": "1.74.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.74.0.tgz",
+ "integrity": "sha512-ZATQeHZCyr6MbDveg0obD5sxLHFOghtOdC5jwVwYlvFWqtFOxctgFEG6Ef/64hYvZrWyhyCckB10AelqLopeDA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-x64-gnu": {
+ "version": "1.74.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.74.0.tgz",
+ "integrity": "sha512-+aIvJyrdeD7LwCQ2WYLMUWNmnbeDRSPb40aBYtPjD9+PTqUwgJnk+HK5yLfSMeqXrMrDhE9uTmtt2y50tvjhHw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-linux-x64-musl": {
+ "version": "1.74.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.74.0.tgz",
+ "integrity": "sha512-XyktaR8lhK2qWiCK0Tk8oYD+/cgn+oHA6ddRnxSSXUKkkojkV78CmShZUxQF+yrBFs0SuW+JBOPG6hecyc/iZg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-openharmony-arm64": {
+ "version": "1.74.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.74.0.tgz",
+ "integrity": "sha512-mzbjrPl4neaVUiJ1fUiEUxTGaSZBoiKtaoB6jmIpz9S+VOA2vDYmJpihQ82w6178V5jxziclTg8Cgj5yF6tTDg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-win32-arm64-msvc": {
+ "version": "1.74.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.74.0.tgz",
+ "integrity": "sha512-vUAe9okpS2Oa5+lX67lqHMuNUvfkleRKwrUDJ/WJBsgmddvZ1mrsh2HVmuFDRzqFELhaJhFaCNOuR6a7L3rtIA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-win32-ia32-msvc": {
+ "version": "1.74.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.74.0.tgz",
+ "integrity": "sha512-yyXXJyYYSXL4I8K8jAWjJs+J3fa9gH2JmEbo4f5adm+1tNC9itseicBNuwK7BDHvqQ5J534s+yDULu89vYL2ZQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxlint/binding-win32-x64-msvc": {
+ "version": "1.74.0",
+ "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.74.0.tgz",
+ "integrity": "sha512-VTC9IYTIMrVUk/i6Ms1ohzzDKZFkWn0KU2OBbPBzgmVZ2V30165T/zK4LztTr0Xgp9fZ1qQZ1rsZAu/rEmySlA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
+ "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz",
+ "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz",
+ "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz",
+ "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz",
+ "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz",
+ "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz",
+ "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz",
+ "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz",
+ "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz",
+ "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz",
+ "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz",
+ "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz",
+ "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "1.11.1",
+ "@emnapi/runtime": "1.11.1",
+ "@napi-rs/wasm-runtime": "^1.1.6"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
+ "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz",
+ "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@tybys/wasm-util": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
+ "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@types/react": {
+ "version": "19.2.17",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
+ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.2.3",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
+ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.2.0"
+ }
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz",
+ "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rolldown/pluginutils": "^1.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
+ "babel-plugin-react-compiler": "^1.0.0",
+ "vite": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@rolldown/plugin-babel": {
+ "optional": true
+ },
+ "babel-plugin-react-compiler": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/cookie": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
+ "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.32.0",
+ "lightningcss-darwin-arm64": "1.32.0",
+ "lightningcss-darwin-x64": "1.32.0",
+ "lightningcss-freebsd-x64": "1.32.0",
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
+ "lightningcss-linux-arm64-gnu": "1.32.0",
+ "lightningcss-linux-arm64-musl": "1.32.0",
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0",
+ "lightningcss-win32-arm64-msvc": "1.32.0",
+ "lightningcss-win32-x64-msvc": "1.32.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.16",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
+ "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/oxlint": {
+ "version": "1.74.0",
+ "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.74.0.tgz",
+ "integrity": "sha512-odGl2s2x5IOJoj3A0v1k0PGBXVFBZeZ2+AK/+K2MJur7Ghi3bkyX5NuLUWHKqa4js1wjep3hJeuTQJOlr+4+dA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "oxlint": "bin/oxlint"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ },
+ "optionalDependencies": {
+ "@oxlint/binding-android-arm-eabi": "1.74.0",
+ "@oxlint/binding-android-arm64": "1.74.0",
+ "@oxlint/binding-darwin-arm64": "1.74.0",
+ "@oxlint/binding-darwin-x64": "1.74.0",
+ "@oxlint/binding-freebsd-x64": "1.74.0",
+ "@oxlint/binding-linux-arm-gnueabihf": "1.74.0",
+ "@oxlint/binding-linux-arm-musleabihf": "1.74.0",
+ "@oxlint/binding-linux-arm64-gnu": "1.74.0",
+ "@oxlint/binding-linux-arm64-musl": "1.74.0",
+ "@oxlint/binding-linux-ppc64-gnu": "1.74.0",
+ "@oxlint/binding-linux-riscv64-gnu": "1.74.0",
+ "@oxlint/binding-linux-riscv64-musl": "1.74.0",
+ "@oxlint/binding-linux-s390x-gnu": "1.74.0",
+ "@oxlint/binding-linux-x64-gnu": "1.74.0",
+ "@oxlint/binding-linux-x64-musl": "1.74.0",
+ "@oxlint/binding-openharmony-arm64": "1.74.0",
+ "@oxlint/binding-win32-arm64-msvc": "1.74.0",
+ "@oxlint/binding-win32-ia32-msvc": "1.74.0",
+ "@oxlint/binding-win32-x64-msvc": "1.74.0"
+ },
+ "peerDependencies": {
+ "oxlint-tsgolint": ">=0.24.0",
+ "vite-plus": "*"
+ },
+ "peerDependenciesMeta": {
+ "oxlint-tsgolint": {
+ "optional": true
+ },
+ "vite-plus": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.19",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz",
+ "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.12",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.2.7",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
+ "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.7",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
+ "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.7"
+ }
+ },
+ "node_modules/react-router": {
+ "version": "7.18.1",
+ "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.1.tgz",
+ "integrity": "sha512-GDLgg3i3uM0aeJO3Fm+TCS+sDQ7gu12T6x0qdTEzcwqEfleci7JwugVNIF3U//0FWKnJT7ptG+20B2jfDqnZAg==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "^1.0.1",
+ "set-cookie-parser": "^2.6.0"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ },
+ "peerDependenciesMeta": {
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/react-router-dom": {
+ "version": "7.18.1",
+ "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.1.tgz",
+ "integrity": "sha512-KaZh+X/6UtEp28x51AUYZDMg9NGoz2ja3dNHa+ta/tk40vCzKhQ/RypCWBMLbmDr6//E24Vv5uPsrqXFozdkAg==",
+ "license": "MIT",
+ "dependencies": {
+ "react-router": "7.18.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ },
+ "peerDependencies": {
+ "react": ">=18",
+ "react-dom": ">=18"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
+ "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.139.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm64": "1.1.5",
+ "@rolldown/binding-darwin-arm64": "1.1.5",
+ "@rolldown/binding-darwin-x64": "1.1.5",
+ "@rolldown/binding-freebsd-x64": "1.1.5",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.1.5",
+ "@rolldown/binding-linux-arm64-gnu": "1.1.5",
+ "@rolldown/binding-linux-arm64-musl": "1.1.5",
+ "@rolldown/binding-linux-ppc64-gnu": "1.1.5",
+ "@rolldown/binding-linux-s390x-gnu": "1.1.5",
+ "@rolldown/binding-linux-x64-gnu": "1.1.5",
+ "@rolldown/binding-linux-x64-musl": "1.1.5",
+ "@rolldown/binding-openharmony-arm64": "1.1.5",
+ "@rolldown/binding-wasm32-wasi": "1.1.5",
+ "@rolldown/binding-win32-arm64-msvc": "1.1.5",
+ "@rolldown/binding-win32-x64-msvc": "1.1.5"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT"
+ },
+ "node_modules/set-cookie-parser": {
+ "version": "2.7.2",
+ "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
+ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
+ "license": "MIT"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD",
+ "optional": true
+ },
+ "node_modules/vite": {
+ "version": "8.1.5",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
+ "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.32.0",
+ "picomatch": "^4.0.5",
+ "postcss": "^8.5.17",
+ "rolldown": "~1.1.5",
+ "tinyglobby": "^0.2.17"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.3.0",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ }
+ }
+}
diff --git a/sona-app/client/package.json b/sona-app/client/package.json
new file mode 100644
index 000000000..555ec8c9c
--- /dev/null
+++ b/sona-app/client/package.json
@@ -0,0 +1,24 @@
+{
+ "name": "sona-client",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "lint": "oxlint",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "react": "^19.2.7",
+ "react-dom": "^19.2.7",
+ "react-router-dom": "^7.18.1"
+ },
+ "devDependencies": {
+ "@types/react": "^19.2.17",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^6.0.3",
+ "oxlint": "^1.71.0",
+ "vite": "^8.1.1"
+ }
+}
diff --git a/sona-app/client/public/favicon-16x16.png b/sona-app/client/public/favicon-16x16.png
new file mode 100644
index 000000000..81ad1921f
Binary files /dev/null and b/sona-app/client/public/favicon-16x16.png differ
diff --git a/sona-app/client/public/favicon-180x180.png b/sona-app/client/public/favicon-180x180.png
new file mode 100644
index 000000000..323e70182
Binary files /dev/null and b/sona-app/client/public/favicon-180x180.png differ
diff --git a/sona-app/client/public/favicon-192x192.png b/sona-app/client/public/favicon-192x192.png
new file mode 100644
index 000000000..1dd406fd3
Binary files /dev/null and b/sona-app/client/public/favicon-192x192.png differ
diff --git a/sona-app/client/public/favicon-32x32.png b/sona-app/client/public/favicon-32x32.png
new file mode 100644
index 000000000..796068642
Binary files /dev/null and b/sona-app/client/public/favicon-32x32.png differ
diff --git a/sona-app/client/public/favicon-48x48.png b/sona-app/client/public/favicon-48x48.png
new file mode 100644
index 000000000..adaf0e0bc
Binary files /dev/null and b/sona-app/client/public/favicon-48x48.png differ
diff --git a/sona-app/client/public/favicon-512x512.png b/sona-app/client/public/favicon-512x512.png
new file mode 100644
index 000000000..ff801115e
Binary files /dev/null and b/sona-app/client/public/favicon-512x512.png differ
diff --git a/sona-app/client/public/favicon.ico b/sona-app/client/public/favicon.ico
new file mode 100644
index 000000000..947f57d79
Binary files /dev/null and b/sona-app/client/public/favicon.ico differ
diff --git a/sona-app/client/public/favicon.svg b/sona-app/client/public/favicon.svg
new file mode 100644
index 000000000..6f9ae428d
--- /dev/null
+++ b/sona-app/client/public/favicon.svg
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ S
+
\ No newline at end of file
diff --git a/sona-app/client/src/App.jsx b/sona-app/client/src/App.jsx
new file mode 100644
index 000000000..91e080cf9
--- /dev/null
+++ b/sona-app/client/src/App.jsx
@@ -0,0 +1,30 @@
+import { Link, Outlet } from "react-router-dom";
+import logo from "./assets/sona-logo-tagline.svg";
+
+function App() {
+ return (
+ <>
+
+
+
+
+
+
+ Profile
+ Merch
+ π
+
+
+
+
+
+
+ >
+ );
+}
+
+export default App;
diff --git a/sona-app/client/src/api.js b/sona-app/client/src/api.js
new file mode 100644
index 000000000..8426cf351
--- /dev/null
+++ b/sona-app/client/src/api.js
@@ -0,0 +1,110 @@
+const BASE = import.meta.env.VITE_API_URL || "http://localhost:3001";
+
+async function request(path, options) {
+ const res = await fetch(`${BASE}/api${path}`, {
+ headers: { "Content-Type": "application/json" },
+ ...options,
+ });
+ if (!res.ok) throw new Error(`Request failed: ${res.status}`);
+ return res.status === 204 ? null : res.json();
+}
+
+export const getArtists = ({ q = "", genre = "" } = {}) => {
+ const params = new URLSearchParams();
+ if (q) params.set("q", q);
+ if (genre) params.set("genre", genre);
+ const qs = params.toString();
+ return request(`/artists${qs ? `?${qs}` : ""}`);
+};
+
+export const getArtist = (id) => request(`/artists/${id}`);
+
+export const updateArtist = (id, data) =>
+ request(`/artists/${id}`, { method: "PATCH", body: JSON.stringify(data) });
+
+export const deleteArtist = (id, userId) =>
+ request(`/artists/${id}`, {
+ method: "DELETE",
+ body: JSON.stringify({ user_id: userId }),
+ });
+
+export const getAdminOf = (userId) => request(`/users/${userId}/admin-of`);
+
+export const getFollowing = (userId) => request(`/users/${userId}/following`);
+
+export const followArtist = (userId, artistId, notify) =>
+ request(`/follows`, {
+ method: "POST",
+ body: JSON.stringify({
+ user_id: userId,
+ artist_id: artistId,
+ notify_on_release: notify,
+ }),
+ });
+
+export const unfollowArtist = (userId, artistId) =>
+ request(`/follows/${artistId}?user_id=${userId}`, { method: "DELETE" });
+
+export const getPostsByArtist = (artistId) =>
+ request(`/artists/${artistId}/posts`);
+
+export const getPost = (postId) => request(`/posts/${postId}`);
+
+export const createPost = (userId, artistId, content) => {
+ return request(`/artists/${artistId}/posts`, {
+ method: "POST",
+ body: JSON.stringify({
+ user_id: userId,
+ artist_id: artistId,
+ content: content,
+ }),
+ });
+};
+
+export const updatePost = (postId, userId, artistId, content) => {
+ return request(`/posts/${postId}`, {
+ method: "PATCH",
+ body: JSON.stringify({
+ user_id: userId,
+ artist_id: artistId,
+ content: content,
+ }),
+ });
+};
+
+export const deletePost = (postId, userId, artistId) => {
+ return request(`/posts/${postId}`, {
+ method: "DELETE",
+ body: JSON.stringify({
+ user_id: userId,
+ artist_id: artistId,
+ }),
+ });
+};
+
+export const getMerch = ({ artist_id = "", type = "", sort = "" } = {}) => {
+ const params = new URLSearchParams();
+ if (artist_id) params.set("artist_id", artist_id);
+ if (type) params.set("type", type);
+ if (sort) params.set("sort", sort);
+ const qs = params.toString();
+ return request(`/merch${qs ? `?${qs}` : ""}`);
+};
+
+export const getArtistMerch = (artistId) =>
+ request(`/artists/${artistId}/merch`);
+
+export const createMerch = (artistId, data) =>
+ request(`/artists/${artistId}/merch`, {
+ method: "POST",
+ body: JSON.stringify(data),
+ });
+
+export const updateMerch = (id, data) =>
+ request(`/merch/${id}`, { method: "PATCH", body: JSON.stringify(data) });
+
+export const deleteMerch = (id, userId) =>
+ request(`/merch/${id}`, {
+ method: "DELETE",
+ body: JSON.stringify({ user_id: userId }),
+ });
diff --git a/sona-app/client/src/assets/sona-logo-tagline.png b/sona-app/client/src/assets/sona-logo-tagline.png
new file mode 100644
index 000000000..8e0ff943b
Binary files /dev/null and b/sona-app/client/src/assets/sona-logo-tagline.png differ
diff --git a/sona-app/client/src/assets/sona-logo-tagline.svg b/sona-app/client/src/assets/sona-logo-tagline.svg
new file mode 100644
index 000000000..2d41dcfa7
--- /dev/null
+++ b/sona-app/client/src/assets/sona-logo-tagline.svg
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ S
+
+ Sona
+ ARTIST + FAN HUB
+
\ No newline at end of file
diff --git a/sona-app/client/src/components/ArtistCard.jsx b/sona-app/client/src/components/ArtistCard.jsx
new file mode 100644
index 000000000..46ee562fb
--- /dev/null
+++ b/sona-app/client/src/components/ArtistCard.jsx
@@ -0,0 +1,31 @@
+import { Link } from "react-router-dom";
+import FollowButton from "./FollowButton.jsx";
+
+export default function ArtistCard({
+ artist,
+ onQuickView,
+ initialFollowing = false,
+ initialNotify = false,
+}) {
+ return (
+
+
+
+
+
+
{artist.name}
+ {artist.genre &&
{artist.genre} }
+
+
+
+ Quick View
+
+
+
+
+ );
+}
diff --git a/sona-app/client/src/components/ArtistPost.jsx b/sona-app/client/src/components/ArtistPost.jsx
new file mode 100644
index 000000000..6c9edbce5
--- /dev/null
+++ b/sona-app/client/src/components/ArtistPost.jsx
@@ -0,0 +1,42 @@
+import { Link, useNavigate } from "react-router-dom";
+import currentUser from "../currentUser.js";
+import { deletePost } from "../api.js";
+
+export default function ArtistPost({ postDetails, isAdmin }) {
+ const pubDate = new Date(postDetails.posted_on);
+ const options = { year: "numeric", month: "long", day: "numeric" };
+ const formattedDate = new Intl.DateTimeFormat("en-US", options).format(
+ pubDate,
+ );
+ const navigate = useNavigate();
+
+ async function handleDelete() {
+ if (!confirm(`Delete post?`)) return;
+ await deletePost(postDetails.post_id, currentUser.id, postDetails.artist_id);
+ navigate(0);
+ }
+
+ return (
+
+
{formattedDate}
+
{postDetails.content}
+ {isAdmin && (
+
+
+ Edit
+
+
+ Delete
+
+
+ )}
+
+ );
+}
diff --git a/sona-app/client/src/components/CreateMerchForm.jsx b/sona-app/client/src/components/CreateMerchForm.jsx
new file mode 100644
index 000000000..033992d07
--- /dev/null
+++ b/sona-app/client/src/components/CreateMerchForm.jsx
@@ -0,0 +1,68 @@
+import { useState } from "react";
+import { createMerch } from "../api.js";
+import currentUser from "../currentUser.js";
+
+export default function CreateMerchForm({ artistId, onCreated }) {
+ const [form, setForm] = useState({ name: "", type: "", price: "", stock: "", photo: "" });
+ const [submitted, setSubmitted] = useState(false);
+ const [error, setError] = useState("");
+
+ async function handleSubmit(event) {
+ event.preventDefault();
+ setError("");
+ try {
+ const created = await createMerch(artistId, {
+ ...form,
+ price: Number(form.price),
+ stock: Number(form.stock) || 0,
+ user_id: currentUser.id,
+ });
+ setSubmitted(true);
+ onCreated(created);
+ } catch (err) {
+ setError("Failed to create merch.");
+ }
+ }
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/sona-app/client/src/components/EditMerchForm.jsx b/sona-app/client/src/components/EditMerchForm.jsx
new file mode 100644
index 000000000..86b170220
--- /dev/null
+++ b/sona-app/client/src/components/EditMerchForm.jsx
@@ -0,0 +1,55 @@
+import { useState } from "react";
+import { updateMerch } from "../api.js";
+import currentUser from "../currentUser.js";
+
+export default function EditMerchForm({ merch, onSaved, onCancel }) {
+ const [form, setForm] = useState({
+ name: merch.name,
+ price: merch.price,
+ stock: merch.stock,
+ photo: merch.photo || "",
+ });
+
+ async function handleSubmit(event) {
+ event.preventDefault();
+ const updated = await updateMerch(merch.id, {
+ ...form,
+ price: Number(form.price),
+ stock: Number(form.stock),
+ user_id: currentUser.id,
+ });
+ onSaved(updated);
+ }
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/sona-app/client/src/components/FollowButton.jsx b/sona-app/client/src/components/FollowButton.jsx
new file mode 100644
index 000000000..e58ca165f
--- /dev/null
+++ b/sona-app/client/src/components/FollowButton.jsx
@@ -0,0 +1,62 @@
+import { useState } from "react";
+import currentUser from "../currentUser.js";
+import { followArtist, unfollowArtist } from "../api.js";
+
+export default function FollowButton({
+ artistId,
+ initialFollowing,
+ initialNotify,
+}) {
+ const [following, setFollowing] = useState(initialFollowing);
+ const [notify, setNotify] = useState(initialNotify);
+ const [toast, setToast] = useState("");
+
+ function showToast(message) {
+ setToast(message);
+ setTimeout(() => setToast(""), 2000);
+ }
+
+ async function toggleFollow() {
+ try {
+ if (following) {
+ await unfollowArtist(currentUser.id, artistId);
+ setFollowing(false);
+ setNotify(false);
+ showToast("Unfollowed");
+ } else {
+ await followArtist(currentUser.id, artistId, notify);
+ setFollowing(true);
+ showToast("Following");
+ }
+ } catch {
+ showToast("Something went wrong");
+ }
+ }
+
+ async function toggleNotify(event) {
+ const next = event.target.checked;
+ setNotify(next);
+ if (following) await followArtist(currentUser.id, artistId, next);
+ }
+
+ return (
+ <>
+
+ {following ? "Unfollow" : "Follow"}
+
+
+ {following && (
+
+
+ Notify me on release
+
+ )}
+
+ {toast && {toast} }
+ >
+ );
+}
diff --git a/sona-app/client/src/components/MerchCard.jsx b/sona-app/client/src/components/MerchCard.jsx
new file mode 100644
index 000000000..8c6d8ff7c
--- /dev/null
+++ b/sona-app/client/src/components/MerchCard.jsx
@@ -0,0 +1,53 @@
+import { useState } from "react";
+import { deleteMerch } from "../api.js";
+import currentUser from "../currentUser.js";
+import EditMerchForm from "./EditMerchForm.jsx";
+
+export default function MerchCard({ merch, isAdmin, onUpdated, onDeleted }) {
+ const [editing, setEditing] = useState(false);
+ const [deleted, setDeleted] = useState(false);
+
+ async function handleDelete() {
+ if (!confirm(`Delete ${merch.name}?`)) return;
+ await deleteMerch(merch.id, currentUser.id);
+ setDeleted(true);
+ onDeleted(merch.id);
+ }
+
+ if (editing) {
+ return (
+
+ {
+ setEditing(false);
+ onUpdated(updated);
+ }}
+ onCancel={() => setEditing(false)}
+ />
+
+ );
+ }
+
+ return (
+
+
+
+
{merch.name}
+
{merch.type}
+
+
+ ${Number(merch.price).toFixed(2)}
+
+
+ {isAdmin && (
+
+ setEditing(true)}>Edit
+
+ {deleted ? "Deleted" : "Delete"}
+
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/sona-app/client/src/components/QuickViewPanel.css b/sona-app/client/src/components/QuickViewPanel.css
new file mode 100644
index 000000000..9654ec231
--- /dev/null
+++ b/sona-app/client/src/components/QuickViewPanel.css
@@ -0,0 +1,104 @@
+.qv-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100vw;
+ height: 100vh;
+ background: rgba(0, 0, 0, 0.5);
+ z-index: 100;
+}
+
+.qv-panel {
+ position: fixed;
+ top: 0;
+ right: 0;
+ width: 480px;
+ height: 100vh;
+ background: #ffffff;
+ z-index: 101;
+ overflow-y: auto;
+ padding: 24px;
+ box-shadow: -4px 0 24px rgba(0, 0, 0, 0.15);
+ animation: slideIn 0.25s ease-out;
+}
+
+@keyframes slideIn {
+ from { transform: translateX(100%); }
+ to { transform: translateX(0); }
+}
+
+.qv-close {
+ position: absolute;
+ top: 16px;
+ right: 16px;
+ background: none;
+ border: none;
+ font-size: 20px;
+ cursor: pointer;
+ color: #555;
+}
+
+.qv-image {
+ width: 100%;
+ height: 200px;
+ object-fit: cover;
+ border-radius: 8px;
+ margin-bottom: 16px;
+}
+
+.qv-name {
+ font-size: 22px;
+ font-weight: 700;
+ margin: 0 0 8px 0;
+ color: #222;
+}
+
+.qv-genre {
+ display: inline-block;
+ background: #eeeeee;
+ border-radius: 999px;
+ padding: 4px 12px;
+ font-size: 12px;
+ color: #555;
+ margin-bottom: 16px;
+}
+
+.qv-bio {
+ font-size: 14px;
+ color: #444;
+ line-height: 1.6;
+ margin-bottom: 20px;
+}
+
+.qv-socials {
+ display: flex;
+ gap: 12px;
+ margin-bottom: 20px;
+}
+.qv-socials a {
+ font-size: 13px;
+ color: #333;
+ text-decoration: underline;
+}
+
+.qv-spotify {
+ border: none;
+ border-radius: 8px;
+ margin-bottom: 20px;
+ display: block;
+}
+
+.qv-full-profile {
+ display: block;
+ text-align: center;
+ background: #222;
+ color: #fff;
+ padding: 12px;
+ border-radius: 8px;
+ text-decoration: none;
+ font-size: 14px;
+ font-weight: 600;
+}
+.qv-full-profile:hover {
+ background: #444;
+}
\ No newline at end of file
diff --git a/sona-app/client/src/components/QuickViewPanel.jsx b/sona-app/client/src/components/QuickViewPanel.jsx
new file mode 100644
index 000000000..dafd74fbe
--- /dev/null
+++ b/sona-app/client/src/components/QuickViewPanel.jsx
@@ -0,0 +1,59 @@
+import "./QuickViewPanel.css";
+
+export default function QuickViewPanel({ artist, onClose }) {
+ if (!artist) return null;
+
+ return (
+ <>
+
+
+
+ β
+
+
+
+
{artist.name}
+
{artist.genre}
+
{artist.description}
+
+
+
+ {artist.spotify && (
+
+ )}
+
+
+ View Full Profile
+
+
+ >
+ );
+}
diff --git a/sona-app/client/src/components/Toast.jsx b/sona-app/client/src/components/Toast.jsx
new file mode 100644
index 000000000..05d2447ce
--- /dev/null
+++ b/sona-app/client/src/components/Toast.jsx
@@ -0,0 +1,6 @@
+
+
+export default function Toast({ message }) {
+ if (!message) return null;
+ return {message}
;
+}
\ No newline at end of file
diff --git a/sona-app/client/src/currentUser.js b/sona-app/client/src/currentUser.js
new file mode 100644
index 000000000..2fd83fcba
--- /dev/null
+++ b/sona-app/client/src/currentUser.js
@@ -0,0 +1,7 @@
+// Temporary placeholder for logged-in user, until real auth (Issue 16) exists.
+// id: 1 = testfan (fan) β id: 2 = testadmin (artist, manages Artist 1 only)
+// Swap between the two to test both fan and artist views locally.
+
+const currentUser = { id: 1 };
+
+export default currentUser;
diff --git a/sona-app/client/src/index.css b/sona-app/client/src/index.css
new file mode 100644
index 000000000..52269c209
--- /dev/null
+++ b/sona-app/client/src/index.css
@@ -0,0 +1,388 @@
+:root {
+ --text: #444;
+ --text-h: #111;
+ --bg: #fff;
+ --border: #d9d9d9;
+ --bar: #2b2b2b;
+ --pill: #e9e9ec;
+ --muted: #f0f0f0;
+ --content-width: 1180px;
+
+ font:
+ 16px/1.45 system-ui,
+ "Segoe UI",
+ Roboto,
+ sans-serif;
+ color: var(--text);
+ background: var(--bg);
+ -webkit-font-smoothing: antialiased;
+}
+
+* {
+ box-sizing: border-box;
+}
+body {
+ margin: 0;
+}
+h1,
+h2,
+h3 {
+ color: var(--text-h);
+}
+a {
+ color: inherit;
+ text-decoration: none;
+}
+
+/* Nav */
+.nav {
+ background: var(--bar);
+ color: #fff;
+}
+.brand {
+ display: flex;
+ align-items: center;
+}
+.brand-logo {
+ height: 80px;
+ width: auto;
+}
+.nav-inner {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ max-width: var(--content-width);
+ margin: 0 auto;
+ padding: 14px 32px;
+}
+.nav-right {
+ font-size: 24px;
+ display: flex;
+ align-items: center;
+ gap: 24px;
+}
+.cart {
+ font-size: 24px;
+ padding-bottom: 8px;
+ opacity: 0.7;
+ cursor: pointer;
+}
+
+.container {
+ max-width: var(--content-width);
+ margin: 0 auto;
+ padding: 24px 32px;
+}
+h1 {
+ font-size: 40px;
+ margin: 8px 0 20px;
+}
+
+/* Directory filters */
+.filters {
+ display: flex;
+ gap: 16px;
+ margin-bottom: 28px;
+}
+.filters input,
+.filters select {
+ padding: 12px 14px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--bg);
+ color: var(--text-h);
+ font-size: 15px;
+}
+.filters input {
+ flex: 1;
+}
+.filters select {
+ min-width: 200px;
+}
+
+/* Grid + card */
+.grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
+ gap: 24px;
+}
+.card {
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ overflow: hidden;
+ padding-bottom: 14px;
+}
+.card-photo {
+ width: 100%;
+ aspect-ratio: 4 / 3;
+ object-fit: cover;
+ display: block;
+ background: var(--muted);
+}
+
+.name-bar {
+ display: inline-block;
+ background: var(--bar);
+ color: #fff;
+ font-size: 14px;
+ padding: 4px 12px;
+ margin: 14px 14px 8px;
+}
+.genre-tag {
+ display: inline-block;
+ background: var(--pill);
+ color: #333;
+ font-size: 12px;
+ padding: 3px 10px;
+ border-radius: 12px;
+ margin: 0 14px;
+}
+
+.card-actions {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 10px;
+ margin: 14px 14px 0;
+}
+
+/* Buttons */
+.btn,
+.btn-outline,
+.btn-follow,
+.btn-unfollow,
+.btn-danger,
+.admin-controls button,
+.edit-form button,
+.edit-post button {
+ padding: 8px 18px;
+ border-radius: 8px;
+ font-size: 13px;
+ cursor: pointer;
+ border: 1px solid var(--bar);
+ transition:
+ background 0.15s ease,
+ color 0.15s ease;
+}
+.btn {
+ background: var(--bar);
+ color: #fff;
+}
+.btn-outline {
+ background: transparent;
+ color: var(--text-h);
+}
+.btn-outline:hover {
+ background: var(--bar);
+ color: #fff;
+}
+.btn-outline:disabled {
+ opacity: 0.55;
+ cursor: not-allowed;
+}
+.btn-follow {
+ min-width: 96px;
+ text-align: center;
+ background: transparent;
+ color: var(--text-h);
+}
+.btn-follow:hover {
+ background: #1a7a3c;
+ border-color: #1a7a3c;
+ color: #fff;
+}
+.btn-unfollow {
+ min-width: 96px;
+ text-align: center;
+ background: var(--bar);
+ color: #fff;
+}
+.btn-unfollow:hover {
+ background: #c0392b;
+ border-color: #c0392b;
+ color: #fff;
+}
+.btn-danger {
+ background: transparent;
+ color: #c0392b;
+ border-color: #c0392b;
+}
+
+/* Detail page */
+.detail-hero {
+ background: var(--muted);
+ border-radius: 6px;
+ padding: 24px;
+ text-align: center;
+}
+.detail-hero-img {
+ max-height: 320px;
+ max-width: 100%;
+ object-fit: contain;
+ border-radius: 6px;
+}
+
+.detail-titlebar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ border-bottom: 1px solid var(--border);
+ padding: 16px 0;
+ margin-bottom: 24px;
+}
+.detail-titlebar h1 {
+ margin: 0 0 6px;
+}
+
+.admin-controls {
+ display: flex;
+ gap: 10px;
+ margin: 0 0 20px;
+}
+
+.detail-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 40px;
+}
+@media (max-width: 800px) {
+ .detail-grid {
+ grid-template-columns: 1fr;
+ }
+}
+.detail-about h2,
+.detail-side h2 {
+ margin-top: 0;
+}
+
+.social-row {
+ display: flex;
+ gap: 12px;
+ margin: 18px 0;
+}
+.social {
+ width: 34px;
+ height: 34px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--pill);
+ border-radius: 50%;
+ font-size: 12px;
+ font-weight: 600;
+ color: #333;
+}
+.spotify-embed {
+ display: block;
+ background: var(--muted);
+ border-radius: 6px;
+ padding: 28px 16px;
+ color: var(--text);
+ font-size: 14px;
+}
+.detail-side {
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ padding: 20px;
+}
+
+.merch-strip {
+ margin-top: 40px;
+ background: var(--muted);
+ border-radius: 6px;
+ padding: 20px;
+}
+.merch-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+.placeholder {
+ color: #999;
+ font-style: italic;
+}
+
+.edit-form {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ max-width: 460px;
+ margin-bottom: 20px;
+}
+.edit-form input,
+.edit-form textarea {
+ padding: 10px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ font: inherit;
+}
+
+.titlebar-actions {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+}
+.titlebar-actions .admin-controls {
+ margin: 0;
+}
+
+.notify {
+ flex-basis: 100%;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 13px;
+ color: var(--text);
+}
+
+.tabs {
+ display: flex;
+ gap: 40px;
+ border-bottom: 1px solid var(--border);
+ margin-bottom: 24px;
+}
+.tab {
+ padding: 8px 0;
+ font-size: 17px;
+}
+.tab.active {
+ color: var(--text-h);
+ border-bottom: 2px solid var(--bar);
+ font-weight: 600;
+}
+.tab.disabled {
+ color: #aaa;
+ cursor: not-allowed;
+}
+
+.toast {
+ position: fixed;
+ bottom: 24px;
+ right: 24px;
+ background: var(--bar);
+ color: #fff;
+ padding: 10px 18px;
+ border-radius: 6px;
+ font-size: 14px;
+ z-index: 10;
+}
+
+.post {
+ padding: 16px;
+}
+
+.post .btn {
+ background-color: buttonface;
+ color: black;
+}
+
+.post-date {
+ font-size: 0.75rem;
+ margin: 0;
+}
+
+.posts-titlebar {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
diff --git a/sona-app/client/src/main.jsx b/sona-app/client/src/main.jsx
new file mode 100644
index 000000000..2308324da
--- /dev/null
+++ b/sona-app/client/src/main.jsx
@@ -0,0 +1,28 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import { BrowserRouter, Routes, Route } from "react-router-dom";
+import App from "./App.jsx";
+import Directory from "./pages/Directory.jsx";
+import ArtistDetail from "./pages/ArtistDetail.jsx";
+import Profile from "./pages/Profile.jsx";
+import PostEditForm from "./pages/PostEditForm.jsx";
+import PostCreateForm from "./pages/PostCreateForm.jsx";
+import MerchShop from "./pages/MerchShop.jsx";
+import "./index.css";
+
+createRoot(document.getElementById("root")).render(
+
+
+
+ }>
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+
+ ,
+);
diff --git a/sona-app/client/src/pages/ArtistDetail.jsx b/sona-app/client/src/pages/ArtistDetail.jsx
new file mode 100644
index 000000000..b78e19622
--- /dev/null
+++ b/sona-app/client/src/pages/ArtistDetail.jsx
@@ -0,0 +1,302 @@
+import { useEffect, useState } from "react";
+import {
+ useParams,
+ useNavigate,
+ useSearchParams,
+ Link,
+} from "react-router-dom";
+import currentUser from "../currentUser.js";
+import {
+ getArtist,
+ getAdminOf,
+ getFollowing,
+ updateArtist,
+ deleteArtist,
+ getPostsByArtist,
+ getArtistMerch,
+} from "../api.js";
+import FollowButton from "../components/FollowButton.jsx";
+import ArtistPost from "../components/ArtistPost.jsx";
+import MerchCard from "../components/MerchCard.jsx";
+import CreateMerchForm from "../components/CreateMerchForm.jsx";
+import Toast from "../components/Toast.jsx";
+
+function Social({ label, value }) {
+ if (!value) return null;
+ return (
+
+ {label}
+
+ );
+}
+
+const EDITABLE = [
+ { key: "name", label: "Name" },
+ { key: "genre", label: "Genre" },
+ { key: "description", label: "Bio", textarea: true },
+ { key: "instagram", label: "Instagram" },
+ { key: "twitter", label: "Twitter" },
+ { key: "facebook", label: "Facebook" },
+ { key: "tiktok", label: "TikTok" },
+ { key: "spotify", label: "Spotify URL" },
+];
+
+export default function ArtistDetail() {
+ const { id } = useParams();
+ const navigate = useNavigate();
+ const [artist, setArtist] = useState(null);
+ const [isAdmin, setIsAdmin] = useState(false);
+ const [following, setFollowing] = useState(null);
+ const [notify, setNotify] = useState(false);
+ const [editing, setEditing] = useState(false);
+ const [form, setForm] = useState({});
+ const [posts, setPosts] = useState([]);
+ const [searchParams, setSearchParams] = useSearchParams();
+ const [toast, setToast] = useState("");
+ const [merch, setMerch] = useState([]);
+ const [merchLoading, setMerchLoading] = useState(true);
+ const [showCreateMerch, setShowCreateMerch] = useState(false);
+
+ let new_post = searchParams.get("new_post") === "true";
+
+ useEffect(() => {
+ getArtist(id).then((data) => {
+ setArtist(data);
+ const seeded = {};
+ for (const { key } of EDITABLE) seeded[key] = data[key] ?? "";
+ setForm(seeded);
+ });
+ getAdminOf(currentUser.id).then((administered) => {
+ setIsAdmin(administered?.id === Number(id));
+ });
+ getFollowing(currentUser.id).then((rows) => {
+ const match = rows.find((row) => row.id === Number(id));
+ setFollowing(Boolean(match));
+ setNotify(match?.notify_on_release ?? false);
+ });
+ }, [id]);
+
+ useEffect(() => {
+ getPostsByArtist(id).then(setPosts);
+ setMerchLoading(true);
+ getArtistMerch(id)
+ .then(setMerch)
+ .finally(() => setMerchLoading(false));
+ }, [id]);
+
+ useEffect(() => {
+ if (new_post) {
+ showToast("Post published");
+ const params = new URLSearchParams(searchParams);
+ params.delete("new_post");
+ setSearchParams(params, { replace: true });
+ }
+ }, [new_post, searchParams, setSearchParams]);
+
+ if (!artist || following === null) return Loading...
;
+
+ async function handleSave(event) {
+ event.preventDefault();
+ const updated = await updateArtist(id, {
+ ...form,
+ user_id: currentUser.id,
+ });
+ setArtist((prev) => ({ ...prev, ...updated, ...form }));
+ setEditing(false);
+ }
+
+ async function handleDelete() {
+ if (!confirm(`Delete ${artist.name}?`)) return;
+ await deleteArtist(id, currentUser.id);
+ navigate("/");
+ }
+
+ function showToast(message) {
+ setToast(message);
+ setTimeout(() => setToast(""), 2000);
+ }
+
+ return (
+
+
+
+
+
+
+
+
{artist.name}
+ {artist.genre && {artist.genre} }
+
+
+
+ {!isAdmin && (
+
+ )}
+ {isAdmin && !editing && (
+
+ setEditing(true)}
+ >
+ Edit
+
+
+ Delete
+
+
+ )}
+
+
+
+ {isAdmin && editing && (
+
+ )}
+
+
+
+
About
+
{artist.description || "No bio yet."}
+
+
+
+
+
+
+
+
+ {artist.spotify ? (
+
+ Open on Spotify
+
+ ) : (
+
Spotify Embed Placeholder
+ )}
+
+
+
+
Concerts
+
Concerts table β coming soon (Issue 8)
+
+
+
+
+
+
Posts
+ {isAdmin && (
+
+ Create
+
+ )}
+
+
+ {posts.length > 0 ?
+ (posts.map((post) => (
+
+ ))) :
+ (
No posts yet.
)
+ }
+
+
+
+
+
+
Merch
+ See All β
+
+
+ {isAdmin && !showCreateMerch && (
+ setShowCreateMerch(true)}>
+ Add Merch
+
+ )}
+
+ {isAdmin && showCreateMerch && (
+ {
+ setMerch((prev) => [created, ...prev]);
+ setToast("Merch created!");
+ setTimeout(() => setToast(""), 3000);
+ }}
+ />
+ )}
+
+ {merchLoading ? (
+ Loading...
+ ) : merch.length === 0 ? (
+ No merch yet.
+ ) : (
+
+ {merch.map((item) => (
+ {
+ setMerch((prev) =>
+ prev.map((m) => (m.id === updated.id ? updated : m)),
+ );
+ setToast("Merch updated!");
+ setTimeout(() => setToast(""), 3000);
+ }}
+ onDeleted={(deletedId) => {
+ setMerch((prev) => prev.filter((m) => m.id !== deletedId));
+ }}
+ />
+ ))}
+
+ )}
+
+
+ {toast && {toast} }
+
+ );
+}
diff --git a/sona-app/client/src/pages/Directory.jsx b/sona-app/client/src/pages/Directory.jsx
new file mode 100644
index 000000000..9a71aee16
--- /dev/null
+++ b/sona-app/client/src/pages/Directory.jsx
@@ -0,0 +1,92 @@
+import { useEffect, useState } from "react";
+import { getArtists, getFollowing } from "../api.js";
+import currentUser from "../currentUser.js";
+import ArtistCard from "../components/ArtistCard.jsx";
+import QuickViewPanel from "../components/QuickViewPanel.jsx";
+
+export default function Directory() {
+ const [artists, setArtists] = useState([]);
+ const [followMap, setFollowMap] = useState({});
+ const [followLoaded, setFollowLoaded] = useState(false);
+ const [q, setQ] = useState("");
+ const [genreOptions, setGenreOptions] = useState([]);
+ const [selectedGenre, setSelectedGenre] = useState("");
+ const [selectedArtist, setSelectedArtist] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ getArtists().then((all) => {
+ const unique = [...new Set(all.map((a) => a.genre).filter(Boolean))];
+ setGenreOptions(unique);
+ });
+ }, []);
+
+ useEffect(() => {
+ setLoading(true);
+ getArtists({ q, genre: selectedGenre })
+ .then(setArtists)
+ .finally(() => setLoading(false));
+ }, [q, selectedGenre]);
+
+ useEffect(() => {
+ getFollowing(currentUser.id).then((rows) => {
+ const map = {};
+ for (const row of rows) {
+ map[row.id] = { following: true, notify: row.notify_on_release };
+ }
+ setFollowMap(map);
+ setFollowLoaded(true);
+ });
+ }, []);
+
+ const ready = !loading && followLoaded;
+
+ return (
+
+ Discover Artists
+
+
+ setQ(event.target.value)}
+ />
+ setSelectedGenre(event.target.value)}
+ >
+ Genre Dropdown
+ {genreOptions.map((option) => (
+
+ {option}
+
+ ))}
+
+
+
+ {!ready ? (
+ Loading...
+ ) : artists.length === 0 ? (
+ No artists found.
+ ) : (
+
+ {artists.map((artist) => (
+
setSelectedArtist(artist)}
+ initialFollowing={followMap[artist.id]?.following ?? false}
+ initialNotify={followMap[artist.id]?.notify ?? false}
+ />
+ ))}
+
+ )}
+
+ setSelectedArtist(null)}
+ />
+
+ );
+}
diff --git a/sona-app/client/src/pages/MerchShop.jsx b/sona-app/client/src/pages/MerchShop.jsx
new file mode 100644
index 000000000..3fb552fc3
--- /dev/null
+++ b/sona-app/client/src/pages/MerchShop.jsx
@@ -0,0 +1,63 @@
+import { useEffect, useState } from "react";
+import { getMerch, getArtists } from "../api.js";
+import MerchCard from "../components/MerchCard.jsx";
+
+export default function MerchShop() {
+ const [merch, setMerch] = useState([]);
+ const [artists, setArtists] = useState([]);
+ const [artistId, setArtistId] = useState("");
+ const [type, setType] = useState("");
+ const [sort, setSort] = useState("");
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ getArtists().then(setArtists);
+ }, []);
+
+ useEffect(() => {
+ setLoading(true);
+ getMerch({ artist_id: artistId, type, sort })
+ .then(setMerch)
+ .finally(() => setLoading(false));
+ }, [artistId, type, sort]);
+
+ return (
+
+ Merch Shop
+
+
+ setArtistId(e.target.value)}>
+ All Artists
+ {artists.map((artist) => (
+ {artist.name}
+ ))}
+
+
+ setType(e.target.value)}>
+ All Types
+ Apparel
+ Music
+ Accessory
+
+
+ setSort(e.target.value)}>
+ Sort
+ Price: Low to High
+ Price: High to Low
+
+
+
+ {loading ? (
+ Loading...
+ ) : merch.length === 0 ? (
+ No merch found.
+ ) : (
+
+ {merch.map((item) => (
+
+ ))}
+
+ )}
+
+ );
+}
\ No newline at end of file
diff --git a/sona-app/client/src/pages/PostCreateForm.jsx b/sona-app/client/src/pages/PostCreateForm.jsx
new file mode 100644
index 000000000..eb5582a02
--- /dev/null
+++ b/sona-app/client/src/pages/PostCreateForm.jsx
@@ -0,0 +1,56 @@
+import { useState, useEffect } from "react";
+import { createPost, getArtist } from "../api.js";
+import { useSearchParams, useNavigate } from "react-router-dom";
+import currentUser from "../currentUser.js";
+
+export default function PostCreateForm() {
+ const navigate = useNavigate();
+ const [searchParams, setSearchParams] = useSearchParams();
+ const [content, setContent] = useState("");
+ const [artist, setArtist] = useState({});
+ const [error, setError] = useState("");
+
+ const artistId = searchParams.get("artist");
+
+ useEffect(() => {
+ getArtist(artistId).then(setArtist);
+ }, [artistId]);
+
+ const handleChange = (event) => {
+ setContent(event.target.value);
+ };
+
+ const handleSubmit = async (event) => {
+ event.preventDefault();
+ if (!content.trim()) {
+ setError("Post can't be empty.");
+ return;
+ }
+ try {
+ await createPost(currentUser.id, artistId, content);
+ navigate(`/artists/${artistId}?new_post=true`);
+ } catch (err) {
+ setError("Something went wrong. You don't have permission to post here.");
+ }
+ };
+
+ return (
+
+
+
{artist.name}
+
+
Create New Post
+ {error &&
{error}
}
+
+
+
+
+
+ );
+}
diff --git a/sona-app/client/src/pages/PostEditForm.jsx b/sona-app/client/src/pages/PostEditForm.jsx
new file mode 100644
index 000000000..b67f3ae04
--- /dev/null
+++ b/sona-app/client/src/pages/PostEditForm.jsx
@@ -0,0 +1,55 @@
+import { useState, useEffect } from "react";
+import { getPost, updatePost } from "../api.js";
+import { useParams, useNavigate } from "react-router-dom";
+import currentUser from "../currentUser.js";
+
+export default function PostEditForm() {
+ const navigate = useNavigate();
+ const { id } = useParams();
+ const [post, setPost] = useState({
+ id: 0,
+ artist_name: "",
+ artist_id: 0,
+ content: "",
+ posted_on: null,
+ });
+
+ useEffect(() => {
+ getPost(id).then(setPost);
+ }, [id]);
+
+ const handleChange = (event) => {
+ const { name, value } = event.target;
+ setPost((prev) => {
+ return {
+ ...prev,
+ [name]: value,
+ };
+ });
+ };
+
+ const handleSubmit = async (event) => {
+ event.preventDefault();
+ await updatePost(id, currentUser.id, post.artist_id, post.content);
+ navigate(`/artists/${post.artist_id}`);
+ };
+
+ return (
+
+
+
{post.artist_name}
+
+
Edit Post
+
+
+
+
+
+ );
+}
diff --git a/sona-app/client/src/pages/Profile.jsx b/sona-app/client/src/pages/Profile.jsx
new file mode 100644
index 000000000..bcccfd11d
--- /dev/null
+++ b/sona-app/client/src/pages/Profile.jsx
@@ -0,0 +1,44 @@
+import { useEffect, useState } from "react";
+import { getFollowing } from "../api.js";
+import currentUser from "../currentUser.js";
+import ArtistCard from "../components/ArtistCard.jsx";
+
+export default function Profile() {
+ const [following, setFollowing] = useState([]);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ getFollowing(currentUser.id)
+ .then(setFollowing)
+ .finally(() => setLoading(false));
+ }, []);
+
+ return (
+
+ My Profile
+
+
+ Following
+ Order History
+
+
+ {loading ? (
+ Loading your following list...
+ ) : following.length === 0 ? (
+ You're not following anyone yet.
+ ) : (
+
+ {following.map((artist) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/sona-app/client/vite.config.js b/sona-app/client/vite.config.js
new file mode 100644
index 000000000..8b0f57b91
--- /dev/null
+++ b/sona-app/client/vite.config.js
@@ -0,0 +1,7 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+// https://vite.dev/config/
+export default defineConfig({
+ plugins: [react()],
+})
diff --git a/sona-app/server/config/database.js b/sona-app/server/config/database.js
new file mode 100644
index 000000000..0102ef160
--- /dev/null
+++ b/sona-app/server/config/database.js
@@ -0,0 +1,18 @@
+import pg from "pg";
+import "./dotenv.js";
+
+const { Pool } = pg;
+
+const isLocalhost =
+ process.env.PGHOST === "localhost" || process.env.PGHOST === "127.0.0.1";
+
+const pool = new Pool({
+ user: process.env.PGUSER,
+ host: process.env.PGHOST,
+ database: process.env.PGDATABASE,
+ password: process.env.PGPASSWORD,
+ port: process.env.PGPORT,
+ ssl: isLocalhost ? false : { rejectUnauthorized: false },
+});
+
+export default pool;
diff --git a/sona-app/server/config/dotenv.js b/sona-app/server/config/dotenv.js
new file mode 100644
index 000000000..b6932e1de
--- /dev/null
+++ b/sona-app/server/config/dotenv.js
@@ -0,0 +1,8 @@
+import dotenv from "dotenv";
+import { fileURLToPath } from "url";
+import path from "path";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = path.dirname(__filename);
+
+dotenv.config({ path: path.join(__dirname, "..", ".env") });
diff --git a/sona-app/server/config/reset.js b/sona-app/server/config/reset.js
new file mode 100644
index 000000000..6e6d46ed5
--- /dev/null
+++ b/sona-app/server/config/reset.js
@@ -0,0 +1,175 @@
+import pool from "./database.js";
+
+const dropTables = `
+ DROP TABLE IF EXISTS posts CASCADE;
+ DROP TABLE IF EXISTS follows CASCADE;
+ DROP TABLE IF EXISTS merch CASCADE;
+ DROP TABLE IF EXISTS admin CASCADE;
+ DROP TABLE IF EXISTS profile CASCADE;
+ DROP TABLE IF EXISTS artists CASCADE;
+ DROP TABLE IF EXISTS users CASCADE;
+`;
+
+const createTables = `
+ CREATE TABLE users (
+ id SERIAL PRIMARY KEY,
+ username VARCHAR(50) UNIQUE NOT NULL,
+ role VARCHAR(20) NOT NULL DEFAULT 'fan',
+ created_at TIMESTAMP DEFAULT NOW()
+ );
+
+ CREATE TABLE artists (
+ id SERIAL PRIMARY KEY,
+ name VARCHAR(100) NOT NULL,
+ genre VARCHAR(50),
+ photo VARCHAR(255),
+ created_at TIMESTAMP DEFAULT NOW()
+ );
+
+ CREATE TABLE merch (
+ id SERIAL PRIMARY KEY,
+ artist_id INTEGER NOT NULL REFERENCES artists(id) ON DELETE CASCADE,
+ name VARCHAR(255) NOT NULL,
+ type VARCHAR(100) NOT NULL,
+ price NUMERIC(10, 2) NOT NULL,
+ photo VARCHAR(255),
+ stock INTEGER NOT NULL DEFAULT 0,
+ created_at TIMESTAMP DEFAULT NOW()
+ );
+
+ -- one-to-one: artists <-> profile
+ CREATE TABLE profile (
+ artist_id INTEGER UNIQUE REFERENCES artists(id) ON DELETE CASCADE,
+ description TEXT,
+ instagram VARCHAR(100),
+ twitter VARCHAR(100),
+ facebook VARCHAR(100),
+ tiktok VARCHAR(100),
+ spotify VARCHAR(255)
+ );
+
+ -- one-to-one: users <-> artists, via admin
+ CREATE TABLE admin (
+ id SERIAL PRIMARY KEY,
+ user_id INTEGER UNIQUE REFERENCES users(id) ON DELETE CASCADE,
+ artist_id INTEGER UNIQUE REFERENCES artists(id) ON DELETE CASCADE
+ );
+
+ -- many-to-many join table: users <-> artists
+ CREATE TABLE follows (
+ user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
+ artist_id INTEGER REFERENCES artists(id) ON DELETE CASCADE,
+ notify_on_release BOOLEAN DEFAULT false,
+ created_at TIMESTAMP DEFAULT NOW(),
+ PRIMARY KEY (user_id, artist_id)
+ );
+
+ CREATE TABLE posts (
+ id SERIAL PRIMARY KEY,
+ artist_id INTEGER REFERENCES artists(id) ON DELETE CASCADE,
+ content TEXT NOT NULL,
+ created_at TIMESTAMP DEFAULT NOW()
+ );
+`;
+
+async function seed() {
+ const client = await pool.connect();
+ try {
+ console.log("Dropping existing tables...");
+ await client.query(dropTables);
+
+ console.log("Creating tables...");
+ await client.query(createTables);
+
+ console.log("Seeding users...");
+ const users = await client.query(`
+ INSERT INTO users (username, role) VALUES
+ ('testfan', 'fan'),
+ ('testadmin', 'artist')
+ RETURNING id, username, role;
+ `);
+ const [fan, testAdmin] = users.rows;
+
+ console.log("Seeding artists...");
+ const artists = await client.query(`
+ INSERT INTO artists (name, genre, photo) VALUES
+ ('Test Artist 1', 'Pop', 'https://picsum.photos/seed/artist1/400/400'),
+ ('Test Artist 2', 'Rock', 'https://picsum.photos/seed/artist2/400/400'),
+ ('Test Artist 3', 'Hip-Hop', 'https://picsum.photos/seed/artist3/400/400'),
+ ('Test Artist 4', 'R&B', 'https://picsum.photos/seed/artist4/400/400'),
+ ('Test Artist 5', 'Indie', 'https://picsum.photos/seed/artist5/400/400'),
+ ('Test Artist 6', 'Electronic', 'https://picsum.photos/seed/artist6/400/400'),
+ ('Test Artist 7', 'Jazz', 'https://picsum.photos/seed/artist7/400/400')
+ RETURNING id, name;
+ `);
+ const [artist1, artist2, artist3, artist4, artist5, artist6, artist7] =
+ artists.rows;
+
+ console.log("Seeding merch...");
+ await client.query(
+ `INSERT INTO merch (artist_id, name, type, price, photo, stock) VALUES
+ ($1, 'Tour T-Shirt', 'apparel', 25.00, 'https://picsum.photos/seed/merch1/400/400', 50),
+ ($1, 'Vinyl Record', 'music', 30.00, 'https://picsum.photos/seed/merch2/400/400', 20),
+ ($2, 'Hoodie', 'apparel', 45.00, 'https://picsum.photos/seed/merch3/400/400', 30),
+ ($2, 'Poster', 'accessory', 15.00, 'https://picsum.photos/seed/merch4/400/400', 100),
+ ($3, 'Snapback Hat', 'apparel', 28.00, 'https://picsum.photos/seed/merch5/400/400', 40),
+ ($4, 'Band Tee', 'apparel', 35.00, 'https://picsum.photos/seed/merch6/400/400', 25),
+ ($5, 'Concert Ticket', 'accessory', 50.00, 'https://picsum.photos/seed/merch7/400/400', 10)
+ `,
+ [artist1.id, artist2.id, artist3.id, artist4.id, artist5.id],
+ );
+
+ console.log("Seeding profiles...");
+ await client.query(
+ `INSERT INTO profile (artist_id, description, instagram, twitter, facebook, tiktok, spotify) VALUES
+ ($1, 'Placeholder bio for Test Artist 1.', '@testartist1', '@testartist1', '@testartist1fb', '@testartist1tt', 'https://open.spotify.com/artist/example1'),
+ ($2, 'Placeholder bio for Test Artist 2.', '@testartist2', '@testartist2', '@testartist2fb', '@testartist2tt', 'https://open.spotify.com/artist/example2'),
+ ($3, 'Placeholder bio for Test Artist 3.', '@testartist3', '@testartist3', '@testartist3fb', '@testartist3tt', 'https://open.spotify.com/artist/example3'),
+ ($4, 'Placeholder bio for Test Artist 4.', '@testartist4', '@testartist4', '@testartist4fb', '@testartist4tt', 'https://open.spotify.com/artist/example4'),
+ ($5, 'Placeholder bio for Test Artist 5.', '@testartist5', '@testartist5', '@testartist5fb', '@testartist5tt', 'https://open.spotify.com/artist/example5'),
+ ($6, 'Placeholder bio for Test Artist 6.', '@testartist6', '@testartist6', '@testartist6fb', '@testartist6tt', 'https://open.spotify.com/artist/example6'),
+ ($7, 'Placeholder bio for Test Artist 7.', '@testartist7', '@testartist7', '@testartist7fb', '@testartist7tt', 'https://open.spotify.com/artist/example7')
+ `,
+ [
+ artist1.id,
+ artist2.id,
+ artist3.id,
+ artist4.id,
+ artist5.id,
+ artist6.id,
+ artist7.id,
+ ],
+ );
+
+ console.log("Seeding admin link...");
+ await client.query(
+ `INSERT INTO admin (user_id, artist_id) VALUES ($1, $2)`,
+ [testAdmin.id, artist1.id],
+ );
+
+ console.log("Seeding follows...");
+ await client.query(
+ `INSERT INTO follows (user_id, artist_id) VALUES ($1, $2), ($1, $3), ($1, $4)`,
+ [fan.id, artist1.id, artist3.id, artist5.id],
+ );
+
+ console.log("Seeding posts...");
+ await client.query(
+ `INSERT INTO posts (artist_id, content) VALUES
+ ($1, 'Placeholder post from Test Artist 1.'),
+ ($2, 'Placeholder post from Test Artist 2.'),
+ ($3, 'Placeholder post from Test Artist 3.')
+ `,
+ [artist1.id, artist2.id, artist3.id],
+ );
+
+ console.log("β
Database reset and seeded successfully.");
+ } catch (err) {
+ console.error("Seed error:", err);
+ } finally {
+ client.release();
+ pool.end();
+ }
+}
+
+seed();
diff --git a/sona-app/server/package-lock.json b/sona-app/server/package-lock.json
new file mode 100644
index 000000000..40c6334de
--- /dev/null
+++ b/sona-app/server/package-lock.json
@@ -0,0 +1,1049 @@
+{
+ "name": "server",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "server",
+ "version": "1.0.0",
+ "license": "ISC",
+ "dependencies": {
+ "cors": "^2.8.6",
+ "dotenv": "^17.4.2",
+ "express": "^5.2.1",
+ "pg": "^8.22.0"
+ }
+ },
+ "node_modules/accepts": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
+ "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "^3.0.0",
+ "negotiator": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/body-parser": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
+ "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "^3.1.2",
+ "content-type": "^2.0.0",
+ "debug": "^4.4.3",
+ "http-errors": "^2.0.1",
+ "iconv-lite": "^0.7.2",
+ "on-finished": "^2.4.1",
+ "qs": "^6.15.2",
+ "raw-body": "^3.0.2",
+ "type-is": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/body-parser/node_modules/content-type": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
+ "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
+ "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz",
+ "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.6.0"
+ }
+ },
+ "node_modules/cors": {
+ "version": "2.8.6",
+ "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
+ "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
+ "license": "MIT",
+ "dependencies": {
+ "object-assign": "^4",
+ "vary": "^1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/dotenv": {
+ "version": "17.4.2",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
+ "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
+ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "^2.0.0",
+ "body-parser": "^2.2.1",
+ "content-disposition": "^1.0.0",
+ "content-type": "^1.0.5",
+ "cookie": "^0.7.1",
+ "cookie-signature": "^1.2.1",
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "finalhandler": "^2.1.0",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.0",
+ "merge-descriptors": "^2.0.0",
+ "mime-types": "^3.0.0",
+ "on-finished": "^2.4.1",
+ "once": "^1.4.0",
+ "parseurl": "^1.3.3",
+ "proxy-addr": "^2.0.7",
+ "qs": "^6.14.0",
+ "range-parser": "^1.2.1",
+ "router": "^2.2.0",
+ "send": "^1.1.0",
+ "serve-static": "^2.2.0",
+ "statuses": "^2.0.1",
+ "type-is": "^2.0.1",
+ "vary": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/finalhandler": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz",
+ "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "on-finished": "^2.4.1",
+ "parseurl": "^1.3.3",
+ "statuses": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz",
+ "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.7.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
+ "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/is-promise": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
+ "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
+ "license": "MIT"
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
+ "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
+ "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.54.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
+ "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz",
+ "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "^1.54.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/negotiator": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz",
+ "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/once": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
+ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+ "license": "ISC",
+ "dependencies": {
+ "wrappy": "1"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "8.4.2",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
+ "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
+ "license": "MIT",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/pg": {
+ "version": "8.22.0",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz",
+ "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.14.0",
+ "pg-pool": "^3.14.0",
+ "pg-protocol": "^1.15.0",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.4.0"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
+ "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.14.0",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
+ "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.14.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.15.0",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz",
+ "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==",
+ "license": "MIT"
+ },
+ "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==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "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/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==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "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==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "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==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "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==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.15.3",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
+ "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "es-define-property": "^1.0.1",
+ "side-channel": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
+ "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "3.0.2",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
+ "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.7.0",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/router": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
+ "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.0",
+ "depd": "^2.0.0",
+ "is-promise": "^4.0.0",
+ "parseurl": "^1.3.3",
+ "path-to-regexp": "^8.0.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ }
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/send": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
+ "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "^4.4.3",
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "etag": "^1.8.1",
+ "fresh": "^2.0.0",
+ "http-errors": "^2.0.1",
+ "mime-types": "^3.0.2",
+ "ms": "^2.1.3",
+ "on-finished": "^2.4.1",
+ "range-parser": "^1.2.1",
+ "statuses": "^2.0.2"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/serve-static": {
+ "version": "2.2.1",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz",
+ "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "^2.0.0",
+ "escape-html": "^1.0.3",
+ "parseurl": "^1.3.3",
+ "send": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4",
+ "side-channel-list": "^1.0.1",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz",
+ "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==",
+ "license": "MIT",
+ "dependencies": {
+ "content-type": "^2.0.0",
+ "media-typer": "^1.1.0",
+ "mime-types": "^3.0.0"
+ },
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/type-is/node_modules/content-type": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz",
+ "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/wrappy": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+ "license": "ISC"
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
+ }
+ }
+}
diff --git a/sona-app/server/package.json b/sona-app/server/package.json
new file mode 100644
index 000000000..e74677373
--- /dev/null
+++ b/sona-app/server/package.json
@@ -0,0 +1,20 @@
+{
+ "name": "server",
+ "version": "1.0.0",
+ "description": "",
+ "main": "server.js",
+ "type": "module",
+ "scripts": {
+ "dev": "node --watch server.js",
+ "reset-db": "node config/reset.js"
+ },
+ "keywords": [],
+ "author": "",
+ "license": "ISC",
+ "dependencies": {
+ "cors": "^2.8.6",
+ "dotenv": "^17.4.2",
+ "express": "^5.2.1",
+ "pg": "^8.22.0"
+ }
+}
diff --git a/sona-app/server/routes/artists.js b/sona-app/server/routes/artists.js
new file mode 100644
index 000000000..7f4a67327
--- /dev/null
+++ b/sona-app/server/routes/artists.js
@@ -0,0 +1,271 @@
+import express from "express";
+import pool from "../config/database.js";
+
+const router = express.Router();
+
+export async function isAdminOf(userId, artistId) {
+ const result = await pool.query(
+ `SELECT 1 FROM admin WHERE user_id = $1 AND artist_id = $2`,
+ [userId, artistId],
+ );
+ return result.rows.length > 0;
+}
+
+router.get("/", async (req, res) => {
+ const { q, genre } = req.query;
+ const conditions = [];
+ const values = [];
+
+ if (q) {
+ values.push(`%${q}%`);
+ conditions.push(`name ILIKE $${values.length}`);
+ }
+ if (genre) {
+ values.push(genre);
+ conditions.push(`genre = $${values.length}`);
+ }
+
+ const where = conditions.length ? `WHERE ${conditions.join(" AND ")}` : "";
+
+ try {
+ const result = await pool.query(
+ `SELECT artists.*,
+ profile.description, profile.instagram, profile.twitter,
+ profile.facebook, profile.tiktok, profile.spotify
+ FROM artists
+ LEFT JOIN profile ON profile.artist_id = artists.id
+ ${where}
+ ORDER BY artists.name`,
+ values,
+ );
+ res.json(result.rows);
+ } catch (err) {
+ console.error(err);
+ res.status(500).json({ error: "Failed to fetch artists" });
+ }
+});
+
+// GET /api/artists/:id/merch
+router.get('/:id/merch', async (req, res) => {
+ try {
+ const { rows } = await pool.query(
+ 'SELECT * FROM merch WHERE artist_id = $1 ORDER BY created_at DESC',
+ [req.params.id]
+ );
+ res.json(rows);
+ } catch (err) {
+ console.error(err);
+ res.status(500).json({ error: 'Failed to fetch artist merch' });
+ }
+});
+
+// POST /api/artists/:id/merch
+router.post('/:id/merch', async (req, res) => {
+ try {
+ const artistId = req.params.id;
+ const { user_id, name, type, price, photo, stock } = req.body;
+
+ if (!user_id) return res.status(400).json({ error: 'user_id is required' });
+
+ if (!(await isAdminOf(user_id, artistId))) {
+ return res.status(403).json({ error: 'Not authorized' });
+ }
+ if (!name || !type || price === undefined) {
+ return res.status(400).json({ error: 'name, type, and price are required' });
+ }
+
+ const { rows } = await pool.query(
+ `INSERT INTO merch (artist_id, name, type, price, photo, stock)
+ VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
+ [artistId, name, type, price, photo, stock ?? 0]
+ );
+ res.status(201).json(rows[0]);
+ } catch (err) {
+ console.error(err);
+ res.status(500).json({ error: 'Failed to create merch' });
+ }
+});
+
+router.get("/:id", async (req, res) => {
+ const { id } = req.params;
+ try {
+ const result = await pool.query(
+ `SELECT artists.*,
+ profile.description, profile.instagram, profile.twitter,
+ profile.facebook, profile.tiktok, profile.spotify
+ FROM artists
+ LEFT JOIN profile ON profile.artist_id = artists.id
+ WHERE artists.id = $1`,
+ [id],
+ );
+ if (result.rows.length === 0) {
+ return res.status(404).json({ error: "Artist not found" });
+ }
+ res.json(result.rows[0]);
+ } catch (err) {
+ console.error(err);
+ res.status(500).json({ error: "Failed to fetch artist" });
+ }
+});
+
+router.post("/", async (req, res) => {
+ const {
+ name, genre, photo,
+ description, instagram, twitter, facebook, tiktok, spotify,
+ } = req.body;
+
+ if (!name) {
+ return res.status(400).json({ error: "name is required" });
+ }
+
+ const client = await pool.connect();
+ try {
+ await client.query("BEGIN");
+
+ const artistResult = await client.query(
+ `INSERT INTO artists (name, genre, photo)
+ VALUES ($1, $2, $3)
+ RETURNING *`,
+ [name, genre, photo],
+ );
+ const artist = artistResult.rows[0];
+
+ await client.query(
+ `INSERT INTO profile
+ (artist_id, description, instagram, twitter, facebook, tiktok, spotify)
+ VALUES ($1, $2, $3, $4, $5, $6, $7)`,
+ [artist.id, description, instagram, twitter, facebook, tiktok, spotify],
+ );
+
+ await client.query("COMMIT");
+ res.status(201).json(artist);
+ } catch (err) {
+ await client.query("ROLLBACK");
+ console.error(err);
+ res.status(500).json({ error: "Failed to create artist" });
+ } finally {
+ client.release();
+ }
+});
+
+router.patch("/:id", async (req, res) => {
+ const { id } = req.params;
+ const {
+ user_id,
+ name, genre, photo,
+ description, instagram, twitter, facebook, tiktok, spotify,
+ } = req.body;
+
+ if (!user_id) {
+ return res.status(400).json({ error: "user_id is required" });
+ }
+ if (!(await isAdminOf(user_id, id))) {
+ return res.status(403).json({ error: "Not authorized to edit this artist" });
+ }
+
+ const client = await pool.connect();
+ try {
+ await client.query("BEGIN");
+
+ const artistResult = await client.query(
+ `UPDATE artists
+ SET name = COALESCE($2, name),
+ genre = COALESCE($3, genre),
+ photo = COALESCE($4, photo)
+ WHERE id = $1
+ RETURNING *`,
+ [id, name, genre, photo],
+ );
+
+ await client.query(
+ `UPDATE profile
+ SET description = COALESCE($2, description),
+ instagram = COALESCE($3, instagram),
+ twitter = COALESCE($4, twitter),
+ facebook = COALESCE($5, facebook),
+ tiktok = COALESCE($6, tiktok),
+ spotify = COALESCE($7, spotify)
+ WHERE artist_id = $1`,
+ [id, description, instagram, twitter, facebook, tiktok, spotify],
+ );
+
+ await client.query("COMMIT");
+ res.json(artistResult.rows[0]);
+ } catch (err) {
+ await client.query("ROLLBACK");
+ console.error(err);
+ res.status(500).json({ error: "Failed to update artist" });
+ } finally {
+ client.release();
+ }
+});
+
+router.delete("/:id", async (req, res) => {
+ const { id } = req.params;
+ const { user_id } = req.body;
+
+ if (!user_id) {
+ return res.status(400).json({ error: "user_id is required" });
+ }
+ if (!(await isAdminOf(user_id, id))) {
+ return res.status(403).json({ error: "Not authorized to delete this artist" });
+ }
+
+ try {
+ await pool.query(`DELETE FROM artists WHERE id = $1`, [id]);
+ res.status(204).send();
+ } catch (err) {
+ console.error(err);
+ res.status(500).json({ error: "Failed to delete artist" });
+ }
+});
+
+router.get("/:id/posts", async (req, res) => {
+ const {id} = req.params;
+
+ try {
+ const postResult = await pool.query(
+ `SELECT posts.id AS post_id,
+ artists.id AS artist_id,
+ artists.name AS artist_name,
+ posts.content,
+ posts.created_at AS posted_on
+ FROM artists
+ JOIN posts ON artists.id = posts.artist_id
+ WHERE artist_id = $1
+ ORDER BY posts.created_at DESC`,
+ [id]
+ )
+ res.json(postResult.rows)
+ } catch (err) {
+ console.error(err)
+ res.status(500).json({ error: "Failed to fetch posts" });
+ }
+})
+
+router.post('/:id/posts', async (req, res) => {
+ const {user_id, artist_id, content} = req.body;
+
+ if (!artist_id || !content) {
+ return res.status(400).json({error: 'artist_id and content is required'})
+ }
+
+ if (!(await isAdminOf(user_id, artist_id))) {
+ return res.status(403).json({ error: "Not authorized to create a post this artist" });
+ }
+
+ try {
+ const result = await pool.query(
+ `INSERT INTO posts (artist_id, content)
+ VALUES ($1, $2)
+ RETURNING *`,
+ [artist_id, content]
+ )
+ res.status(201).json(result.rows[0])
+ console.log("new post submitted")
+ } catch (err) {
+ res.status(500).json({error: "Failed to upload post"})
+ }
+})
+
+export default router;
diff --git a/sona-app/server/routes/follows.js b/sona-app/server/routes/follows.js
new file mode 100644
index 000000000..8ee9438a8
--- /dev/null
+++ b/sona-app/server/routes/follows.js
@@ -0,0 +1,49 @@
+import express from "express";
+import pool from "../config/database.js";
+
+const router = express.Router();
+
+router.post("/", async (req, res) => {
+ const { user_id, artist_id, notify_on_release = false } = req.body;
+
+ if (!user_id || !artist_id) {
+ return res.status(400).json({ error: "user_id and artist_id are required" });
+ }
+
+ try {
+ const result = await pool.query(
+ `INSERT INTO follows (user_id, artist_id, notify_on_release)
+ VALUES ($1, $2, $3)
+ ON CONFLICT (user_id, artist_id)
+ DO UPDATE SET notify_on_release = EXCLUDED.notify_on_release
+ RETURNING *`,
+ [user_id, artist_id, notify_on_release],
+ );
+ res.status(201).json(result.rows[0]);
+ } catch (err) {
+ console.error(err);
+ res.status(500).json({ error: "Failed to follow artist" });
+ }
+});
+
+router.delete("/:artistId", async (req, res) => {
+ const { artistId } = req.params;
+ const { user_id } = req.query;
+
+ if (!user_id) {
+ return res.status(400).json({ error: "user_id is required" });
+ }
+
+ try {
+ await pool.query(
+ `DELETE FROM follows WHERE user_id = $1 AND artist_id = $2`,
+ [user_id, artistId],
+ );
+ res.status(204).send();
+ } catch (err) {
+ console.error(err);
+ res.status(500).json({ error: "Failed to unfollow artist" });
+ }
+});
+
+export default router;
diff --git a/sona-app/server/routes/merch.js b/sona-app/server/routes/merch.js
new file mode 100644
index 000000000..708c16ec3
--- /dev/null
+++ b/sona-app/server/routes/merch.js
@@ -0,0 +1,113 @@
+import { Router } from 'express';
+import pool from '../config/database.js';
+
+const router = Router();
+
+async function isAdminOf(userId, artistId) {
+ const result = await pool.query(
+ `SELECT 1 FROM admin WHERE user_id = $1 AND artist_id = $2`,
+ [userId, artistId],
+ );
+ return result.rows.length > 0;
+}
+
+// GET /api/merch?artist_id=&type=&sort=
+router.get('/', async (req, res) => {
+ try {
+ const { artist_id, type, sort } = req.query;
+ const conditions = [];
+ const values = [];
+
+ if (artist_id) {
+ values.push(artist_id);
+ conditions.push(`artist_id = $${values.length}`);
+ }
+ if (type) {
+ values.push(type);
+ conditions.push(`type = $${values.length}`);
+ }
+
+ const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
+
+ const sortMap = {
+ price_asc: 'price ASC',
+ price_desc: 'price DESC',
+ };
+ const orderBy = sortMap[sort] ? `ORDER BY ${sortMap[sort]}` : 'ORDER BY created_at DESC';
+
+ const { rows } = await pool.query(
+ `SELECT * FROM merch ${where} ${orderBy}`,
+ values
+ );
+ res.json(rows);
+ } catch (err) {
+ console.error(err);
+ res.status(500).json({ error: 'Failed to fetch merch' });
+ }
+});
+
+// PATCH /api/merch/:id
+router.patch('/:id', async (req, res) => {
+ try {
+ const { user_id, name, type, price, photo, stock } = req.body;
+
+ if (!user_id) {
+ return res.status(400).json({ error: 'user_id is required' });
+ }
+
+ const { rows: existing } = await pool.query(
+ 'SELECT artist_id FROM merch WHERE id = $1',
+ [req.params.id]
+ );
+ if (!existing.length) return res.status(404).json({ error: 'Merch not found' });
+
+ if (!(await isAdminOf(user_id, existing[0].artist_id))) {
+ return res.status(403).json({ error: 'Not authorized' });
+ }
+
+ const { rows } = await pool.query(
+ `UPDATE merch SET
+ name = COALESCE($1, name),
+ type = COALESCE($2, type),
+ price = COALESCE($3, price),
+ photo = COALESCE($4, photo),
+ stock = COALESCE($5, stock)
+ WHERE id = $6 RETURNING *`,
+ [name, type, price, photo, stock, req.params.id]
+ );
+ res.json(rows[0]);
+ } catch (err) {
+ console.error(err);
+ res.status(500).json({ error: 'Failed to update merch' });
+ }
+});
+
+
+// DELETE /api/merch/:id
+router.delete('/:id', async (req, res) => {
+ try {
+ const { user_id } = req.body;
+
+ if (!user_id) {
+ return res.status(400).json({ error: 'user_id is required' });
+ }
+
+ const { rows: existing } = await pool.query(
+ 'SELECT artist_id FROM merch WHERE id = $1',
+ [req.params.id]
+ );
+ if (!existing.length) return res.status(404).json({ error: 'Merch not found' });
+
+ if (!(await isAdminOf(user_id, existing[0].artist_id))) {
+ return res.status(403).json({ error: 'Not authorized' });
+ }
+
+ await pool.query('DELETE FROM merch WHERE id = $1', [req.params.id]);
+ res.status(204).send();
+ } catch (err) {
+ console.error(err);
+ res.status(500).json({ error: 'Failed to delete merch' });
+ }
+});
+
+export default router;
\ No newline at end of file
diff --git a/sona-app/server/routes/posts.js b/sona-app/server/routes/posts.js
new file mode 100644
index 000000000..1ca6410f4
--- /dev/null
+++ b/sona-app/server/routes/posts.js
@@ -0,0 +1,77 @@
+import express from "express";
+import pool from "../config/database.js";
+import { isAdminOf } from "./artists.js";
+
+const router = express.Router();
+
+router.get("/:id", async (req, res) => {
+ const { id } = req.params;
+
+ try {
+ const result = await pool.query(
+ `SELECT posts.id,
+ artists.name AS artist_name,
+ posts.artist_id,
+ posts.content,
+ posts.created_at AS posted_on
+ FROM artists
+ JOIN posts ON posts.artist_id = artists.id
+ WHERE posts.id = $1
+ ORDER BY posts.created_at DESC`,
+ [id],
+ );
+ res.json(result.rows[0]);
+ } catch (err) {
+ console.log(err);
+ res.status(500).json({ error: "Failed to fetch post" });
+ }
+});
+
+router.patch("/:id", async (req, res) => {
+ const { id } = req.params;
+ const { user_id, artist_id, content } = req.body;
+
+ if (!artist_id || !content) {
+ return res.status(400).json({ error: "artist_id and content is required" });
+ }
+
+ if (!(await isAdminOf(user_id, artist_id))) {
+ return res
+ .status(403)
+ .json({ error: "Not authorized to edit a post this artist" });
+ }
+
+ try {
+ const result = await pool.query(
+ `UPDATE posts
+ SET artist_id = $1,
+ content = $2
+ WHERE id = $3
+ RETURNING *`,
+ [artist_id, content, id],
+ );
+ res.status(200).json(result.rows[0]);
+ } catch (err) {
+ res.status(500).json({ error: "Failed to update post" });
+ }
+});
+
+router.delete("/:id", async (req, res) => {
+ const { id } = req.params;
+ const { user_id, artist_id } = req.body;
+
+ if (!(await isAdminOf(user_id, artist_id))) {
+ return res
+ .status(403)
+ .json({ error: "Not authorized to delete a post this artist" });
+ }
+
+ try {
+ const result = await pool.query(`DELETE FROM posts WHERE id = $1`, [id]);
+ res.status(204).send();
+ } catch (err) {
+ res.status(500).json({ error: "Failed to delete post" });
+ }
+});
+
+export default router;
diff --git a/sona-app/server/routes/users.js b/sona-app/server/routes/users.js
new file mode 100644
index 000000000..14700994a
--- /dev/null
+++ b/sona-app/server/routes/users.js
@@ -0,0 +1,43 @@
+import express from "express";
+import pool from "../config/database.js";
+
+const router = express.Router();
+
+router.get("/:id/admin-of", async (req, res) => {
+ const { id } = req.params;
+ try {
+ const result = await pool.query(
+ `SELECT artists.*
+ FROM admin
+ JOIN artists ON artists.id = admin.artist_id
+ WHERE admin.user_id = $1`,
+ [id],
+ );
+ res.json(result.rows[0] || null);
+ } catch (err) {
+ console.error(err);
+ res.status(500).json({ error: "Failed to fetch admin-of" });
+ }
+});
+
+router.get("/:id/following", async (req, res) => {
+ const { id } = req.params;
+ try {
+ const result = await pool.query(
+ `SELECT artists.*,
+ follows.notify_on_release,
+ follows.created_at AS followed_at
+ FROM follows
+ JOIN artists ON artists.id = follows.artist_id
+ WHERE follows.user_id = $1
+ ORDER BY follows.created_at DESC`,
+ [id],
+ );
+ res.json(result.rows);
+ } catch (err) {
+ console.error(err);
+ res.status(500).json({ error: "Failed to fetch following" });
+ }
+});
+
+export default router;
diff --git a/sona-app/server/server.js b/sona-app/server/server.js
new file mode 100644
index 000000000..5ffcbb121
--- /dev/null
+++ b/sona-app/server/server.js
@@ -0,0 +1,25 @@
+import express from "express";
+import cors from "cors";
+import "./config/dotenv.js";
+import artistsRouter from "./routes/artists.js";
+import usersRouter from "./routes/users.js";
+import followsRouter from "./routes/follows.js";
+import postsRouter from "./routes/posts.js";
+import merchRouter from "./routes/merch.js";
+
+const app = express();
+const PORT = process.env.PORT || 3001;
+
+app.use(cors());
+app.use(express.json());
+
+app.get("/api/health", (req, res) => res.json({ status: "ok" }));
+app.use("/api/artists", artistsRouter);
+app.use("/api/users", usersRouter);
+app.use("/api/follows", followsRouter);
+app.use("/api/posts", postsRouter);
+app.use("/api/merch", merchRouter);
+
+app.listen(PORT, () => {
+ console.log(`Sona server running on http://localhost:${PORT}`);
+});