Final commit

This commit is contained in:
Giovanni 2025-08-02 13:28:10 +02:00
parent 91fffb3294
commit c35e0716af
372 changed files with 16591 additions and 1 deletions

126
README.md
View file

@ -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

Binary file not shown.

1
backend/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
.env

View 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
View 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
View 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
View 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
View 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
View file

209
backend/deno.lock generated Normal file
View 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
View 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
View 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");
}
});

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

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

View 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`);
});

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

View 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"
}

View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,4 @@
export interface Vids {
title: string;
artist: string;
}

46
backend/youtube-api.ts Normal file
View 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
View 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
View file

@ -0,0 +1 @@
engine-strict=true

9
frontend/.prettierignore Normal file
View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,6 @@
{
"words": [
"supabase",
"interted"
]
}

4740
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

53
frontend/package.json Normal file
View 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
View 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
View 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
View 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>

View 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

View 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>

View 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>

View file

@ -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>

View file

@ -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}
/>

View file

@ -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>

View 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}
/>

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

View file

@ -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}
/>

View file

@ -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}
/>

View file

@ -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>

View file

@ -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}
/>

View 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="alert-dialog-footer"
class={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
{...restProps}
>
{@render children?.()}
</div>

View 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-dialog-header"
class={cn('flex flex-col gap-2 text-center sm:text-left', className)}
{...restProps}
>
{@render children?.()}
</div>

View file

@ -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}
/>

View file

@ -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}
/>

View file

@ -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} />

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

View file

@ -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>

View 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>

View 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>

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

View file

@ -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} />

View file

@ -0,0 +1,3 @@
import Root from './aspect-ratio.svelte';
export { Root, Root as AspectRatio };

View 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}
/>

View 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}
/>

View 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}
/>

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

View 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>

View file

@ -0,0 +1,2 @@
export { default as Badge } from './badge.svelte';
export { badgeVariants, type BadgeVariant } from './badge.svelte';

View file

@ -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>

View file

@ -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>

View file

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

View file

@ -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>

View file

@ -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>

View file

@ -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>

View 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>

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

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

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

View file

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

View 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}
/>

View 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}
/>

View file

@ -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} />

View file

@ -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} />

View file

@ -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} />

View 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}
/>

View 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.HeadCellProps = $props();
</script>
<CalendarPrimitive.HeadCell
bind:ref
class={cn(
'w-(--cell-size) rounded-md text-[0.8rem] font-normal text-muted-foreground',
className
)}
{...restProps}
/>

View 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.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}
/>

View 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.HeadingProps = $props();
</script>
<CalendarPrimitive.Heading
bind:ref
class={cn('px-(--cell-size) text-sm font-medium', className)}
{...restProps}
/>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View 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>

View file

@ -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}
/>

View file

@ -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}
/>

View file

@ -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>

View 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>

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

View 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>

View 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>

View 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>

View 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