Final commit
This commit is contained in:
parent
91fffb3294
commit
c35e0716af
372 changed files with 16591 additions and 1 deletions
126
README.md
126
README.md
|
@ -1,2 +1,126 @@
|
|||
# team-4
|
||||
# 🎵 Supa Tunes
|
||||
|
||||
**A collaborative music queue app built by Supa Duo**
|
||||
|
||||
Turn any space into a shared music experience! Supa Tunes lets people vote on songs, add their favorites, and enjoy music together in real-time.
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- 🎧 **Real-time collaborative queue** - Everyone can add songs and vote
|
||||
- 🎵 **Spotify integration** - Play music directly through Spotify
|
||||
- 🤖 **AI-powered suggestions** - Get song recommendations based on vibes and room themes
|
||||
- 📱 **QR code sharing** - Easy room access via QR codes
|
||||
- ⚡ **Live updates** - Real-time sync across all devices
|
||||
- 🗳️ **Smart voting system** - Rate-limited voting to keep things fair
|
||||
- 🎯 **Room rules** - Set the vibe with custom music rules
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Deno](https://deno.land/) (for backend)
|
||||
- [Node.js](https://nodejs.org/) (for frontend)
|
||||
- Spotify Premium account
|
||||
- API keys for Spotify, YouTube, and Google Gemini
|
||||
|
||||
### Backend Setup
|
||||
|
||||
1. Navigate to the backend directory:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
```
|
||||
|
||||
2. Create a `.env` file with your API keys:
|
||||
|
||||
```env
|
||||
PORT=8000
|
||||
GEMINI_API_KEY=your_gemini_api_key
|
||||
SPOTIFY_CLIENT_ID=your_spotify_client_id
|
||||
SPOTIFY_CLIENT_SECRET=your_spotify_client_secret
|
||||
YT_API_KEY=your_youtube_api_key
|
||||
```
|
||||
|
||||
3. Run with Docker:
|
||||
|
||||
```bash
|
||||
docker build -f Dockerfile -t supa-tunes-backend .
|
||||
docker run --rm -p 8000:8000 supa-tunes-backend
|
||||
```
|
||||
|
||||
Or run directly with Deno:
|
||||
|
||||
```bash
|
||||
deno run --allow-net --allow-env main.ts
|
||||
```
|
||||
|
||||
### Frontend Setup
|
||||
|
||||
1. Navigate to the frontend directory:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Start the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 🎮 How to Use
|
||||
|
||||
1. **Create a Room**: Connect your Spotify account and create a new music space
|
||||
2. **Share the QR Code**: Others can scan to join your room
|
||||
3. **Add Songs**: Search for music using natural language (e.g., "upbeat 80s vibes")
|
||||
4. **Vote**: Everyone gets votes to influence what plays next
|
||||
5. **Enjoy**: Music plays automatically based on the queue and votes!
|
||||
|
||||
## 🛠️ Tech Stack
|
||||
|
||||
**Backend (Deno/TypeScript)**
|
||||
|
||||
- Deno runtime with TypeScript
|
||||
- Google Gemini AI for music suggestions
|
||||
- Spotify Web API integration
|
||||
- YouTube API for video content
|
||||
- RESTful API design
|
||||
|
||||
**Frontend (SvelteKit)**
|
||||
|
||||
- SvelteKit with TypeScript
|
||||
- Supabase for real-time database
|
||||
- Tailwind CSS for styling
|
||||
- Real-time subscriptions for live updates
|
||||
|
||||
**Infrastructure**
|
||||
|
||||
- Docker containerization
|
||||
- Supabase for database and real-time features
|
||||
- Railway for deployment
|
||||
|
||||
## 🎵 API Endpoints
|
||||
|
||||
- `POST /music` - Search for songs with AI
|
||||
- `POST /newSuggestions` - Get AI-powered room suggestions
|
||||
- `POST /track` - Get track details by ID
|
||||
- `POST /video` - Find YouTube videos for tracks
|
||||
- `GET /health` - Health check
|
||||
|
||||
## 🚀 Deployment
|
||||
|
||||
The app is containerized and ready for deployment on Railway or any Docker-compatible platform.
|
||||
|
||||
## 👥 Team Supa Duo
|
||||
|
||||
Built with ❤️ by the Supa Duo team!
|
||||
|
||||
## Thanks
|
||||
|
||||
Special thanks to Konverto Lab for providing this challenge!
|
||||
|
|
BIN
backend/.DS_Store
vendored
Normal file
BIN
backend/.DS_Store
vendored
Normal file
Binary file not shown.
1
backend/.gitignore
vendored
Normal file
1
backend/.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
.env
|
40
backend/.zed/settings.json
Normal file
40
backend/.zed/settings.json
Normal file
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"lsp": {
|
||||
"deno": {
|
||||
"settings": {
|
||||
"deno": {
|
||||
"enable": true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"languages": {
|
||||
"JavaScript": {
|
||||
"language_servers": [
|
||||
"deno",
|
||||
"!typescript-language-server",
|
||||
"!vtsls",
|
||||
"!eslint"
|
||||
],
|
||||
"formatter": "language_server"
|
||||
},
|
||||
"TypeScript": {
|
||||
"language_servers": [
|
||||
"deno",
|
||||
"!typescript-language-server",
|
||||
"!vtsls",
|
||||
"!eslint"
|
||||
],
|
||||
"formatter": "language_server"
|
||||
},
|
||||
"TSX": {
|
||||
"language_servers": [
|
||||
"deno",
|
||||
"!typescript-language-server",
|
||||
"!vtsls",
|
||||
"!eslint"
|
||||
],
|
||||
"formatter": "language_server"
|
||||
}
|
||||
}
|
||||
}
|
16
backend/.zed/tasks.json
Normal file
16
backend/.zed/tasks.json
Normal file
|
@ -0,0 +1,16 @@
|
|||
[
|
||||
{
|
||||
"label": "Run Deno Backend",
|
||||
"command": "cd .. && docker build -f backend/Dockerfile -t deno-backend ./ && docker run --rm -it -p 8000:8000 deno-backend",
|
||||
"env": {},
|
||||
"use_new_terminal": false,
|
||||
"allow_concurrent_runs": false,
|
||||
"reveal": "always",
|
||||
"reveal_target": "dock",
|
||||
"hide": "never"
|
||||
},
|
||||
{
|
||||
"label": "Run Deno Test in line",
|
||||
"command": "deno test --env-file=${ZED_WORKTREE_ROOT}/.env --allow-all --filter \"$(sed -n '${ZED_ROW}p' $ZED_FILE | grep -o 'Deno\\.test(\"[^\"]*\"' | sed 's/Deno\\.test(\"//' | sed 's/\"$//')\" $ZED_FILE"
|
||||
}
|
||||
]
|
13
backend/Dockerfile
Normal file
13
backend/Dockerfile
Normal file
|
@ -0,0 +1,13 @@
|
|||
FROM denoland/deno:latest
|
||||
|
||||
# Create working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy source
|
||||
COPY backend/ .
|
||||
|
||||
# Compile the main app
|
||||
RUN deno cache main.ts
|
||||
|
||||
# Run the app
|
||||
CMD ["deno", "run", "--allow-net", "--allow-env", "main.ts"]
|
48
backend/ai-selector.ts
Normal file
48
backend/ai-selector.ts
Normal file
|
@ -0,0 +1,48 @@
|
|||
import { GoogleGenAI } from "npm:@google/genai@1.12.0";
|
||||
import { assert } from "https://deno.land/std@0.224.0/assert/mod.ts";
|
||||
import schema from "./schemas/AiSuggestionsOutput.json" with { type: "json" };
|
||||
import { AiSuggestionsOutput } from "./types/ai.ts";
|
||||
|
||||
const ai = new GoogleGenAI({});
|
||||
|
||||
// Generate songs using AI
|
||||
export async function generateSongs(
|
||||
userPrompt: string,
|
||||
rules: string,
|
||||
): Promise<AiSuggestionsOutput> {
|
||||
const rulesText = rules && rules.trim().length > 0
|
||||
? `Make sure that they follow the following set of rules: "${rules}".`
|
||||
: "";
|
||||
const aiPrompt = `
|
||||
Based on this music request: "${userPrompt}", generate 15 suggestion for songs.
|
||||
Make sure they are real, popular songs that exist on Spotify.
|
||||
Make sure to follow the following set of costraints in order to suggest songs but bear in mind
|
||||
to return an error only in extreme cases. Ypu can be a little open even if genres clash with eah other a little bit.
|
||||
The rules are: "${rulesText}"
|
||||
The type should be either succes or error.
|
||||
Make the error as user friendly as possible ans concise, and don't make it happen too often.
|
||||
`;
|
||||
|
||||
const response = await ai.models.generateContent({
|
||||
model: "gemini-2.5-flash",
|
||||
contents: aiPrompt,
|
||||
config: { responseMimeType: "application/json", responseSchema: schema },
|
||||
});
|
||||
|
||||
const text = response.candidates?.at(0)?.content?.parts?.at(0)?.text;
|
||||
if (!text) {
|
||||
throw Error("Text not found");
|
||||
}
|
||||
|
||||
return JSON.parse(text) as AiSuggestionsOutput;
|
||||
}
|
||||
|
||||
Deno.test("generateSongs basic", async () => {
|
||||
const prompt = "Give me 5 songs by the queens";
|
||||
const rules = "Songs must vool";
|
||||
const songs = await generateSongs(prompt, rules);
|
||||
|
||||
console.log(songs);
|
||||
|
||||
assert(songs.type === "success");
|
||||
});
|
65
backend/ai-suggestion.ts
Normal file
65
backend/ai-suggestion.ts
Normal file
|
@ -0,0 +1,65 @@
|
|||
import { GoogleGenAI } from "npm:@google/genai@1.12.0";
|
||||
import { assert } from "https://deno.land/std@0.224.0/assert/mod.ts";
|
||||
import schema from "./schemas/AiSnewuggestionOutput.json" with { type: "json" };
|
||||
import { GetNewSuggestInput, GetNewSuggestOutput } from "./types/api.ts";
|
||||
import { AiSnewuggestionOutput } from "./types/ai.ts";
|
||||
|
||||
const ai = new GoogleGenAI({});
|
||||
|
||||
// Generate songs using AI
|
||||
export async function generateVibes(
|
||||
input: GetNewSuggestInput,
|
||||
): Promise<AiSnewuggestionOutput> {
|
||||
const { rules } = input;
|
||||
let aiPrompt = "";
|
||||
let contextText = "";
|
||||
|
||||
if (input.type === "scratch-suggestion") {
|
||||
contextText =
|
||||
`The music request is for a place: "${input.room_name}". Your job is to find 15 songs that match the vibe of this place.`;
|
||||
} else if (input.type === "from-songs-suggestion") {
|
||||
const songList = input.songs.map((s, i) => `${i + 1}. ${s.song}`).join(
|
||||
"\n",
|
||||
);
|
||||
contextText =
|
||||
`The music request is based on the last 10 songs:\n${songList}\nYour job is to 15 find songs that match the vibe of these songs.`;
|
||||
}
|
||||
|
||||
const rulesText = rules && rules.trim().length > 0
|
||||
? `Make sure that they follow the following set of rules: "${rules}".`
|
||||
: "";
|
||||
|
||||
aiPrompt = `
|
||||
${contextText}
|
||||
Make sure they are real, popular songs that exist on Spotify.
|
||||
${rulesText}
|
||||
`;
|
||||
|
||||
const response = await ai.models.generateContent({
|
||||
model: "gemini-2.5-flash",
|
||||
contents: aiPrompt,
|
||||
config: { responseMimeType: "application/json", responseSchema: schema },
|
||||
});
|
||||
|
||||
const text = response.candidates?.at(0)?.content?.parts?.at(0)?.text;
|
||||
if (!text) {
|
||||
throw Error("Text not found");
|
||||
}
|
||||
|
||||
return JSON.parse(text) as AiSnewuggestionOutput;
|
||||
}
|
||||
|
||||
Deno.test("generateVibes with array of songs", async () => {
|
||||
const input: GetNewSuggestInput = {
|
||||
type: "from-songs-suggestion" as const,
|
||||
rules: "The songs must be great for food",
|
||||
songs: [
|
||||
{ song: "Something - The Beatles" },
|
||||
{ song: "Come Together - The Beatles" },
|
||||
{ song: "Help! - The Beatles" },
|
||||
],
|
||||
};
|
||||
const result = await generateVibes(input);
|
||||
|
||||
console.log(result);
|
||||
});
|
0
backend/deno.json
Normal file
0
backend/deno.json
Normal file
209
backend/deno.lock
generated
Normal file
209
backend/deno.lock
generated
Normal file
|
@ -0,0 +1,209 @@
|
|||
{
|
||||
"version": "5",
|
||||
"specifiers": {
|
||||
"jsr:@std/assert@*": "1.0.13",
|
||||
"jsr:@std/internal@^1.0.6": "1.0.10",
|
||||
"npm:@google/genai@1.12.0": "1.12.0"
|
||||
},
|
||||
"jsr": {
|
||||
"@std/assert@1.0.13": {
|
||||
"integrity": "ae0d31e41919b12c656c742b22522c32fb26ed0cba32975cb0de2a273cb68b29",
|
||||
"dependencies": [
|
||||
"jsr:@std/internal"
|
||||
]
|
||||
},
|
||||
"@std/internal@1.0.10": {
|
||||
"integrity": "e3be62ce42cab0e177c27698e5d9800122f67b766a0bea6ca4867886cbde8cf7"
|
||||
}
|
||||
},
|
||||
"npm": {
|
||||
"@google/genai@1.12.0": {
|
||||
"integrity": "sha512-JBkQsULVexdM9zY4iXbm3A2dJ7El/hSPGCnxuRWPJNgeqcfYuyUnPTSy+I/v+MvTbz/occVmONSD6wn+17QLkg==",
|
||||
"dependencies": [
|
||||
"google-auth-library",
|
||||
"ws"
|
||||
]
|
||||
},
|
||||
"agent-base@7.1.4": {
|
||||
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="
|
||||
},
|
||||
"base64-js@1.5.1": {
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="
|
||||
},
|
||||
"bignumber.js@9.3.1": {
|
||||
"integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="
|
||||
},
|
||||
"buffer-equal-constant-time@1.0.1": {
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="
|
||||
},
|
||||
"debug@4.4.1": {
|
||||
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
|
||||
"dependencies": [
|
||||
"ms"
|
||||
]
|
||||
},
|
||||
"ecdsa-sig-formatter@1.0.11": {
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"dependencies": [
|
||||
"safe-buffer"
|
||||
]
|
||||
},
|
||||
"extend@3.0.2": {
|
||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="
|
||||
},
|
||||
"gaxios@6.7.1": {
|
||||
"integrity": "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==",
|
||||
"dependencies": [
|
||||
"extend",
|
||||
"https-proxy-agent",
|
||||
"is-stream",
|
||||
"node-fetch",
|
||||
"uuid"
|
||||
]
|
||||
},
|
||||
"gcp-metadata@6.1.1": {
|
||||
"integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==",
|
||||
"dependencies": [
|
||||
"gaxios",
|
||||
"google-logging-utils",
|
||||
"json-bigint"
|
||||
]
|
||||
},
|
||||
"google-auth-library@9.15.1": {
|
||||
"integrity": "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==",
|
||||
"dependencies": [
|
||||
"base64-js",
|
||||
"ecdsa-sig-formatter",
|
||||
"gaxios",
|
||||
"gcp-metadata",
|
||||
"gtoken",
|
||||
"jws"
|
||||
]
|
||||
},
|
||||
"google-logging-utils@0.0.2": {
|
||||
"integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ=="
|
||||
},
|
||||
"gtoken@7.1.0": {
|
||||
"integrity": "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==",
|
||||
"dependencies": [
|
||||
"gaxios",
|
||||
"jws"
|
||||
]
|
||||
},
|
||||
"https-proxy-agent@7.0.6": {
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"dependencies": [
|
||||
"agent-base",
|
||||
"debug"
|
||||
]
|
||||
},
|
||||
"is-stream@2.0.1": {
|
||||
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="
|
||||
},
|
||||
"json-bigint@1.0.0": {
|
||||
"integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==",
|
||||
"dependencies": [
|
||||
"bignumber.js"
|
||||
]
|
||||
},
|
||||
"jwa@2.0.1": {
|
||||
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||
"dependencies": [
|
||||
"buffer-equal-constant-time",
|
||||
"ecdsa-sig-formatter",
|
||||
"safe-buffer"
|
||||
]
|
||||
},
|
||||
"jws@4.0.0": {
|
||||
"integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==",
|
||||
"dependencies": [
|
||||
"jwa",
|
||||
"safe-buffer"
|
||||
]
|
||||
},
|
||||
"ms@2.1.3": {
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||
},
|
||||
"node-fetch@2.7.0": {
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"dependencies": [
|
||||
"whatwg-url"
|
||||
]
|
||||
},
|
||||
"safe-buffer@5.2.1": {
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="
|
||||
},
|
||||
"tr46@0.0.3": {
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="
|
||||
},
|
||||
"uuid@9.0.1": {
|
||||
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
|
||||
"bin": true
|
||||
},
|
||||
"webidl-conversions@3.0.1": {
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="
|
||||
},
|
||||
"whatwg-url@5.0.0": {
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"dependencies": [
|
||||
"tr46",
|
||||
"webidl-conversions"
|
||||
]
|
||||
},
|
||||
"ws@8.18.3": {
|
||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="
|
||||
}
|
||||
},
|
||||
"remote": {
|
||||
"https://deno.land/std@0.224.0/assert/_constants.ts": "a271e8ef5a573f1df8e822a6eb9d09df064ad66a4390f21b3e31f820a38e0975",
|
||||
"https://deno.land/std@0.224.0/assert/assert.ts": "09d30564c09de846855b7b071e62b5974b001bb72a4b797958fe0660e7849834",
|
||||
"https://deno.land/std@0.224.0/assert/assert_almost_equals.ts": "9e416114322012c9a21fa68e187637ce2d7df25bcbdbfd957cd639e65d3cf293",
|
||||
"https://deno.land/std@0.224.0/assert/assert_array_includes.ts": "14c5094471bc8e4a7895fc6aa5a184300d8a1879606574cb1cd715ef36a4a3c7",
|
||||
"https://deno.land/std@0.224.0/assert/assert_equals.ts": "3bbca947d85b9d374a108687b1a8ba3785a7850436b5a8930d81f34a32cb8c74",
|
||||
"https://deno.land/std@0.224.0/assert/assert_exists.ts": "43420cf7f956748ae6ed1230646567b3593cb7a36c5a5327269279c870c5ddfd",
|
||||
"https://deno.land/std@0.224.0/assert/assert_false.ts": "3e9be8e33275db00d952e9acb0cd29481a44fa0a4af6d37239ff58d79e8edeff",
|
||||
"https://deno.land/std@0.224.0/assert/assert_greater.ts": "5e57b201fd51b64ced36c828e3dfd773412c1a6120c1a5a99066c9b261974e46",
|
||||
"https://deno.land/std@0.224.0/assert/assert_greater_or_equal.ts": "9870030f997a08361b6f63400273c2fb1856f5db86c0c3852aab2a002e425c5b",
|
||||
"https://deno.land/std@0.224.0/assert/assert_instance_of.ts": "e22343c1fdcacfaea8f37784ad782683ec1cf599ae9b1b618954e9c22f376f2c",
|
||||
"https://deno.land/std@0.224.0/assert/assert_is_error.ts": "f856b3bc978a7aa6a601f3fec6603491ab6255118afa6baa84b04426dd3cc491",
|
||||
"https://deno.land/std@0.224.0/assert/assert_less.ts": "60b61e13a1982865a72726a5fa86c24fad7eb27c3c08b13883fb68882b307f68",
|
||||
"https://deno.land/std@0.224.0/assert/assert_less_or_equal.ts": "d2c84e17faba4afe085e6c9123a63395accf4f9e00150db899c46e67420e0ec3",
|
||||
"https://deno.land/std@0.224.0/assert/assert_match.ts": "ace1710dd3b2811c391946954234b5da910c5665aed817943d086d4d4871a8b7",
|
||||
"https://deno.land/std@0.224.0/assert/assert_not_equals.ts": "78d45dd46133d76ce624b2c6c09392f6110f0df9b73f911d20208a68dee2ef29",
|
||||
"https://deno.land/std@0.224.0/assert/assert_not_instance_of.ts": "3434a669b4d20cdcc5359779301a0588f941ffdc2ad68803c31eabdb4890cf7a",
|
||||
"https://deno.land/std@0.224.0/assert/assert_not_match.ts": "df30417240aa2d35b1ea44df7e541991348a063d9ee823430e0b58079a72242a",
|
||||
"https://deno.land/std@0.224.0/assert/assert_not_strict_equals.ts": "37f73880bd672709373d6dc2c5f148691119bed161f3020fff3548a0496f71b8",
|
||||
"https://deno.land/std@0.224.0/assert/assert_object_match.ts": "411450fd194fdaabc0089ae68f916b545a49d7b7e6d0026e84a54c9e7eed2693",
|
||||
"https://deno.land/std@0.224.0/assert/assert_rejects.ts": "4bee1d6d565a5b623146a14668da8f9eb1f026a4f338bbf92b37e43e0aa53c31",
|
||||
"https://deno.land/std@0.224.0/assert/assert_strict_equals.ts": "b4f45f0fd2e54d9029171876bd0b42dd9ed0efd8f853ab92a3f50127acfa54f5",
|
||||
"https://deno.land/std@0.224.0/assert/assert_string_includes.ts": "496b9ecad84deab72c8718735373feb6cdaa071eb91a98206f6f3cb4285e71b8",
|
||||
"https://deno.land/std@0.224.0/assert/assert_throws.ts": "c6508b2879d465898dab2798009299867e67c570d7d34c90a2d235e4553906eb",
|
||||
"https://deno.land/std@0.224.0/assert/assertion_error.ts": "ba8752bd27ebc51f723702fac2f54d3e94447598f54264a6653d6413738a8917",
|
||||
"https://deno.land/std@0.224.0/assert/equal.ts": "bddf07bb5fc718e10bb72d5dc2c36c1ce5a8bdd3b647069b6319e07af181ac47",
|
||||
"https://deno.land/std@0.224.0/assert/fail.ts": "0eba674ffb47dff083f02ced76d5130460bff1a9a68c6514ebe0cdea4abadb68",
|
||||
"https://deno.land/std@0.224.0/assert/mod.ts": "48b8cb8a619ea0b7958ad7ee9376500fe902284bb36f0e32c598c3dc34cbd6f3",
|
||||
"https://deno.land/std@0.224.0/assert/unimplemented.ts": "8c55a5793e9147b4f1ef68cd66496b7d5ba7a9e7ca30c6da070c1a58da723d73",
|
||||
"https://deno.land/std@0.224.0/assert/unreachable.ts": "5ae3dbf63ef988615b93eb08d395dda771c96546565f9e521ed86f6510c29e19",
|
||||
"https://deno.land/std@0.224.0/dotenv/load.ts": "587b342f0f6a3df071331fe6ba1c823729ab68f7d53805809475e486dd4161d7",
|
||||
"https://deno.land/std@0.224.0/dotenv/mod.ts": "0180eaeedaaf88647318811cdaa418cc64dc51fb08354f91f5f480d0a1309f7d",
|
||||
"https://deno.land/std@0.224.0/dotenv/parse.ts": "09977ff88dfd1f24f9973a338f0f91bbdb9307eb5ff6085446e7c423e4c7ba0c",
|
||||
"https://deno.land/std@0.224.0/dotenv/stringify.ts": "275da322c409170160440836342eaa7cf012a1d11a7e700d8ca4e7f2f8aa4615",
|
||||
"https://deno.land/std@0.224.0/fmt/colors.ts": "508563c0659dd7198ba4bbf87e97f654af3c34eb56ba790260f252ad8012e1c5",
|
||||
"https://deno.land/std@0.224.0/internal/diff.ts": "6234a4b493ebe65dc67a18a0eb97ef683626a1166a1906232ce186ae9f65f4e6",
|
||||
"https://deno.land/std@0.224.0/internal/format.ts": "0a98ee226fd3d43450245b1844b47003419d34d210fa989900861c79820d21c2",
|
||||
"https://deno.land/std@0.224.0/internal/mod.ts": "534125398c8e7426183e12dc255bb635d94e06d0f93c60a297723abe69d3b22e",
|
||||
"https://deno.land/x/zod@v3.22.4/ZodError.ts": "4de18ff525e75a0315f2c12066b77b5c2ae18c7c15ef7df7e165d63536fdf2ea",
|
||||
"https://deno.land/x/zod@v3.22.4/errors.ts": "5285922d2be9700cc0c70c95e4858952b07ae193aa0224be3cbd5cd5567eabef",
|
||||
"https://deno.land/x/zod@v3.22.4/external.ts": "a6cfbd61e9e097d5f42f8a7ed6f92f93f51ff927d29c9fbaec04f03cbce130fe",
|
||||
"https://deno.land/x/zod@v3.22.4/helpers/enumUtil.ts": "54efc393cc9860e687d8b81ff52e980def00fa67377ad0bf8b3104f8a5bf698c",
|
||||
"https://deno.land/x/zod@v3.22.4/helpers/errorUtil.ts": "7a77328240be7b847af6de9189963bd9f79cab32bbc61502a9db4fe6683e2ea7",
|
||||
"https://deno.land/x/zod@v3.22.4/helpers/parseUtil.ts": "f791e6e65a0340d85ad37d26cd7a3ba67126cd9957eac2b7163162155283abb1",
|
||||
"https://deno.land/x/zod@v3.22.4/helpers/partialUtil.ts": "998c2fe79795257d4d1cf10361e74492f3b7d852f61057c7c08ac0a46488b7e7",
|
||||
"https://deno.land/x/zod@v3.22.4/helpers/typeAliases.ts": "0fda31a063c6736fc3cf9090dd94865c811dfff4f3cb8707b932bf937c6f2c3e",
|
||||
"https://deno.land/x/zod@v3.22.4/helpers/util.ts": "8baf19b19b2fca8424380367b90364b32503b6b71780269a6e3e67700bb02774",
|
||||
"https://deno.land/x/zod@v3.22.4/index.ts": "d27aabd973613985574bc31f39e45cb5d856aa122ef094a9f38a463b8ef1a268",
|
||||
"https://deno.land/x/zod@v3.22.4/locales/en.ts": "a7a25cd23563ccb5e0eed214d9b31846305ddbcdb9c5c8f508b108943366ab4c",
|
||||
"https://deno.land/x/zod@v3.22.4/mod.ts": "64e55237cb4410e17d968cd08975566059f27638ebb0b86048031b987ba251c4",
|
||||
"https://deno.land/x/zod@v3.22.4/types.ts": "724185522fafe43ee56a52333958764c8c8cd6ad4effa27b42651df873fc151e"
|
||||
}
|
||||
}
|
21
backend/generate-schemas.sh
Executable file
21
backend/generate-schemas.sh
Executable file
|
@ -0,0 +1,21 @@
|
|||
#!/bin/bash
|
||||
|
||||
generate_schema_for_type() {
|
||||
yes | npx typescript-json-schema --noExtraProps true --required --refs false --out "temp_schemas/$1.json" "./types/**.ts" "$1"
|
||||
}
|
||||
|
||||
generate_schemas() {
|
||||
rm -rf temp_schemas
|
||||
mkdir temp_schemas
|
||||
|
||||
generate_schema_for_type "AiSuggestionsOutput" &
|
||||
generate_schema_for_type "AiSnewuggestionOutput" &
|
||||
wait
|
||||
|
||||
rm -rf ./schemas
|
||||
mv temp_schemas schemas
|
||||
}
|
||||
|
||||
# Run the schema generation
|
||||
generate_schemas
|
||||
echo "Schema generated successfully!"
|
190
backend/main.ts
Normal file
190
backend/main.ts
Normal file
|
@ -0,0 +1,190 @@
|
|||
const PORT = parseInt(Deno.env.get("PORT") || "8000");
|
||||
import {
|
||||
GetNewSuggestInput,
|
||||
GetNewSuggestOutput,
|
||||
GetSongsTextParams,
|
||||
GetTrackInput,
|
||||
GetYoutubeInput,
|
||||
} from "./types/api.ts";
|
||||
import { getSong } from "./routes/get-song.ts";
|
||||
import { getSpotifyTrackById } from "./routes/get-track.ts";
|
||||
import { getVids } from "./routes/get-video.ts";
|
||||
import { getSpotifyAccessToken } from "./spotify-api.ts";
|
||||
import { generateVibes } from "./ai-suggestion.ts";
|
||||
import { getNewSuggestion } from "./routes/get-new-suggest.ts";
|
||||
|
||||
const routes = [
|
||||
{
|
||||
pattern: new URLPattern({ pathname: "/music" }),
|
||||
handler: async (req: Request) => {
|
||||
const params = await req.json();
|
||||
const songs = await getSong(params);
|
||||
|
||||
return new Response(JSON.stringify(songs), {
|
||||
status: 200,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
pattern: new URLPattern({ pathname: "/health" }),
|
||||
handler: (_req: Request) => {
|
||||
return new Response("All good", {
|
||||
status: 200,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
pattern: new URLPattern({ pathname: "/video" }),
|
||||
handler: async (req: Request) => {
|
||||
const params: GetYoutubeInput = await req.json();
|
||||
const vids = await getVids(params);
|
||||
|
||||
return new Response(JSON.stringify(vids), {
|
||||
status: 200,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
pattern: new URLPattern({ pathname: "/track" }),
|
||||
handler: async (req: Request) => {
|
||||
const params: GetTrackInput = await req.json();
|
||||
const track = await getSpotifyTrackById(params.trackId);
|
||||
|
||||
return new Response(JSON.stringify(track), {
|
||||
status: 200,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
pattern: new URLPattern({ pathname: "/newSuggestions" }),
|
||||
handler: async (req: Request) => {
|
||||
const params: GetNewSuggestInput = await req.json();
|
||||
const suggestions: GetNewSuggestOutput = await getNewSuggestion(params);
|
||||
return new Response(JSON.stringify(suggestions), {
|
||||
status: 200,
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const cors = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Methods": "POST, GET, OPTIONS",
|
||||
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
||||
};
|
||||
|
||||
const handler = async (req: Request): Promise<Response> => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: cors,
|
||||
});
|
||||
}
|
||||
|
||||
let response: Response | undefined;
|
||||
try {
|
||||
for (const route of routes) {
|
||||
const match = route.pattern.exec(req.url);
|
||||
if (match) {
|
||||
response = await route.handler(req);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Server error:", error);
|
||||
response = new Response(error.toString() || "Internal server error", {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
|
||||
response ??= new Response("Not found", { status: 404 });
|
||||
|
||||
for (const [key, value] of Object.entries(cors)) {
|
||||
response.headers.set(key, value);
|
||||
}
|
||||
return response;
|
||||
};
|
||||
|
||||
Deno.serve({ hostname: "0.0.0.0", port: PORT }, handler);
|
||||
|
||||
Deno.test("music API complete workflow", async () => {
|
||||
const spotify_api_key = getSpotifyAccessToken();
|
||||
if (!spotify_api_key) throw Error("Testing token not found");
|
||||
|
||||
const testInput: GetSongsTextParams = {
|
||||
prompt: "i want summer feeling music",
|
||||
rules: "make it fun and suitable for children",
|
||||
};
|
||||
const res = await fetch(`http://localhost:${PORT}/music`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(testInput),
|
||||
});
|
||||
|
||||
if (res.status !== 200) throw new Error(`Unexpected status: ${res.status}`);
|
||||
const json = await res.json();
|
||||
console.log("API Response:", json);
|
||||
});
|
||||
|
||||
Deno.test("video API complete workflow", async () => {
|
||||
const yt_api_key = Deno.env.get("YT_API_KEY");
|
||||
if (!yt_api_key) throw Error("Testing token not found");
|
||||
|
||||
const input: GetYoutubeInput = {
|
||||
title: "Here Comes The Sun",
|
||||
artist: "The Beatles",
|
||||
};
|
||||
const res = await fetch(`http://localhost:${PORT}/video`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
if (res.status !== 200) throw new Error(`Unexpected status: ${res.status}`);
|
||||
const json = await res.json();
|
||||
console.log("Video API Response:", json);
|
||||
});
|
||||
|
||||
Deno.test("track API complete workflow", async () => {
|
||||
const spotify_api_key = getSpotifyAccessToken();
|
||||
if (!spotify_api_key) throw Error("Testing token not found");
|
||||
|
||||
const input: GetTrackInput = { trackId: "6dGnYIeXmHdcikdzNNDMm2" };
|
||||
const res = await fetch(`http://localhost:${PORT}/track`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
if (res.status !== 200) throw new Error(`Unexpected status: ${res.status}`);
|
||||
const json = await res.json();
|
||||
console.log("Track API Response:", json);
|
||||
});
|
||||
|
||||
Deno.test("new suggestion API complete workflow", async () => {
|
||||
const testInput: GetNewSuggestInput = {
|
||||
type: "from-songs-suggestion",
|
||||
rules: "The songs should be upbeat and suitable for a party",
|
||||
songs: [
|
||||
{ song: "Thriller - Michael Jackson" },
|
||||
{ song: "Uptown Funk - Bruno Mars" },
|
||||
{ song: "24K Magic - Bruno Mars" },
|
||||
],
|
||||
};
|
||||
|
||||
const res = await fetch(`http://localhost:${PORT}/newSuggestions`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(testInput),
|
||||
});
|
||||
|
||||
if (res.status !== 200) throw new Error(`Unexpected status: ${res.status}`);
|
||||
|
||||
const json = await res.json();
|
||||
console.log("New Suggestion API Response:", json);
|
||||
|
||||
// Validate that the response contains a songs array.
|
||||
if (!json.songs || !Array.isArray(json.songs)) {
|
||||
throw new Error("Response does not contain a valid songs array");
|
||||
}
|
||||
});
|
15
backend/routes/get-new-suggest.ts
Normal file
15
backend/routes/get-new-suggest.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
import { searchSpotifyTracks } from "../spotify-api.ts";
|
||||
import { GetNewSuggestInput, GetNewSuggestOutput } from "../types/api.ts";
|
||||
import { generateVibes } from "../ai-suggestion.ts";
|
||||
|
||||
export async function getNewSuggestion(
|
||||
params: GetNewSuggestInput,
|
||||
): Promise<GetNewSuggestOutput> {
|
||||
const ai_suggestion = await generateVibes(params);
|
||||
|
||||
const suggestionResults = await searchSpotifyTracks(
|
||||
ai_suggestion.songs,
|
||||
);
|
||||
|
||||
return { songs: suggestionResults };
|
||||
}
|
18
backend/routes/get-song.ts
Normal file
18
backend/routes/get-song.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { generateSongs } from "../ai-selector.ts";
|
||||
import { searchSpotifyTracks } from "../spotify-api.ts";
|
||||
import { GetSongsTextOutput, GetSongsTextParams } from "../types/api.ts";
|
||||
|
||||
export async function getSong(
|
||||
params: GetSongsTextParams,
|
||||
): Promise<GetSongsTextOutput> {
|
||||
const ai_response = await generateSongs(params.prompt, params.rules);
|
||||
if (ai_response.type == "error") {
|
||||
return ai_response;
|
||||
}
|
||||
|
||||
const spotifyResults = await searchSpotifyTracks(
|
||||
ai_response.songs,
|
||||
);
|
||||
|
||||
return { type: "success", songs: spotifyResults };
|
||||
}
|
87
backend/routes/get-track.ts
Normal file
87
backend/routes/get-track.ts
Normal file
|
@ -0,0 +1,87 @@
|
|||
import { SpotifyTrack } from "../types/spotify.ts";
|
||||
import { getSpotifyAccessToken } from "../spotify-api.ts";
|
||||
import { assert } from "jsr:@std/assert/assert";
|
||||
|
||||
// Simple in-memory cache for track objects by ID
|
||||
const trackCache = new Map<string, SpotifyTrack>();
|
||||
|
||||
/**
|
||||
* Inner function that performs the actual API call without retries.
|
||||
*/
|
||||
async function fetchSpotifyTrackById(trackId: string): Promise<SpotifyTrack> {
|
||||
const accessToken = await getSpotifyAccessToken();
|
||||
const url = `https://api.spotify.com/v1/tracks/${trackId}?market=US`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"Authorization": `Bearer ${accessToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch track: ${response.status} ${await response.text()}`,
|
||||
);
|
||||
}
|
||||
|
||||
const track: SpotifyTrack = await response.json();
|
||||
|
||||
// Cache the result
|
||||
trackCache.set(trackId, track);
|
||||
|
||||
return track;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches a Spotify track by its ID, with caching.
|
||||
* Returns the full SpotifyTrack object.
|
||||
*/
|
||||
export async function getSpotifyTrackById(
|
||||
trackId: string,
|
||||
): Promise<SpotifyTrack> {
|
||||
// Check cache first
|
||||
if (trackCache.has(trackId)) {
|
||||
return trackCache.get(trackId)!;
|
||||
}
|
||||
|
||||
const maxRetries = 10;
|
||||
let lastError: Error;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
return await fetchSpotifyTrackById(trackId);
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
if (attempt === maxRetries) {
|
||||
throw Error(`Attempt ${attempt}: ${lastError}`);
|
||||
}
|
||||
// Optional: add delay between retries
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000 * attempt));
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError!;
|
||||
}
|
||||
|
||||
Deno.test("getSpotifyTrackById basic", async () => {
|
||||
// Example: Blinding Lights by The Weeknd
|
||||
const trackId = "2EqlS6tkEnglzr7tkKAAYD";
|
||||
|
||||
// First call (should fetch from API)
|
||||
const track1 = await getSpotifyTrackById(trackId);
|
||||
assert(track1.id === trackId);
|
||||
assert(typeof track1.name === "string");
|
||||
assert(Array.isArray(track1.artists));
|
||||
|
||||
// Second call (should hit cache)
|
||||
const start = performance.now();
|
||||
const track2 = await getSpotifyTrackById(trackId);
|
||||
const elapsed = performance.now() - start;
|
||||
|
||||
console.log(track2);
|
||||
console.log("Second call elapsed:", elapsed, "ms");
|
||||
assert(track2.id === trackId);
|
||||
assert(typeof track2.name === "string");
|
||||
assert(Array.isArray(track2.artists));
|
||||
assert(elapsed < 10, `Second call took too long: ${elapsed}ms`);
|
||||
});
|
9
backend/routes/get-video.ts
Normal file
9
backend/routes/get-video.ts
Normal file
|
@ -0,0 +1,9 @@
|
|||
import { GetYoutubeInput, GetYoutubeOutput } from "../types/api.ts";
|
||||
import { searchYTVideo } from "../youtube-api.ts";
|
||||
|
||||
export async function getVids(
|
||||
params: GetYoutubeInput,
|
||||
): Promise<GetYoutubeOutput> {
|
||||
const videoId = await searchYTVideo(params);
|
||||
return { videoId };
|
||||
}
|
30
backend/schemas/AiSnewuggestionOutput.json
Normal file
30
backend/schemas/AiSnewuggestionOutput.json
Normal file
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"songs": {
|
||||
"items": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"artist": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"artist",
|
||||
"title"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"songs"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
|
56
backend/schemas/AiSuggestionsOutput.json
Normal file
56
backend/schemas/AiSuggestionsOutput.json
Normal file
|
@ -0,0 +1,56 @@
|
|||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"anyOf": [
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"songs": {
|
||||
"items": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"artist": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"artist",
|
||||
"title"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"type": {
|
||||
"const": "success",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"songs",
|
||||
"type"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"error": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"const": "error",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"error",
|
||||
"type"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
136
backend/spotify-api.ts
Normal file
136
backend/spotify-api.ts
Normal file
|
@ -0,0 +1,136 @@
|
|||
import { assert } from "jsr:@std/assert/assert";
|
||||
import { SpotifySearchResponse, SpotifyTrack } from "./types/spotify.ts";
|
||||
import { SongSearch } from "./types/ai.ts";
|
||||
|
||||
export async function searchSpotifyTracks(
|
||||
songs: SongSearch[],
|
||||
): Promise<SpotifyTrack[]> {
|
||||
const results: SpotifyTrack[] = [];
|
||||
|
||||
const trackPromises = songs.map(async (song) => {
|
||||
try {
|
||||
const query = `track:"${song.title}" artist:"${song.artist}"`;
|
||||
|
||||
const params = new URLSearchParams({
|
||||
q: query,
|
||||
type: "track",
|
||||
market: "US",
|
||||
limit: "1",
|
||||
offset: "0",
|
||||
});
|
||||
|
||||
const url = `https://api.spotify.com/v1/search?${params.toString()}`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"Authorization": "Bearer " + await getSpotifyAccessToken(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw Error("Unsuccesful response");
|
||||
}
|
||||
|
||||
const data: SpotifySearchResponse = await response.json();
|
||||
|
||||
const track = data.tracks.items.at(0);
|
||||
if (track) {
|
||||
return track;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const tracks = await Promise.all(trackPromises);
|
||||
for (const track of tracks) {
|
||||
if (track) {
|
||||
results.push(track);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Spotify access token using Client Credentials Flow.
|
||||
* Reads credentials from environment variables, caches token in memory with expiry.
|
||||
* Throws if credentials are missing or request fails.
|
||||
*/
|
||||
let spotifyTokenCache: { token: string; expiresAt: number } | null = null;
|
||||
|
||||
export async function getSpotifyAccessToken(): Promise<string> {
|
||||
const clientId = Deno.env.get("SPOTIFY_CLIENT_ID");
|
||||
const clientSecret = Deno.env.get("SPOTIFY_CLIENT_SECRET");
|
||||
|
||||
if (!clientId || !clientSecret) {
|
||||
throw new Error("Spotify credentials not found in environment variables.");
|
||||
}
|
||||
|
||||
// If cached and not expired, return cached token
|
||||
if (
|
||||
spotifyTokenCache &&
|
||||
spotifyTokenCache.expiresAt > Date.now()
|
||||
) {
|
||||
return spotifyTokenCache.token;
|
||||
}
|
||||
|
||||
const tokenResponse = await fetch("https://accounts.spotify.com/api/token", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Authorization": "Basic " +
|
||||
btoa(`${clientId}:${clientSecret}`),
|
||||
},
|
||||
body: "grant_type=client_credentials",
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch Spotify access token: ${tokenResponse.status} ${await tokenResponse
|
||||
.text()}`,
|
||||
);
|
||||
}
|
||||
|
||||
const tokenData = await tokenResponse.json();
|
||||
const accessToken = tokenData.access_token;
|
||||
const expiresIn = tokenData.expires_in; // seconds
|
||||
|
||||
if (!accessToken || !expiresIn) {
|
||||
throw new Error("Invalid token response from Spotify.");
|
||||
}
|
||||
|
||||
// Cache token with expiry
|
||||
spotifyTokenCache = {
|
||||
token: accessToken,
|
||||
expiresAt: Date.now() + expiresIn * 1000 - 10_000, // 10s early
|
||||
};
|
||||
|
||||
return accessToken;
|
||||
}
|
||||
|
||||
Deno.test("getSpotifyAccessToken caching", async () => {
|
||||
await getSpotifyAccessToken();
|
||||
|
||||
const start2 = performance.now();
|
||||
const token2 = await getSpotifyAccessToken();
|
||||
const elapsed2 = performance.now() - start2;
|
||||
|
||||
assert(elapsed2 < 10, `Second call took too long: ${elapsed2}ms`);
|
||||
});
|
||||
|
||||
Deno.test("searchSpotifyTracks basic", async () => {
|
||||
const songs = [
|
||||
{ title: "Blinding Lights", artist: "The Weeknd" },
|
||||
{ title: "Levitating", artist: "Dua Lipa" },
|
||||
];
|
||||
|
||||
const spotify_api_key = getSpotifyAccessToken();
|
||||
if (!spotify_api_key) throw Error("Testing token not found");
|
||||
const tracks = await searchSpotifyTracks(songs);
|
||||
|
||||
console.log(tracks);
|
||||
|
||||
assert(tracks.length > 0);
|
||||
});
|
19
backend/types/ai.ts
Normal file
19
backend/types/ai.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
export type AiSuggestionsOutput = {
|
||||
type: "success";
|
||||
songs: {
|
||||
title: string;
|
||||
artist: string;
|
||||
}[];
|
||||
} | {
|
||||
type: "error";
|
||||
error: string;
|
||||
};
|
||||
|
||||
export interface SongSearch {
|
||||
title: string;
|
||||
artist: string;
|
||||
}
|
||||
|
||||
export interface AiSnewuggestionOutput {
|
||||
songs: SongSearch[];
|
||||
}
|
47
backend/types/api.ts
Normal file
47
backend/types/api.ts
Normal file
|
@ -0,0 +1,47 @@
|
|||
import { SpotifyTrack } from "./spotify.ts";
|
||||
|
||||
export interface GetSongsTextParams {
|
||||
prompt: string;
|
||||
rules: string;
|
||||
}
|
||||
|
||||
export type GetSongsTextOutput = {
|
||||
type: "success";
|
||||
songs: SpotifyTrack[];
|
||||
} | {
|
||||
type: "error";
|
||||
error: string;
|
||||
};
|
||||
|
||||
export interface GetYoutubeInput {
|
||||
title: string;
|
||||
artist: string;
|
||||
}
|
||||
|
||||
export interface GetYoutubeOutput {
|
||||
videoId?: string;
|
||||
}
|
||||
|
||||
export interface GetTrackInput {
|
||||
trackId: string;
|
||||
}
|
||||
|
||||
export interface GetTrackOutput {
|
||||
track: SpotifyTrack;
|
||||
}
|
||||
|
||||
export type GetNewSuggestInput =
|
||||
& {
|
||||
rules: string;
|
||||
}
|
||||
& ({
|
||||
type: "scratch-suggestion";
|
||||
room_name: string;
|
||||
} | {
|
||||
type: "from-songs-suggestion";
|
||||
songs: { song: string }[];
|
||||
});
|
||||
|
||||
export type GetNewSuggestOutput = {
|
||||
songs: SpotifyTrack[];
|
||||
};
|
97
backend/types/spotify.ts
Normal file
97
backend/types/spotify.ts
Normal file
|
@ -0,0 +1,97 @@
|
|||
export interface SpotifyArtist {
|
||||
external_urls: {
|
||||
spotify: string;
|
||||
};
|
||||
href: string;
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
uri: string;
|
||||
}
|
||||
|
||||
export interface SpotifyAlbum {
|
||||
album_type: string;
|
||||
total_tracks: number;
|
||||
available_markets: string[];
|
||||
external_urls: {
|
||||
spotify: string;
|
||||
};
|
||||
href: string;
|
||||
id: string;
|
||||
images: Array<{
|
||||
url: string;
|
||||
height: number;
|
||||
width: number;
|
||||
}>;
|
||||
name: string;
|
||||
release_date: string;
|
||||
release_date_precision: string;
|
||||
type: string;
|
||||
uri: string;
|
||||
artists: SpotifyArtist[];
|
||||
is_playable?: boolean;
|
||||
}
|
||||
|
||||
export interface SpotifyTrack {
|
||||
album: SpotifyAlbum;
|
||||
artists: SpotifyArtist[];
|
||||
available_markets: string[];
|
||||
disc_number: number;
|
||||
duration_ms: number;
|
||||
explicit: boolean;
|
||||
external_ids: {
|
||||
isrc?: string;
|
||||
};
|
||||
external_urls: {
|
||||
spotify: string;
|
||||
};
|
||||
href: string;
|
||||
id: string;
|
||||
is_playable?: boolean;
|
||||
name: string;
|
||||
popularity: number;
|
||||
preview_url: string | null;
|
||||
track_number: number;
|
||||
type: string;
|
||||
uri: string;
|
||||
is_local: boolean;
|
||||
}
|
||||
|
||||
export interface SpotifySearchResponse {
|
||||
tracks: {
|
||||
href: string;
|
||||
limit: number;
|
||||
next: string | null;
|
||||
offset: number;
|
||||
previous: string | null;
|
||||
total: number;
|
||||
items: SpotifyTrack[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface SearchResultTrack {
|
||||
id: string;
|
||||
name: string;
|
||||
artists: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
spotify_url: string;
|
||||
}>;
|
||||
album: {
|
||||
id: string;
|
||||
name: string;
|
||||
release_date: string;
|
||||
images: Array<{
|
||||
url: string;
|
||||
height: number;
|
||||
width: number;
|
||||
}>;
|
||||
spotify_url: string;
|
||||
};
|
||||
duration_ms: number;
|
||||
popularity: number;
|
||||
preview_url: string | null;
|
||||
spotify_url: string;
|
||||
explicit: boolean;
|
||||
available_markets: string[];
|
||||
}
|
4
backend/types/youtube.ts
Normal file
4
backend/types/youtube.ts
Normal file
|
@ -0,0 +1,4 @@
|
|||
export interface Vids {
|
||||
title: string;
|
||||
artist: string;
|
||||
}
|
46
backend/youtube-api.ts
Normal file
46
backend/youtube-api.ts
Normal file
|
@ -0,0 +1,46 @@
|
|||
import { assert } from "jsr:@std/assert/assert";
|
||||
import { Vids } from "./types/youtube.ts";
|
||||
|
||||
export async function searchYTVideo(
|
||||
videos: Vids,
|
||||
): Promise<string | undefined> {
|
||||
const query = `${videos.title} ${videos.artist}`;
|
||||
const params = new URLSearchParams({
|
||||
part: "snippet",
|
||||
q: query,
|
||||
type: "video",
|
||||
key: Deno.env.get("YT_API_KEY")!, // Replace with your actual API key
|
||||
});
|
||||
|
||||
const endpoint =
|
||||
`https://www.googleapis.com/youtube/v3/search?${params.toString()}`;
|
||||
|
||||
const response = await fetch(endpoint);
|
||||
if (!response.ok) {
|
||||
throw new Error(`YouTube API error: ${response.statusText}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
const videoId = data.items && data.items.length > 0
|
||||
? data.items[0].id.videoId
|
||||
: undefined;
|
||||
|
||||
if (!videoId) {
|
||||
throw new Error("No video found for the given title and artist.");
|
||||
}
|
||||
|
||||
return videoId;
|
||||
}
|
||||
|
||||
Deno.test("searchYT", async () => {
|
||||
const yt_api_key = Deno.env.get("YT_API_KEY");
|
||||
if (!yt_api_key) throw Error("Testing token not found");
|
||||
const videoId = await searchYTVideo({
|
||||
title: "Here Comes The Sun",
|
||||
artist: "The Beatles",
|
||||
});
|
||||
|
||||
console.log("Here's the video ID:", videoId);
|
||||
|
||||
assert(typeof videoId === "string" && videoId);
|
||||
});
|
23
frontend/.gitignore
vendored
Normal file
23
frontend/.gitignore
vendored
Normal file
|
@ -0,0 +1,23 @@
|
|||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
1
frontend/.npmrc
Normal file
1
frontend/.npmrc
Normal file
|
@ -0,0 +1 @@
|
|||
engine-strict=true
|
9
frontend/.prettierignore
Normal file
9
frontend/.prettierignore
Normal file
|
@ -0,0 +1,9 @@
|
|||
# Package Managers
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
bun.lock
|
||||
bun.lockb
|
||||
|
||||
# Miscellaneous
|
||||
/static/
|
17
frontend/.prettierrc
Normal file
17
frontend/.prettierrc
Normal file
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"useTabs": false,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"tabWidth": 4,
|
||||
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.svelte",
|
||||
"options": {
|
||||
"parser": "svelte"
|
||||
}
|
||||
}
|
||||
],
|
||||
"tailwindStylesheet": "./src/app.css"
|
||||
}
|
6
frontend/.zed/tasks.json
Normal file
6
frontend/.zed/tasks.json
Normal file
|
@ -0,0 +1,6 @@
|
|||
[
|
||||
{
|
||||
"label": "Generate types",
|
||||
"command": "yes | npx supabase gen types typescript --project-id htdssaqidnitlbswknqw > src/lib/database.types.ts"
|
||||
}
|
||||
]
|
21
frontend/Dockerfile
Normal file
21
frontend/Dockerfile
Normal file
|
@ -0,0 +1,21 @@
|
|||
# Use the Node alpine official image
|
||||
# https://hub.docker.com/_/node
|
||||
FROM node:lts-alpine
|
||||
|
||||
# Create and change to the app directory.
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the files to the container image
|
||||
COPY frontend/package*.json ./
|
||||
|
||||
# Install packages
|
||||
RUN npm ci
|
||||
|
||||
# Copy local code to the container image.
|
||||
COPY frontend/ ./
|
||||
|
||||
# Build the app.
|
||||
RUN npm run build
|
||||
|
||||
# Serve the app
|
||||
CMD ["npm", "run", "start"]
|
16
frontend/components.json
Normal file
16
frontend/components.json
Normal file
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"$schema": "https://shadcn-svelte.com/schema.json",
|
||||
"tailwind": {
|
||||
"css": "src/app.css",
|
||||
"baseColor": "zinc"
|
||||
},
|
||||
"aliases": {
|
||||
"components": "$lib/components",
|
||||
"utils": "$lib/utils",
|
||||
"ui": "$lib/components/ui",
|
||||
"hooks": "$lib/hooks",
|
||||
"lib": "$lib"
|
||||
},
|
||||
"typescript": true,
|
||||
"registry": "https://shadcn-svelte.com/registry"
|
||||
}
|
6
frontend/cspell.json
Normal file
6
frontend/cspell.json
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"words": [
|
||||
"supabase",
|
||||
"interted"
|
||||
]
|
||||
}
|
4740
frontend/package-lock.json
generated
Normal file
4740
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
53
frontend/package.json
Normal file
53
frontend/package.json
Normal file
|
@ -0,0 +1,53 @@
|
|||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"start": "node build/index.js",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"format": "prettier --write .",
|
||||
"lint": "prettier --check ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@internationalized/date": "^3.8.2",
|
||||
"@lucide/svelte": "^0.515.0",
|
||||
"@sveltejs/adapter-auto": "^6.0.1",
|
||||
"@sveltejs/adapter-node": "^5.2.13",
|
||||
"@sveltejs/kit": "^2.26.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.1.0",
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@tanstack/table-core": "^8.21.3",
|
||||
"bits-ui": "^2.9.0",
|
||||
"clsx": "^2.1.1",
|
||||
"embla-carousel-svelte": "^8.6.0",
|
||||
"formsnap": "^2.0.1",
|
||||
"layerchart": "^2.0.0-next.27",
|
||||
"mode-watcher": "^1.1.0",
|
||||
"paneforge": "^1.0.1",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier-plugin-svelte": "^3.4.0",
|
||||
"prettier-plugin-tailwindcss": "^0.6.14",
|
||||
"svelte": "^5.37.1",
|
||||
"svelte-check": "^4.3.0",
|
||||
"svelte-sonner": "^1.0.5",
|
||||
"sveltekit-superforms": "^2.27.1",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tailwind-variants": "^1.0.0",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"tw-animate-css": "^1.3.6",
|
||||
"typescript": "^5.9.2",
|
||||
"vaul-svelte": "^1.0.0-next.7",
|
||||
"vite": "^7.0.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@supabase/supabase-js": "^2.53.0",
|
||||
"@types/qrcode": "^1.5.5",
|
||||
"qrcode": "^1.5.4"
|
||||
}
|
||||
}
|
128
frontend/src/app.css
Normal file
128
frontend/src/app.css
Normal file
|
@ -0,0 +1,128 @@
|
|||
@import 'tailwindcss';
|
||||
|
||||
@import 'tw-animate-css';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
* {
|
||||
scrollbar-color: color-mix(in oklch, currentColor 35%, transparent) transparent;
|
||||
}
|
||||
*:hover {
|
||||
scrollbar-color: color-mix(in oklch, currentColor 60%, transparent) transparent;
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.141 0.005 285.823);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||
--primary: oklch(0.21 0.006 285.885);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.967 0.001 286.375);
|
||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||
--accent: oklch(0.967 0.001 286.375);
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.705 0.015 286.067);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||
--sidebar-primary: oklch(0.21 0.006 285.885);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||
--sidebar-ring: oklch(0.705 0.015 286.067);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.141 0.005 285.823);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.21 0.006 285.885);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.92 0.004 286.32);
|
||||
--primary-foreground: oklch(0.21 0.006 285.885);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.274 0.006 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.552 0.016 285.938);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.21 0.006 285.885);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.552 0.016 285.938);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
13
frontend/src/app.d.ts
vendored
Normal file
13
frontend/src/app.d.ts
vendored
Normal file
|
@ -0,0 +1,13 @@
|
|||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
14
frontend/src/app.html
Normal file
14
frontend/src/app.html
Normal file
|
@ -0,0 +1,14 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, user-scalable=no, viewport-fit=cover`"
|
||||
/>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
1
frontend/src/lib/assets/favicon.svg
Normal file
1
frontend/src/lib/assets/favicon.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
After Width: | Height: | Size: 1.5 KiB |
171
frontend/src/lib/components/app-sidebar.svelte
Normal file
171
frontend/src/lib/components/app-sidebar.svelte
Normal file
|
@ -0,0 +1,171 @@
|
|||
<script lang="ts" module>
|
||||
// sample data
|
||||
const data = {
|
||||
versions: ['1.0.1', '1.1.0-alpha', '2.0.0-beta1'],
|
||||
navMain: [
|
||||
{
|
||||
title: 'Getting Started',
|
||||
url: '#',
|
||||
items: [
|
||||
{
|
||||
title: 'Installation',
|
||||
url: '#'
|
||||
},
|
||||
{
|
||||
title: 'Project Structure',
|
||||
url: '#'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Building Your Application',
|
||||
url: '#',
|
||||
items: [
|
||||
{
|
||||
title: 'Routing',
|
||||
url: '#'
|
||||
},
|
||||
{
|
||||
title: 'Data Fetching',
|
||||
url: '#',
|
||||
isActive: true
|
||||
},
|
||||
{
|
||||
title: 'Rendering',
|
||||
url: '#'
|
||||
},
|
||||
{
|
||||
title: 'Caching',
|
||||
url: '#'
|
||||
},
|
||||
{
|
||||
title: 'Styling',
|
||||
url: '#'
|
||||
},
|
||||
{
|
||||
title: 'Optimizing',
|
||||
url: '#'
|
||||
},
|
||||
{
|
||||
title: 'Configuring',
|
||||
url: '#'
|
||||
},
|
||||
{
|
||||
title: 'Testing',
|
||||
url: '#'
|
||||
},
|
||||
{
|
||||
title: 'Authentication',
|
||||
url: '#'
|
||||
},
|
||||
{
|
||||
title: 'Deploying',
|
||||
url: '#'
|
||||
},
|
||||
{
|
||||
title: 'Upgrading',
|
||||
url: '#'
|
||||
},
|
||||
{
|
||||
title: 'Examples',
|
||||
url: '#'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'API Reference',
|
||||
url: '#',
|
||||
items: [
|
||||
{
|
||||
title: 'Components',
|
||||
url: '#'
|
||||
},
|
||||
{
|
||||
title: 'File Conventions',
|
||||
url: '#'
|
||||
},
|
||||
{
|
||||
title: 'Functions',
|
||||
url: '#'
|
||||
},
|
||||
{
|
||||
title: 'next.config.js Options',
|
||||
url: '#'
|
||||
},
|
||||
{
|
||||
title: 'CLI',
|
||||
url: '#'
|
||||
},
|
||||
{
|
||||
title: 'Edge Runtime',
|
||||
url: '#'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Architecture',
|
||||
url: '#',
|
||||
items: [
|
||||
{
|
||||
title: 'Accessibility',
|
||||
url: '#'
|
||||
},
|
||||
{
|
||||
title: 'Fast Refresh',
|
||||
url: '#'
|
||||
},
|
||||
{
|
||||
title: 'Svelte Compiler',
|
||||
url: '#'
|
||||
},
|
||||
{
|
||||
title: 'Supported Browsers',
|
||||
url: '#'
|
||||
},
|
||||
{
|
||||
title: 'Rollup',
|
||||
url: '#'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import SearchForm from './search-form.svelte';
|
||||
import VersionSwitcher from './version-switcher.svelte';
|
||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||
import type { ComponentProps } from 'svelte';
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: ComponentProps<typeof Sidebar.Root> = $props();
|
||||
</script>
|
||||
|
||||
<Sidebar.Root {...restProps} bind:ref>
|
||||
<Sidebar.Header>
|
||||
<VersionSwitcher versions={data.versions} defaultVersion={data.versions[0]} />
|
||||
<SearchForm />
|
||||
</Sidebar.Header>
|
||||
<Sidebar.Content>
|
||||
<!-- We create a Sidebar.Group for each parent. -->
|
||||
{#each data.navMain as group (group.title)}
|
||||
<Sidebar.Group>
|
||||
<Sidebar.GroupLabel>{group.title}</Sidebar.GroupLabel>
|
||||
<Sidebar.GroupContent>
|
||||
<Sidebar.Menu>
|
||||
{#each group.items as item (item.title)}
|
||||
<Sidebar.MenuItem>
|
||||
<Sidebar.MenuButton isActive={item.isActive}>
|
||||
{#snippet child({ props })}
|
||||
<a href={item.url} {...props}>{item.title}</a>
|
||||
{/snippet}
|
||||
</Sidebar.MenuButton>
|
||||
</Sidebar.MenuItem>
|
||||
{/each}
|
||||
</Sidebar.Menu>
|
||||
</Sidebar.GroupContent>
|
||||
</Sidebar.Group>
|
||||
{/each}
|
||||
</Sidebar.Content>
|
||||
<Sidebar.Rail />
|
||||
</Sidebar.Root>
|
21
frontend/src/lib/components/search-form.svelte
Normal file
21
frontend/src/lib/components/search-form.svelte
Normal file
|
@ -0,0 +1,21 @@
|
|||
<script lang="ts">
|
||||
import { Label } from '$lib/components/ui/label/index.js';
|
||||
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
|
||||
import type { WithElementRef } from '$lib/utils.js';
|
||||
import SearchIcon from '@lucide/svelte/icons/search';
|
||||
import type { HTMLFormAttributes } from 'svelte/elements';
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: WithElementRef<HTMLFormAttributes> = $props();
|
||||
</script>
|
||||
|
||||
<form bind:this={ref} {...restProps}>
|
||||
<Sidebar.Group class="py-0">
|
||||
<Sidebar.GroupContent class="relative">
|
||||
<Label for="search" class="sr-only">Search</Label>
|
||||
<Sidebar.Input id="search" placeholder="Search the docs..." class="pl-8" />
|
||||
<SearchIcon
|
||||
class="pointer-events-none absolute top-1/2 left-2 size-4 -translate-y-1/2 opacity-50 select-none"
|
||||
/>
|
||||
</Sidebar.GroupContent>
|
||||
</Sidebar.Group>
|
||||
</form>
|
|
@ -0,0 +1,22 @@
|
|||
<script lang="ts">
|
||||
import { Accordion as AccordionPrimitive } from 'bits-ui';
|
||||
import { cn, type WithoutChild } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithoutChild<AccordionPrimitive.ContentProps> = $props();
|
||||
</script>
|
||||
|
||||
<AccordionPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="accordion-content"
|
||||
class="overflow-hidden text-sm data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||
{...restProps}
|
||||
>
|
||||
<div class={cn('pt-0 pb-4', className)}>
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</AccordionPrimitive.Content>
|
|
@ -0,0 +1,17 @@
|
|||
<script lang="ts">
|
||||
import { Accordion as AccordionPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AccordionPrimitive.ItemProps = $props();
|
||||
</script>
|
||||
|
||||
<AccordionPrimitive.Item
|
||||
bind:ref
|
||||
data-slot="accordion-item"
|
||||
class={cn('border-b last:border-b-0', className)}
|
||||
{...restProps}
|
||||
/>
|
|
@ -0,0 +1,32 @@
|
|||
<script lang="ts">
|
||||
import { Accordion as AccordionPrimitive } from 'bits-ui';
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
||||
import { cn, type WithoutChild } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
level = 3,
|
||||
children,
|
||||
...restProps
|
||||
}: WithoutChild<AccordionPrimitive.TriggerProps> & {
|
||||
level?: AccordionPrimitive.HeaderProps['level'];
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<AccordionPrimitive.Header {level} class="flex">
|
||||
<AccordionPrimitive.Trigger
|
||||
data-slot="accordion-trigger"
|
||||
bind:ref
|
||||
class={cn(
|
||||
'flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
<ChevronDownIcon
|
||||
class="pointer-events-none size-4 shrink-0 translate-y-0.5 text-muted-foreground transition-transform duration-200"
|
||||
/>
|
||||
</AccordionPrimitive.Trigger>
|
||||
</AccordionPrimitive.Header>
|
16
frontend/src/lib/components/ui/accordion/accordion.svelte
Normal file
16
frontend/src/lib/components/ui/accordion/accordion.svelte
Normal file
|
@ -0,0 +1,16 @@
|
|||
<script lang="ts">
|
||||
import { Accordion as AccordionPrimitive } from 'bits-ui';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(),
|
||||
...restProps
|
||||
}: AccordionPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<AccordionPrimitive.Root
|
||||
bind:ref
|
||||
bind:value={value as never}
|
||||
data-slot="accordion"
|
||||
{...restProps}
|
||||
/>
|
16
frontend/src/lib/components/ui/accordion/index.ts
Normal file
16
frontend/src/lib/components/ui/accordion/index.ts
Normal file
|
@ -0,0 +1,16 @@
|
|||
import Root from './accordion.svelte';
|
||||
import Content from './accordion-content.svelte';
|
||||
import Item from './accordion-item.svelte';
|
||||
import Trigger from './accordion-trigger.svelte';
|
||||
|
||||
export {
|
||||
Root,
|
||||
Content,
|
||||
Item,
|
||||
Trigger,
|
||||
//
|
||||
Root as Accordion,
|
||||
Content as AccordionContent,
|
||||
Item as AccordionItem,
|
||||
Trigger as AccordionTrigger
|
||||
};
|
|
@ -0,0 +1,18 @@
|
|||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
||||
import { buttonVariants } from '$lib/components/ui/button/index.js';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AlertDialogPrimitive.ActionProps = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Action
|
||||
bind:ref
|
||||
data-slot="alert-dialog-action"
|
||||
class={cn(buttonVariants(), className)}
|
||||
{...restProps}
|
||||
/>
|
|
@ -0,0 +1,18 @@
|
|||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
||||
import { buttonVariants } from '$lib/components/ui/button/index.js';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AlertDialogPrimitive.CancelProps = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Cancel
|
||||
bind:ref
|
||||
data-slot="alert-dialog-cancel"
|
||||
class={cn(buttonVariants({ variant: 'outline' }), className)}
|
||||
{...restProps}
|
||||
/>
|
|
@ -0,0 +1,27 @@
|
|||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
||||
import AlertDialogOverlay from './alert-dialog-overlay.svelte';
|
||||
import { cn, type WithoutChild, type WithoutChildrenOrChild } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
portalProps,
|
||||
...restProps
|
||||
}: WithoutChild<AlertDialogPrimitive.ContentProps> & {
|
||||
portalProps?: WithoutChildrenOrChild<AlertDialogPrimitive.PortalProps>;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Portal {...portalProps}>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
bind:ref
|
||||
data-slot="alert-dialog-content"
|
||||
class={cn(
|
||||
'fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
||||
</AlertDialogPrimitive.Portal>
|
|
@ -0,0 +1,17 @@
|
|||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AlertDialogPrimitive.DescriptionProps = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Description
|
||||
bind:ref
|
||||
data-slot="alert-dialog-description"
|
||||
class={cn('text-sm text-muted-foreground', className)}
|
||||
{...restProps}
|
||||
/>
|
|
@ -0,0 +1,20 @@
|
|||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="alert-dialog-footer"
|
||||
class={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
|
@ -0,0 +1,20 @@
|
|||
<script lang="ts">
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="alert-dialog-header"
|
||||
class={cn('flex flex-col gap-2 text-center sm:text-left', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
|
@ -0,0 +1,20 @@
|
|||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AlertDialogPrimitive.OverlayProps = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Overlay
|
||||
bind:ref
|
||||
data-slot="alert-dialog-overlay"
|
||||
class={cn(
|
||||
'fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
|
@ -0,0 +1,17 @@
|
|||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AlertDialogPrimitive.TitleProps = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Title
|
||||
bind:ref
|
||||
data-slot="alert-dialog-title"
|
||||
class={cn('text-lg font-semibold', className)}
|
||||
{...restProps}
|
||||
/>
|
|
@ -0,0 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: AlertDialogPrimitive.TriggerProps = $props();
|
||||
</script>
|
||||
|
||||
<AlertDialogPrimitive.Trigger bind:ref data-slot="alert-dialog-trigger" {...restProps} />
|
39
frontend/src/lib/components/ui/alert-dialog/index.ts
Normal file
39
frontend/src/lib/components/ui/alert-dialog/index.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
import { AlertDialog as AlertDialogPrimitive } from 'bits-ui';
|
||||
import Trigger from './alert-dialog-trigger.svelte';
|
||||
import Title from './alert-dialog-title.svelte';
|
||||
import Action from './alert-dialog-action.svelte';
|
||||
import Cancel from './alert-dialog-cancel.svelte';
|
||||
import Footer from './alert-dialog-footer.svelte';
|
||||
import Header from './alert-dialog-header.svelte';
|
||||
import Overlay from './alert-dialog-overlay.svelte';
|
||||
import Content from './alert-dialog-content.svelte';
|
||||
import Description from './alert-dialog-description.svelte';
|
||||
|
||||
const Root = AlertDialogPrimitive.Root;
|
||||
const Portal = AlertDialogPrimitive.Portal;
|
||||
|
||||
export {
|
||||
Root,
|
||||
Title,
|
||||
Action,
|
||||
Cancel,
|
||||
Portal,
|
||||
Footer,
|
||||
Header,
|
||||
Trigger,
|
||||
Overlay,
|
||||
Content,
|
||||
Description,
|
||||
//
|
||||
Root as AlertDialog,
|
||||
Title as AlertDialogTitle,
|
||||
Action as AlertDialogAction,
|
||||
Cancel as AlertDialogCancel,
|
||||
Portal as AlertDialogPortal,
|
||||
Footer as AlertDialogFooter,
|
||||
Header as AlertDialogHeader,
|
||||
Trigger as AlertDialogTrigger,
|
||||
Overlay as AlertDialogOverlay,
|
||||
Content as AlertDialogContent,
|
||||
Description as AlertDialogDescription
|
||||
};
|
|
@ -0,0 +1,23 @@
|
|||
<script lang="ts">
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="alert-description"
|
||||
class={cn(
|
||||
'col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
20
frontend/src/lib/components/ui/alert/alert-title.svelte
Normal file
20
frontend/src/lib/components/ui/alert/alert-title.svelte
Normal file
|
@ -0,0 +1,20 @@
|
|||
<script lang="ts">
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="alert-title"
|
||||
class={cn('col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
44
frontend/src/lib/components/ui/alert/alert.svelte
Normal file
44
frontend/src/lib/components/ui/alert/alert.svelte
Normal file
|
@ -0,0 +1,44 @@
|
|||
<script lang="ts" module>
|
||||
import { type VariantProps, tv } from 'tailwind-variants';
|
||||
|
||||
export const alertVariants = tv({
|
||||
base: 'relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-card text-card-foreground',
|
||||
destructive:
|
||||
'text-destructive bg-card *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default'
|
||||
}
|
||||
});
|
||||
|
||||
export type AlertVariant = VariantProps<typeof alertVariants>['variant'];
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
variant = 'default',
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
|
||||
variant?: AlertVariant;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="alert"
|
||||
class={cn(alertVariants({ variant }), className)}
|
||||
{...restProps}
|
||||
role="alert"
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
14
frontend/src/lib/components/ui/alert/index.ts
Normal file
14
frontend/src/lib/components/ui/alert/index.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import Root from './alert.svelte';
|
||||
import Description from './alert-description.svelte';
|
||||
import Title from './alert-title.svelte';
|
||||
export { alertVariants, type AlertVariant } from './alert.svelte';
|
||||
|
||||
export {
|
||||
Root,
|
||||
Description,
|
||||
Title,
|
||||
//
|
||||
Root as Alert,
|
||||
Description as AlertDescription,
|
||||
Title as AlertTitle
|
||||
};
|
|
@ -0,0 +1,7 @@
|
|||
<script lang="ts">
|
||||
import { AspectRatio as AspectRatioPrimitive } from 'bits-ui';
|
||||
|
||||
let { ref = $bindable(null), ...restProps }: AspectRatioPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<AspectRatioPrimitive.Root bind:ref data-slot="aspect-ratio" {...restProps} />
|
3
frontend/src/lib/components/ui/aspect-ratio/index.ts
Normal file
3
frontend/src/lib/components/ui/aspect-ratio/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
import Root from './aspect-ratio.svelte';
|
||||
|
||||
export { Root, Root as AspectRatio };
|
17
frontend/src/lib/components/ui/avatar/avatar-fallback.svelte
Normal file
17
frontend/src/lib/components/ui/avatar/avatar-fallback.svelte
Normal file
|
@ -0,0 +1,17 @@
|
|||
<script lang="ts">
|
||||
import { Avatar as AvatarPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AvatarPrimitive.FallbackProps = $props();
|
||||
</script>
|
||||
|
||||
<AvatarPrimitive.Fallback
|
||||
bind:ref
|
||||
data-slot="avatar-fallback"
|
||||
class={cn('flex size-full items-center justify-center rounded-full bg-muted', className)}
|
||||
{...restProps}
|
||||
/>
|
17
frontend/src/lib/components/ui/avatar/avatar-image.svelte
Normal file
17
frontend/src/lib/components/ui/avatar/avatar-image.svelte
Normal file
|
@ -0,0 +1,17 @@
|
|||
<script lang="ts">
|
||||
import { Avatar as AvatarPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AvatarPrimitive.ImageProps = $props();
|
||||
</script>
|
||||
|
||||
<AvatarPrimitive.Image
|
||||
bind:ref
|
||||
data-slot="avatar-image"
|
||||
class={cn('aspect-square size-full', className)}
|
||||
{...restProps}
|
||||
/>
|
19
frontend/src/lib/components/ui/avatar/avatar.svelte
Normal file
19
frontend/src/lib/components/ui/avatar/avatar.svelte
Normal file
|
@ -0,0 +1,19 @@
|
|||
<script lang="ts">
|
||||
import { Avatar as AvatarPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
loadingStatus = $bindable('loading'),
|
||||
class: className,
|
||||
...restProps
|
||||
}: AvatarPrimitive.RootProps = $props();
|
||||
</script>
|
||||
|
||||
<AvatarPrimitive.Root
|
||||
bind:ref
|
||||
bind:loadingStatus
|
||||
data-slot="avatar"
|
||||
class={cn('relative flex size-8 shrink-0 overflow-hidden rounded-full', className)}
|
||||
{...restProps}
|
||||
/>
|
13
frontend/src/lib/components/ui/avatar/index.ts
Normal file
13
frontend/src/lib/components/ui/avatar/index.ts
Normal file
|
@ -0,0 +1,13 @@
|
|||
import Root from './avatar.svelte';
|
||||
import Image from './avatar-image.svelte';
|
||||
import Fallback from './avatar-fallback.svelte';
|
||||
|
||||
export {
|
||||
Root,
|
||||
Image,
|
||||
Fallback,
|
||||
//
|
||||
Root as Avatar,
|
||||
Image as AvatarImage,
|
||||
Fallback as AvatarFallback
|
||||
};
|
50
frontend/src/lib/components/ui/badge/badge.svelte
Normal file
50
frontend/src/lib/components/ui/badge/badge.svelte
Normal file
|
@ -0,0 +1,50 @@
|
|||
<script lang="ts" module>
|
||||
import { type VariantProps, tv } from 'tailwind-variants';
|
||||
|
||||
export const badgeVariants = tv({
|
||||
base: 'focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded-md border px-2 py-0.5 text-xs font-medium transition-[color,box-shadow] focus-visible:ring-[3px] [&>svg]:pointer-events-none [&>svg]:size-3',
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'bg-primary text-primary-foreground [a&]:hover:bg-primary/90 border-transparent',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90 border-transparent',
|
||||
destructive:
|
||||
'bg-destructive [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/70 border-transparent text-white',
|
||||
outline: 'text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default'
|
||||
}
|
||||
});
|
||||
|
||||
export type BadgeVariant = VariantProps<typeof badgeVariants>['variant'];
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { HTMLAnchorAttributes } from 'svelte/elements';
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
href,
|
||||
class: className,
|
||||
variant = 'default',
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAnchorAttributes> & {
|
||||
variant?: BadgeVariant;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<svelte:element
|
||||
this={href ? 'a' : 'span'}
|
||||
bind:this={ref}
|
||||
data-slot="badge"
|
||||
{href}
|
||||
class={cn(badgeVariants({ variant }), className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</svelte:element>
|
2
frontend/src/lib/components/ui/badge/index.ts
Normal file
2
frontend/src/lib/components/ui/badge/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export { default as Badge } from './badge.svelte';
|
||||
export { badgeVariants, type BadgeVariant } from './badge.svelte';
|
|
@ -0,0 +1,23 @@
|
|||
<script lang="ts">
|
||||
import EllipsisIcon from '@lucide/svelte/icons/ellipsis';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import { cn, type WithElementRef, type WithoutChildren } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLSpanElement>>> = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
bind:this={ref}
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
class={cn('flex size-9 items-center justify-center', className)}
|
||||
{...restProps}
|
||||
>
|
||||
<EllipsisIcon class="size-4" />
|
||||
<span class="sr-only">More</span>
|
||||
</span>
|
|
@ -0,0 +1,20 @@
|
|||
<script lang="ts">
|
||||
import type { HTMLLiAttributes } from 'svelte/elements';
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLLiAttributes> = $props();
|
||||
</script>
|
||||
|
||||
<li
|
||||
bind:this={ref}
|
||||
data-slot="breadcrumb-item"
|
||||
class={cn('inline-flex items-center gap-1.5', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</li>
|
|
@ -0,0 +1,31 @@
|
|||
<script lang="ts">
|
||||
import type { HTMLAnchorAttributes } from 'svelte/elements';
|
||||
import type { Snippet } from 'svelte';
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
href = undefined,
|
||||
child,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAnchorAttributes> & {
|
||||
child?: Snippet<[{ props: HTMLAnchorAttributes }]>;
|
||||
} = $props();
|
||||
|
||||
const attrs = $derived({
|
||||
'data-slot': 'breadcrumb-link',
|
||||
class: cn('hover:text-foreground transition-colors', className),
|
||||
href,
|
||||
...restProps
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if child}
|
||||
{@render child({ props: attrs })}
|
||||
{:else}
|
||||
<a bind:this={ref} {...attrs}>
|
||||
{@render children?.()}
|
||||
</a>
|
||||
{/if}
|
|
@ -0,0 +1,23 @@
|
|||
<script lang="ts">
|
||||
import type { HTMLOlAttributes } from 'svelte/elements';
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLOlAttributes> = $props();
|
||||
</script>
|
||||
|
||||
<ol
|
||||
bind:this={ref}
|
||||
data-slot="breadcrumb-list"
|
||||
class={cn(
|
||||
'flex flex-wrap items-center gap-1.5 text-sm break-words text-muted-foreground sm:gap-2.5',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</ol>
|
|
@ -0,0 +1,23 @@
|
|||
<script lang="ts">
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
bind:this={ref}
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
class={cn('font-normal text-foreground', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</span>
|
|
@ -0,0 +1,27 @@
|
|||
<script lang="ts">
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
import type { HTMLLiAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLLiAttributes> = $props();
|
||||
</script>
|
||||
|
||||
<li
|
||||
bind:this={ref}
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
class={cn('[&>svg]:size-3.5', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{#if children}
|
||||
{@render children?.()}
|
||||
{:else}
|
||||
<ChevronRightIcon />
|
||||
{/if}
|
||||
</li>
|
21
frontend/src/lib/components/ui/breadcrumb/breadcrumb.svelte
Normal file
21
frontend/src/lib/components/ui/breadcrumb/breadcrumb.svelte
Normal file
|
@ -0,0 +1,21 @@
|
|||
<script lang="ts">
|
||||
import type { WithElementRef } from '$lib/utils.js';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
||||
</script>
|
||||
|
||||
<nav
|
||||
bind:this={ref}
|
||||
data-slot="breadcrumb"
|
||||
class={className}
|
||||
aria-label="breadcrumb"
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</nav>
|
25
frontend/src/lib/components/ui/breadcrumb/index.ts
Normal file
25
frontend/src/lib/components/ui/breadcrumb/index.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import Root from './breadcrumb.svelte';
|
||||
import Ellipsis from './breadcrumb-ellipsis.svelte';
|
||||
import Item from './breadcrumb-item.svelte';
|
||||
import Separator from './breadcrumb-separator.svelte';
|
||||
import Link from './breadcrumb-link.svelte';
|
||||
import List from './breadcrumb-list.svelte';
|
||||
import Page from './breadcrumb-page.svelte';
|
||||
|
||||
export {
|
||||
Root,
|
||||
Ellipsis,
|
||||
Item,
|
||||
Separator,
|
||||
Link,
|
||||
List,
|
||||
Page,
|
||||
//
|
||||
Root as Breadcrumb,
|
||||
Ellipsis as BreadcrumbEllipsis,
|
||||
Item as BreadcrumbItem,
|
||||
Separator as BreadcrumbSeparator,
|
||||
Link as BreadcrumbLink,
|
||||
List as BreadcrumbList,
|
||||
Page as BreadcrumbPage
|
||||
};
|
80
frontend/src/lib/components/ui/button/button.svelte
Normal file
80
frontend/src/lib/components/ui/button/button.svelte
Normal file
|
@ -0,0 +1,80 @@
|
|||
<script lang="ts" module>
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
|
||||
import { type VariantProps, tv } from 'tailwind-variants';
|
||||
|
||||
export const buttonVariants = tv({
|
||||
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
|
||||
destructive:
|
||||
'bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white',
|
||||
outline:
|
||||
'bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border',
|
||||
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
||||
link: 'text-primary underline-offset-4 hover:underline'
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
||||
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
|
||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
||||
icon: 'size-9'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default'
|
||||
}
|
||||
});
|
||||
|
||||
export type ButtonVariant = VariantProps<typeof buttonVariants>['variant'];
|
||||
export type ButtonSize = VariantProps<typeof buttonVariants>['size'];
|
||||
|
||||
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
|
||||
WithElementRef<HTMLAnchorAttributes> & {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
};
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
let {
|
||||
class: className,
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
ref = $bindable(null),
|
||||
href = undefined,
|
||||
type = 'button',
|
||||
disabled,
|
||||
children,
|
||||
...restProps
|
||||
}: ButtonProps = $props();
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a
|
||||
bind:this={ref}
|
||||
data-slot="button"
|
||||
class={cn(buttonVariants({ variant, size }), className)}
|
||||
href={disabled ? undefined : href}
|
||||
aria-disabled={disabled}
|
||||
role={disabled ? 'link' : undefined}
|
||||
tabindex={disabled ? -1 : undefined}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
bind:this={ref}
|
||||
data-slot="button"
|
||||
class={cn(buttonVariants({ variant, size }), className)}
|
||||
{type}
|
||||
{disabled}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
{/if}
|
17
frontend/src/lib/components/ui/button/index.ts
Normal file
17
frontend/src/lib/components/ui/button/index.ts
Normal file
|
@ -0,0 +1,17 @@
|
|||
import Root, {
|
||||
type ButtonProps,
|
||||
type ButtonSize,
|
||||
type ButtonVariant,
|
||||
buttonVariants
|
||||
} from './button.svelte';
|
||||
|
||||
export {
|
||||
Root,
|
||||
type ButtonProps as Props,
|
||||
//
|
||||
Root as Button,
|
||||
buttonVariants,
|
||||
type ButtonProps,
|
||||
type ButtonSize,
|
||||
type ButtonVariant
|
||||
};
|
|
@ -0,0 +1,76 @@
|
|||
<script lang="ts">
|
||||
import type { ComponentProps } from 'svelte';
|
||||
import type Calendar from './calendar.svelte';
|
||||
import CalendarMonthSelect from './calendar-month-select.svelte';
|
||||
import CalendarYearSelect from './calendar-year-select.svelte';
|
||||
import { DateFormatter, getLocalTimeZone, type DateValue } from '@internationalized/date';
|
||||
|
||||
let {
|
||||
captionLayout,
|
||||
months,
|
||||
monthFormat,
|
||||
years,
|
||||
yearFormat,
|
||||
month,
|
||||
locale,
|
||||
placeholder = $bindable(),
|
||||
monthIndex = 0
|
||||
}: {
|
||||
captionLayout: ComponentProps<typeof Calendar>['captionLayout'];
|
||||
months: ComponentProps<typeof CalendarMonthSelect>['months'];
|
||||
monthFormat: ComponentProps<typeof CalendarMonthSelect>['monthFormat'];
|
||||
years: ComponentProps<typeof CalendarYearSelect>['years'];
|
||||
yearFormat: ComponentProps<typeof CalendarYearSelect>['yearFormat'];
|
||||
month: DateValue;
|
||||
placeholder: DateValue | undefined;
|
||||
locale: string;
|
||||
monthIndex: number;
|
||||
} = $props();
|
||||
|
||||
function formatYear(date: DateValue) {
|
||||
const dateObj = date.toDate(getLocalTimeZone());
|
||||
if (typeof yearFormat === 'function') return yearFormat(dateObj.getFullYear());
|
||||
return new DateFormatter(locale, { year: yearFormat }).format(dateObj);
|
||||
}
|
||||
|
||||
function formatMonth(date: DateValue) {
|
||||
const dateObj = date.toDate(getLocalTimeZone());
|
||||
if (typeof monthFormat === 'function') return monthFormat(dateObj.getMonth() + 1);
|
||||
return new DateFormatter(locale, { month: monthFormat }).format(dateObj);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet MonthSelect()}
|
||||
<CalendarMonthSelect
|
||||
{months}
|
||||
{monthFormat}
|
||||
value={month.month}
|
||||
onchange={(e) => {
|
||||
if (!placeholder) return;
|
||||
const v = Number.parseInt(e.currentTarget.value);
|
||||
const newPlaceholder = placeholder.set({ month: v });
|
||||
placeholder = newPlaceholder.subtract({ months: monthIndex });
|
||||
}}
|
||||
/>
|
||||
{/snippet}
|
||||
|
||||
{#snippet YearSelect()}
|
||||
<CalendarYearSelect {years} {yearFormat} value={month.year} />
|
||||
{/snippet}
|
||||
|
||||
{#if captionLayout === 'dropdown'}
|
||||
{@render MonthSelect()}
|
||||
{@render YearSelect()}
|
||||
{:else if captionLayout === 'dropdown-months'}
|
||||
{@render MonthSelect()}
|
||||
{#if placeholder}
|
||||
{formatYear(placeholder)}
|
||||
{/if}
|
||||
{:else if captionLayout === 'dropdown-years'}
|
||||
{#if placeholder}
|
||||
{formatMonth(placeholder)}
|
||||
{/if}
|
||||
{@render YearSelect()}
|
||||
{:else}
|
||||
{formatMonth(month)} {formatYear(month)}
|
||||
{/if}
|
19
frontend/src/lib/components/ui/calendar/calendar-cell.svelte
Normal file
19
frontend/src/lib/components/ui/calendar/calendar-cell.svelte
Normal file
|
@ -0,0 +1,19 @@
|
|||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CalendarPrimitive.CellProps = $props();
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.Cell
|
||||
bind:ref
|
||||
class={cn(
|
||||
'relative size-(--cell-size) p-0 text-center text-sm focus-within:z-20 [&:first-child[data-selected]_[data-bits-day]]:rounded-l-md [&:last-child[data-selected]_[data-bits-day]]:rounded-r-md',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
35
frontend/src/lib/components/ui/calendar/calendar-day.svelte
Normal file
35
frontend/src/lib/components/ui/calendar/calendar-day.svelte
Normal file
|
@ -0,0 +1,35 @@
|
|||
<script lang="ts">
|
||||
import { buttonVariants } from '$lib/components/ui/button/index.js';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CalendarPrimitive.DayProps = $props();
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.Day
|
||||
bind:ref
|
||||
class={cn(
|
||||
buttonVariants({ variant: 'ghost' }),
|
||||
'flex size-(--cell-size) flex-col items-center justify-center gap-1 p-0 leading-none font-normal whitespace-nowrap select-none',
|
||||
'[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground [&[data-today][data-disabled]]:text-muted-foreground',
|
||||
'data-[selected]:bg-primary data-[selected]:text-primary-foreground dark:data-[selected]:hover:bg-accent/50',
|
||||
// Outside months
|
||||
'[&[data-outside-month]:not([data-selected])]:text-muted-foreground [&[data-outside-month]:not([data-selected])]:hover:text-accent-foreground',
|
||||
// Disabled
|
||||
'data-[disabled]:pointer-events-none data-[disabled]:text-muted-foreground data-[disabled]:opacity-50',
|
||||
// Unavailable
|
||||
'data-[unavailable]:text-muted-foreground data-[unavailable]:line-through',
|
||||
// hover
|
||||
'dark:hover:text-accent-foreground',
|
||||
// focus
|
||||
'focus:relative focus:border-ring focus:ring-ring/50',
|
||||
// inner spans
|
||||
'[&>span]:text-xs [&>span]:opacity-70',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
|
@ -0,0 +1,12 @@
|
|||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CalendarPrimitive.GridBodyProps = $props();
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.GridBody bind:ref class={cn(className)} {...restProps} />
|
|
@ -0,0 +1,12 @@
|
|||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CalendarPrimitive.GridHeadProps = $props();
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.GridHead bind:ref class={cn(className)} {...restProps} />
|
|
@ -0,0 +1,12 @@
|
|||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CalendarPrimitive.GridRowProps = $props();
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.GridRow bind:ref class={cn('flex', className)} {...restProps} />
|
16
frontend/src/lib/components/ui/calendar/calendar-grid.svelte
Normal file
16
frontend/src/lib/components/ui/calendar/calendar-grid.svelte
Normal file
|
@ -0,0 +1,16 @@
|
|||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CalendarPrimitive.GridProps = $props();
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.Grid
|
||||
bind:ref
|
||||
class={cn('mt-4 flex w-full border-collapse flex-col gap-1', className)}
|
||||
{...restProps}
|
||||
/>
|
|
@ -0,0 +1,19 @@
|
|||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CalendarPrimitive.HeadCellProps = $props();
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.HeadCell
|
||||
bind:ref
|
||||
class={cn(
|
||||
'w-(--cell-size) rounded-md text-[0.8rem] font-normal text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
|
@ -0,0 +1,19 @@
|
|||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CalendarPrimitive.HeaderProps = $props();
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.Header
|
||||
bind:ref
|
||||
class={cn(
|
||||
'flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium',
|
||||
className
|
||||
)}
|
||||
{...restProps}
|
||||
/>
|
|
@ -0,0 +1,16 @@
|
|||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
...restProps
|
||||
}: CalendarPrimitive.HeadingProps = $props();
|
||||
</script>
|
||||
|
||||
<CalendarPrimitive.Heading
|
||||
bind:ref
|
||||
class={cn('px-(--cell-size) text-sm font-medium', className)}
|
||||
{...restProps}
|
||||
/>
|
|
@ -0,0 +1,44 @@
|
|||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
||||
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
value,
|
||||
onchange,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<CalendarPrimitive.MonthSelectProps> = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
class={cn(
|
||||
'relative flex rounded-md border border-input shadow-xs has-focus:border-ring has-focus:ring-[3px] has-focus:ring-ring/50',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<CalendarPrimitive.MonthSelect bind:ref class="absolute inset-0 opacity-0" {...restProps}>
|
||||
{#snippet child({ props, monthItems, selectedMonthItem })}
|
||||
<select {...props} {value} {onchange}>
|
||||
{#each monthItems as monthItem (monthItem.value)}
|
||||
<option
|
||||
value={monthItem.value}
|
||||
selected={value !== undefined
|
||||
? monthItem.value === value
|
||||
: monthItem.value === selectedMonthItem.value}
|
||||
>
|
||||
{monthItem.label}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
<span
|
||||
class="flex h-8 items-center gap-1 rounded-md pr-1 pl-2 text-sm font-medium select-none [&>svg]:size-3.5 [&>svg]:text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{monthItems.find((item) => item.value === value)?.label || selectedMonthItem.label}
|
||||
<ChevronDownIcon class="size-4" />
|
||||
</span>
|
||||
{/snippet}
|
||||
</CalendarPrimitive.MonthSelect>
|
||||
</span>
|
|
@ -0,0 +1,15 @@
|
|||
<script lang="ts">
|
||||
import { type WithElementRef, cn } from '$lib/utils.js';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div {...restProps} bind:this={ref} class={cn('flex flex-col', className)}>
|
||||
{@render children?.()}
|
||||
</div>
|
|
@ -0,0 +1,19 @@
|
|||
<script lang="ts">
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
class={cn('relative flex flex-col gap-4 md:flex-row', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
19
frontend/src/lib/components/ui/calendar/calendar-nav.svelte
Normal file
19
frontend/src/lib/components/ui/calendar/calendar-nav.svelte
Normal file
|
@ -0,0 +1,19 @@
|
|||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
|
||||
</script>
|
||||
|
||||
<nav
|
||||
{...restProps}
|
||||
bind:this={ref}
|
||||
class={cn('absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1', className)}
|
||||
>
|
||||
{@render children?.()}
|
||||
</nav>
|
|
@ -0,0 +1,31 @@
|
|||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
||||
import { buttonVariants, type ButtonVariant } from '$lib/components/ui/button/index.js';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
variant = 'ghost',
|
||||
...restProps
|
||||
}: CalendarPrimitive.NextButtonProps & {
|
||||
variant?: ButtonVariant;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#snippet Fallback()}
|
||||
<ChevronRightIcon class="size-4" />
|
||||
{/snippet}
|
||||
|
||||
<CalendarPrimitive.NextButton
|
||||
bind:ref
|
||||
class={cn(
|
||||
buttonVariants({ variant }),
|
||||
'size-(--cell-size) bg-transparent p-0 select-none disabled:opacity-50 rtl:rotate-180',
|
||||
className
|
||||
)}
|
||||
children={children || Fallback}
|
||||
{...restProps}
|
||||
/>
|
|
@ -0,0 +1,31 @@
|
|||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
||||
import ChevronLeftIcon from '@lucide/svelte/icons/chevron-left';
|
||||
import { buttonVariants, type ButtonVariant } from '$lib/components/ui/button/index.js';
|
||||
import { cn } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
variant = 'ghost',
|
||||
...restProps
|
||||
}: CalendarPrimitive.PrevButtonProps & {
|
||||
variant?: ButtonVariant;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#snippet Fallback()}
|
||||
<ChevronLeftIcon class="size-4" />
|
||||
{/snippet}
|
||||
|
||||
<CalendarPrimitive.PrevButton
|
||||
bind:ref
|
||||
class={cn(
|
||||
buttonVariants({ variant }),
|
||||
'size-(--cell-size) bg-transparent p-0 select-none disabled:opacity-50 rtl:rotate-180',
|
||||
className
|
||||
)}
|
||||
children={children || Fallback}
|
||||
{...restProps}
|
||||
/>
|
|
@ -0,0 +1,43 @@
|
|||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
||||
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
value,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<CalendarPrimitive.YearSelectProps> = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
class={cn(
|
||||
'relative flex rounded-md border border-input shadow-xs has-focus:border-ring has-focus:ring-[3px] has-focus:ring-ring/50',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<CalendarPrimitive.YearSelect bind:ref class="absolute inset-0 opacity-0" {...restProps}>
|
||||
{#snippet child({ props, yearItems, selectedYearItem })}
|
||||
<select {...props} {value}>
|
||||
{#each yearItems as yearItem (yearItem.value)}
|
||||
<option
|
||||
value={yearItem.value}
|
||||
selected={value !== undefined
|
||||
? yearItem.value === value
|
||||
: yearItem.value === selectedYearItem.value}
|
||||
>
|
||||
{yearItem.label}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
<span
|
||||
class="flex h-8 items-center gap-1 rounded-md pr-1 pl-2 text-sm font-medium select-none [&>svg]:size-3.5 [&>svg]:text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{yearItems.find((item) => item.value === value)?.label || selectedYearItem.label}
|
||||
<ChevronDownIcon class="size-4" />
|
||||
</span>
|
||||
{/snippet}
|
||||
</CalendarPrimitive.YearSelect>
|
||||
</span>
|
115
frontend/src/lib/components/ui/calendar/calendar.svelte
Normal file
115
frontend/src/lib/components/ui/calendar/calendar.svelte
Normal file
|
@ -0,0 +1,115 @@
|
|||
<script lang="ts">
|
||||
import { Calendar as CalendarPrimitive } from 'bits-ui';
|
||||
import * as Calendar from './index.js';
|
||||
import { cn, type WithoutChildrenOrChild } from '$lib/utils.js';
|
||||
import type { ButtonVariant } from '../button/button.svelte';
|
||||
import { isEqualMonth, type DateValue } from '@internationalized/date';
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
value = $bindable(),
|
||||
placeholder = $bindable(),
|
||||
class: className,
|
||||
weekdayFormat = 'short',
|
||||
buttonVariant = 'ghost',
|
||||
captionLayout = 'label',
|
||||
locale = 'en-US',
|
||||
months: monthsProp,
|
||||
years,
|
||||
monthFormat: monthFormatProp,
|
||||
yearFormat = 'numeric',
|
||||
day,
|
||||
disableDaysOutsideMonth = false,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<CalendarPrimitive.RootProps> & {
|
||||
buttonVariant?: ButtonVariant;
|
||||
captionLayout?: 'dropdown' | 'dropdown-months' | 'dropdown-years' | 'label';
|
||||
months?: CalendarPrimitive.MonthSelectProps['months'];
|
||||
years?: CalendarPrimitive.YearSelectProps['years'];
|
||||
monthFormat?: CalendarPrimitive.MonthSelectProps['monthFormat'];
|
||||
yearFormat?: CalendarPrimitive.YearSelectProps['yearFormat'];
|
||||
day?: Snippet<[{ day: DateValue; outsideMonth: boolean }]>;
|
||||
} = $props();
|
||||
|
||||
const monthFormat = $derived.by(() => {
|
||||
if (monthFormatProp) return monthFormatProp;
|
||||
if (captionLayout.startsWith('dropdown')) return 'short';
|
||||
return 'long';
|
||||
});
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Discriminated Unions + Destructing (required for bindable) do not
|
||||
get along, so we shut typescript up by casting `value` to `never`.
|
||||
-->
|
||||
<CalendarPrimitive.Root
|
||||
bind:value={value as never}
|
||||
bind:ref
|
||||
bind:placeholder
|
||||
{weekdayFormat}
|
||||
{disableDaysOutsideMonth}
|
||||
class={cn(
|
||||
'group/calendar bg-background p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent',
|
||||
className
|
||||
)}
|
||||
{locale}
|
||||
{monthFormat}
|
||||
{yearFormat}
|
||||
{...restProps}
|
||||
>
|
||||
{#snippet children({ months, weekdays })}
|
||||
<Calendar.Months>
|
||||
<Calendar.Nav>
|
||||
<Calendar.PrevButton variant={buttonVariant} />
|
||||
<Calendar.NextButton variant={buttonVariant} />
|
||||
</Calendar.Nav>
|
||||
{#each months as month, monthIndex (month)}
|
||||
<Calendar.Month>
|
||||
<Calendar.Header>
|
||||
<Calendar.Caption
|
||||
{captionLayout}
|
||||
months={monthsProp}
|
||||
{monthFormat}
|
||||
{years}
|
||||
{yearFormat}
|
||||
month={month.value}
|
||||
bind:placeholder
|
||||
{locale}
|
||||
{monthIndex}
|
||||
/>
|
||||
</Calendar.Header>
|
||||
<Calendar.Grid>
|
||||
<Calendar.GridHead>
|
||||
<Calendar.GridRow class="select-none">
|
||||
{#each weekdays as weekday (weekday)}
|
||||
<Calendar.HeadCell>
|
||||
{weekday.slice(0, 2)}
|
||||
</Calendar.HeadCell>
|
||||
{/each}
|
||||
</Calendar.GridRow>
|
||||
</Calendar.GridHead>
|
||||
<Calendar.GridBody>
|
||||
{#each month.weeks as weekDates (weekDates)}
|
||||
<Calendar.GridRow class="mt-2 w-full">
|
||||
{#each weekDates as date (date)}
|
||||
<Calendar.Cell {date} month={month.value}>
|
||||
{#if day}
|
||||
{@render day({
|
||||
day: date,
|
||||
outsideMonth: !isEqualMonth(date, month.value)
|
||||
})}
|
||||
{:else}
|
||||
<Calendar.Day />
|
||||
{/if}
|
||||
</Calendar.Cell>
|
||||
{/each}
|
||||
</Calendar.GridRow>
|
||||
{/each}
|
||||
</Calendar.GridBody>
|
||||
</Calendar.Grid>
|
||||
</Calendar.Month>
|
||||
{/each}
|
||||
</Calendar.Months>
|
||||
{/snippet}
|
||||
</CalendarPrimitive.Root>
|
40
frontend/src/lib/components/ui/calendar/index.ts
Normal file
40
frontend/src/lib/components/ui/calendar/index.ts
Normal file
|
@ -0,0 +1,40 @@
|
|||
import Root from './calendar.svelte';
|
||||
import Cell from './calendar-cell.svelte';
|
||||
import Day from './calendar-day.svelte';
|
||||
import Grid from './calendar-grid.svelte';
|
||||
import Header from './calendar-header.svelte';
|
||||
import Months from './calendar-months.svelte';
|
||||
import GridRow from './calendar-grid-row.svelte';
|
||||
import Heading from './calendar-heading.svelte';
|
||||
import GridBody from './calendar-grid-body.svelte';
|
||||
import GridHead from './calendar-grid-head.svelte';
|
||||
import HeadCell from './calendar-head-cell.svelte';
|
||||
import NextButton from './calendar-next-button.svelte';
|
||||
import PrevButton from './calendar-prev-button.svelte';
|
||||
import MonthSelect from './calendar-month-select.svelte';
|
||||
import YearSelect from './calendar-year-select.svelte';
|
||||
import Month from './calendar-month.svelte';
|
||||
import Nav from './calendar-nav.svelte';
|
||||
import Caption from './calendar-caption.svelte';
|
||||
|
||||
export {
|
||||
Day,
|
||||
Cell,
|
||||
Grid,
|
||||
Header,
|
||||
Months,
|
||||
GridRow,
|
||||
Heading,
|
||||
GridBody,
|
||||
GridHead,
|
||||
HeadCell,
|
||||
NextButton,
|
||||
PrevButton,
|
||||
Nav,
|
||||
Month,
|
||||
YearSelect,
|
||||
MonthSelect,
|
||||
Caption,
|
||||
//
|
||||
Root as Calendar
|
||||
};
|
20
frontend/src/lib/components/ui/card/card-action.svelte
Normal file
20
frontend/src/lib/components/ui/card/card-action.svelte
Normal file
|
@ -0,0 +1,20 @@
|
|||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card-action"
|
||||
class={cn('col-start-2 row-span-2 row-start-1 self-start justify-self-end', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
15
frontend/src/lib/components/ui/card/card-content.svelte
Normal file
15
frontend/src/lib/components/ui/card/card-content.svelte
Normal file
|
@ -0,0 +1,15 @@
|
|||
<script lang="ts">
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div bind:this={ref} data-slot="card-content" class={cn('px-6', className)} {...restProps}>
|
||||
{@render children?.()}
|
||||
</div>
|
20
frontend/src/lib/components/ui/card/card-description.svelte
Normal file
20
frontend/src/lib/components/ui/card/card-description.svelte
Normal file
|
@ -0,0 +1,20 @@
|
|||
<script lang="ts">
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
|
||||
</script>
|
||||
|
||||
<p
|
||||
bind:this={ref}
|
||||
data-slot="card-description"
|
||||
class={cn('text-sm text-muted-foreground', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</p>
|
20
frontend/src/lib/components/ui/card/card-footer.svelte
Normal file
20
frontend/src/lib/components/ui/card/card-footer.svelte
Normal file
|
@ -0,0 +1,20 @@
|
|||
<script lang="ts">
|
||||
import { cn, type WithElementRef } from '$lib/utils.js';
|
||||
import type { HTMLAttributes } from 'svelte/elements';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
class: className,
|
||||
children,
|
||||
...restProps
|
||||
}: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={ref}
|
||||
data-slot="card-footer"
|
||||
class={cn('flex items-center px-6 [.border-t]:pt-6', className)}
|
||||
{...restProps}
|
||||
>
|
||||
{@render children?.()}
|
||||
</div>
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue