Compare commits
3 commits
7738645a69
...
2d16fb8ecc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d16fb8ecc | ||
|
|
49390778c2 | ||
|
|
f79a9893a1 |
24 changed files with 2399 additions and 1690 deletions
6
api/AGENTS.md
Normal file
6
api/AGENTS.md
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
Run biome and typescript checks after your changes:
|
||||||
|
|
||||||
|
```
|
||||||
|
bun x biome ci
|
||||||
|
bun x tsc --noEmit
|
||||||
|
```
|
||||||
|
|
@ -1,11 +1,17 @@
|
||||||
import { defineConfig } from "drizzle-kit";
|
import { defineConfig } from "drizzle-kit";
|
||||||
|
|
||||||
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
|
|
||||||
|
if (!databaseUrl) {
|
||||||
|
throw new Error("Missing required env var: DATABASE_URL");
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
out: "./drizzle",
|
out: "./drizzle",
|
||||||
schema: "./src/db/schema.ts",
|
schema: "./src/db/schema.ts",
|
||||||
dialect: "postgresql",
|
dialect: "postgresql",
|
||||||
dbCredentials: {
|
dbCredentials: {
|
||||||
url: process.env.DATABASE_URL!,
|
url: databaseUrl,
|
||||||
},
|
},
|
||||||
schemaFilter: ["public"],
|
schemaFilter: ["public"],
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,24 @@
|
||||||
{
|
{
|
||||||
"name": "api",
|
"name": "api",
|
||||||
"module": "index.ts",
|
"module": "index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun run --watch src/index.ts"
|
"dev": "bun run --watch src/index.ts",
|
||||||
},
|
"typecheck": "tsc --noEmit"
|
||||||
"devDependencies": {
|
},
|
||||||
"@types/bun": "latest"
|
"devDependencies": {
|
||||||
},
|
"@types/bun": "latest"
|
||||||
"peerDependencies": {
|
},
|
||||||
"typescript": "^5"
|
"peerDependencies": {
|
||||||
},
|
"typescript": "^5"
|
||||||
"dependencies": {
|
},
|
||||||
"@dbos-inc/dbos-sdk": "^4.14.6",
|
"dependencies": {
|
||||||
"@spotify/web-api-ts-sdk": "^1.2.0",
|
"@dbos-inc/dbos-sdk": "^4.14.6",
|
||||||
"@statsfm/statsfm.js": "github.com:statsfm/statsfm.js",
|
"@spotify/web-api-ts-sdk": "^1.2.0",
|
||||||
"better-auth": "^1.6.5",
|
"@statsfm/statsfm.js": "github.com:statsfm/statsfm.js",
|
||||||
"elysia": "catalog:"
|
"better-auth": "^1.6.5",
|
||||||
}
|
"elysia": "catalog:"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,59 +1,65 @@
|
||||||
|
import { SpotifyApi } from "@spotify/web-api-ts-sdk";
|
||||||
import { betterAuth } from "better-auth";
|
import { betterAuth } from "better-auth";
|
||||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||||
|
import Elysia from "elysia";
|
||||||
import { db } from "./db";
|
import { db } from "./db";
|
||||||
import Elysia, { status, type Context } from "elysia";
|
|
||||||
import * as schema from "./db/auth-schema";
|
import * as schema from "./db/auth-schema";
|
||||||
import { SpotifyApi } from "@spotify/web-api-ts-sdk";
|
|
||||||
|
|
||||||
export const SPOTIFY_CLIENT_ID = process.env.SPOTIFY_CLIENT_ID!;
|
const requireEnv = (name: string): string => {
|
||||||
export const SPOTIFY_CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET!;
|
const value = process.env[name];
|
||||||
|
if (!value) throw new Error(`Missing required env var: ${name}`);
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SPOTIFY_CLIENT_ID = requireEnv("SPOTIFY_CLIENT_ID");
|
||||||
|
export const SPOTIFY_CLIENT_SECRET = requireEnv("SPOTIFY_CLIENT_SECRET");
|
||||||
|
|
||||||
export const defaultSdk = SpotifyApi.withClientCredentials(
|
export const defaultSdk = SpotifyApi.withClientCredentials(
|
||||||
SPOTIFY_CLIENT_ID,
|
SPOTIFY_CLIENT_ID,
|
||||||
SPOTIFY_CLIENT_SECRET,
|
SPOTIFY_CLIENT_SECRET,
|
||||||
);
|
);
|
||||||
|
|
||||||
export const auth = betterAuth({
|
export const auth = betterAuth({
|
||||||
database: drizzleAdapter(db, {
|
database: drizzleAdapter(db, {
|
||||||
provider: "pg",
|
provider: "pg",
|
||||||
schema,
|
schema,
|
||||||
}),
|
}),
|
||||||
socialProviders: {
|
socialProviders: {
|
||||||
spotify: {
|
spotify: {
|
||||||
clientId: SPOTIFY_CLIENT_ID,
|
clientId: SPOTIFY_CLIENT_ID,
|
||||||
clientSecret: SPOTIFY_CLIENT_SECRET,
|
clientSecret: SPOTIFY_CLIENT_SECRET,
|
||||||
scope: [
|
scope: [
|
||||||
"user-read-playback-state",
|
"user-read-playback-state",
|
||||||
"user-read-currently-playing",
|
"user-read-currently-playing",
|
||||||
"user-modify-playback-state",
|
"user-modify-playback-state",
|
||||||
"playlist-read-private",
|
"playlist-read-private",
|
||||||
"playlist-read-collaborative",
|
"playlist-read-collaborative",
|
||||||
"user-follow-read",
|
"user-follow-read",
|
||||||
"user-top-read",
|
"user-top-read",
|
||||||
"user-read-recently-played",
|
"user-read-recently-played",
|
||||||
"user-library-read",
|
"user-library-read",
|
||||||
// "user-personalized",
|
// "user-personalized",
|
||||||
"user-read-email",
|
"user-read-email",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const betterAuthElysia = new Elysia({ name: "better-auth" })
|
export const betterAuthElysia = new Elysia({ name: "better-auth" })
|
||||||
.mount(auth.handler)
|
.mount(auth.handler)
|
||||||
.macro({
|
.macro({
|
||||||
auth: {
|
auth: {
|
||||||
async resolve({ status, request: { headers } }) {
|
async resolve({ status, request: { headers } }) {
|
||||||
const session = await auth.api.getSession({
|
const session = await auth.api.getSession({
|
||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!session) return status(401);
|
if (!session) return status(401);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user: session.user,
|
user: session.user,
|
||||||
session: session.session,
|
session: session.session,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,93 +1,93 @@
|
||||||
import { relations } from "drizzle-orm/_relations";
|
import { relations } from "drizzle-orm/_relations";
|
||||||
import { pgTable, text, timestamp, boolean, index } from "drizzle-orm/pg-core";
|
import { boolean, index, pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
export const user = pgTable("user", {
|
export const user = pgTable("user", {
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
email: text("email").notNull().unique(),
|
email: text("email").notNull().unique(),
|
||||||
emailVerified: boolean("email_verified").default(false).notNull(),
|
emailVerified: boolean("email_verified").default(false).notNull(),
|
||||||
image: text("image"),
|
image: text("image"),
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at")
|
updatedAt: timestamp("updated_at")
|
||||||
.defaultNow()
|
.defaultNow()
|
||||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||||
.notNull(),
|
.notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const session = pgTable(
|
export const session = pgTable(
|
||||||
"session",
|
"session",
|
||||||
{
|
{
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
expiresAt: timestamp("expires_at").notNull(),
|
expiresAt: timestamp("expires_at").notNull(),
|
||||||
token: text("token").notNull().unique(),
|
token: text("token").notNull().unique(),
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at")
|
updatedAt: timestamp("updated_at")
|
||||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||||
.notNull(),
|
.notNull(),
|
||||||
ipAddress: text("ip_address"),
|
ipAddress: text("ip_address"),
|
||||||
userAgent: text("user_agent"),
|
userAgent: text("user_agent"),
|
||||||
userId: text("user_id")
|
userId: text("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => user.id, { onDelete: "cascade" }),
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
},
|
},
|
||||||
(table) => [index("session_userId_idx").on(table.userId)],
|
(table) => [index("session_userId_idx").on(table.userId)],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const account = pgTable(
|
export const account = pgTable(
|
||||||
"account",
|
"account",
|
||||||
{
|
{
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
accountId: text("account_id").notNull(),
|
accountId: text("account_id").notNull(),
|
||||||
providerId: text("provider_id").notNull(),
|
providerId: text("provider_id").notNull(),
|
||||||
userId: text("user_id")
|
userId: text("user_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => user.id, { onDelete: "cascade" }),
|
.references(() => user.id, { onDelete: "cascade" }),
|
||||||
accessToken: text("access_token"),
|
accessToken: text("access_token"),
|
||||||
refreshToken: text("refresh_token"),
|
refreshToken: text("refresh_token"),
|
||||||
idToken: text("id_token"),
|
idToken: text("id_token"),
|
||||||
accessTokenExpiresAt: timestamp("access_token_expires_at"),
|
accessTokenExpiresAt: timestamp("access_token_expires_at"),
|
||||||
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
|
refreshTokenExpiresAt: timestamp("refresh_token_expires_at"),
|
||||||
scope: text("scope"),
|
scope: text("scope"),
|
||||||
password: text("password"),
|
password: text("password"),
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at")
|
updatedAt: timestamp("updated_at")
|
||||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||||
.notNull(),
|
.notNull(),
|
||||||
},
|
},
|
||||||
(table) => [index("account_userId_idx").on(table.userId)],
|
(table) => [index("account_userId_idx").on(table.userId)],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const verification = pgTable(
|
export const verification = pgTable(
|
||||||
"verification",
|
"verification",
|
||||||
{
|
{
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
identifier: text("identifier").notNull(),
|
identifier: text("identifier").notNull(),
|
||||||
value: text("value").notNull(),
|
value: text("value").notNull(),
|
||||||
expiresAt: timestamp("expires_at").notNull(),
|
expiresAt: timestamp("expires_at").notNull(),
|
||||||
createdAt: timestamp("created_at").defaultNow().notNull(),
|
createdAt: timestamp("created_at").defaultNow().notNull(),
|
||||||
updatedAt: timestamp("updated_at")
|
updatedAt: timestamp("updated_at")
|
||||||
.defaultNow()
|
.defaultNow()
|
||||||
.$onUpdate(() => /* @__PURE__ */ new Date())
|
.$onUpdate(() => /* @__PURE__ */ new Date())
|
||||||
.notNull(),
|
.notNull(),
|
||||||
},
|
},
|
||||||
(table) => [index("verification_identifier_idx").on(table.identifier)],
|
(table) => [index("verification_identifier_idx").on(table.identifier)],
|
||||||
);
|
);
|
||||||
|
|
||||||
export const userRelations = relations(user, ({ many }) => ({
|
export const userRelations = relations(user, ({ many }) => ({
|
||||||
sessions: many(session),
|
sessions: many(session),
|
||||||
accounts: many(account),
|
accounts: many(account),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const sessionRelations = relations(session, ({ one }) => ({
|
export const sessionRelations = relations(session, ({ one }) => ({
|
||||||
user: one(user, {
|
user: one(user, {
|
||||||
fields: [session.userId],
|
fields: [session.userId],
|
||||||
references: [user.id],
|
references: [user.id],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const accountRelations = relations(account, ({ one }) => ({
|
export const accountRelations = relations(account, ({ one }) => ({
|
||||||
user: one(user, {
|
user: one(user, {
|
||||||
fields: [account.userId],
|
fields: [account.userId],
|
||||||
references: [user.id],
|
references: [user.id],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,10 @@
|
||||||
import { drizzle } from "drizzle-orm/node-postgres";
|
import { drizzle } from "drizzle-orm/node-postgres";
|
||||||
import { relations } from "./schema";
|
import { relations } from "./schema";
|
||||||
|
|
||||||
export const db = drizzle(process.env.DATABASE_URL!, { relations });
|
const databaseUrl = process.env.DATABASE_URL;
|
||||||
|
|
||||||
|
if (!databaseUrl) {
|
||||||
|
throw new Error("Missing required env var: DATABASE_URL");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const db = drizzle(databaseUrl, { relations });
|
||||||
|
|
|
||||||
1027
api/src/db/schema.ts
1027
api/src/db/schema.ts
File diff suppressed because it is too large
Load diff
|
|
@ -1,488 +1,504 @@
|
||||||
import type {
|
import type {
|
||||||
Album,
|
Album,
|
||||||
Artist,
|
Artist,
|
||||||
Image,
|
Image,
|
||||||
PlayHistory,
|
PlayHistory,
|
||||||
SavedAlbum,
|
SavedAlbum,
|
||||||
SavedTrack,
|
SavedTrack,
|
||||||
SimplifiedAlbum,
|
SimplifiedAlbum,
|
||||||
SimplifiedArtist,
|
SimplifiedArtist,
|
||||||
Track,
|
Track,
|
||||||
} from "@spotify/web-api-ts-sdk";
|
} from "@spotify/web-api-ts-sdk";
|
||||||
import { db } from ".";
|
|
||||||
import {
|
|
||||||
album,
|
|
||||||
albumArtist,
|
|
||||||
albumGenre,
|
|
||||||
albumImage,
|
|
||||||
artist,
|
|
||||||
artistGenre,
|
|
||||||
artistImage,
|
|
||||||
followedArtist,
|
|
||||||
genre,
|
|
||||||
playbackHistory,
|
|
||||||
platformImage,
|
|
||||||
savedAlbum,
|
|
||||||
savedTrack,
|
|
||||||
topArtist,
|
|
||||||
topTrack,
|
|
||||||
track,
|
|
||||||
trackArtist,
|
|
||||||
} from "./schema";
|
|
||||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||||
import { defaultSdk } from "../auth";
|
import { defaultSdk } from "../auth";
|
||||||
|
import { db } from ".";
|
||||||
|
import {
|
||||||
|
album,
|
||||||
|
albumArtist,
|
||||||
|
albumGenre,
|
||||||
|
albumImage,
|
||||||
|
artist,
|
||||||
|
artistGenre,
|
||||||
|
artistImage,
|
||||||
|
followedArtist,
|
||||||
|
genre,
|
||||||
|
platformImage,
|
||||||
|
playbackHistory,
|
||||||
|
savedAlbum,
|
||||||
|
savedTrack,
|
||||||
|
topArtist,
|
||||||
|
topTrack,
|
||||||
|
track,
|
||||||
|
trackArtist,
|
||||||
|
} from "./schema";
|
||||||
|
|
||||||
export const PLATFORM_SPOTIFY = "spotify" as const;
|
export const PLATFORM_SPOTIFY = "spotify" as const;
|
||||||
|
|
||||||
type DbClient = typeof db;
|
type DbClient = typeof db;
|
||||||
type DbTransaction = Parameters<typeof db.transaction>[0] extends (
|
type DbTransaction = Parameters<typeof db.transaction>[0] extends (
|
||||||
tx: infer T,
|
tx: infer T,
|
||||||
) => Promise<any>
|
) => Promise<unknown>
|
||||||
? T
|
? T
|
||||||
: never;
|
: never;
|
||||||
type DbLike = DbClient | DbTransaction;
|
type DbLike = DbClient | DbTransaction;
|
||||||
|
|
||||||
|
const requireMapEntry = <K, V>(map: Map<K, V>, key: K, label: string): V => {
|
||||||
|
const value = map.get(key);
|
||||||
|
if (!value) throw new Error(`Missing ${label} for ${String(key)}`);
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
export async function upsertImages(images: Image[], dbClient: DbLike = db) {
|
export async function upsertImages(images: Image[], dbClient: DbLike = db) {
|
||||||
await dbClient
|
await dbClient
|
||||||
.insert(platformImage)
|
.insert(platformImage)
|
||||||
.values(
|
.values(
|
||||||
images.map(({ url, height, width }) => ({
|
images.map(({ url, height, width }) => ({
|
||||||
platform: PLATFORM_SPOTIFY,
|
platform: PLATFORM_SPOTIFY,
|
||||||
url,
|
url,
|
||||||
height,
|
height,
|
||||||
width,
|
width,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.onConflictDoNothing();
|
.onConflictDoNothing();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function upsertGenres(genres: string[], dbClient: DbLike = db) {
|
export async function upsertGenres(genres: string[], dbClient: DbLike = db) {
|
||||||
const values = genres.filter(Boolean).map((name) => ({ name }));
|
const values = genres.filter(Boolean).map((name) => ({ name }));
|
||||||
if (values.length === 0) return;
|
if (values.length === 0) return;
|
||||||
await dbClient.insert(genre).values(values).onConflictDoNothing();
|
await dbClient.insert(genre).values(values).onConflictDoNothing();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function upsertArtists(artists: Artist[], dbClient: DbLike = db) {
|
export async function upsertArtists(artists: Artist[], dbClient: DbLike = db) {
|
||||||
await dbClient
|
await dbClient
|
||||||
.insert(artist)
|
.insert(artist)
|
||||||
.values(
|
.values(
|
||||||
artists.map(({ id, name, images, genres, popularity, type }) => ({
|
artists.map(({ id, name, popularity, type }) => ({
|
||||||
platform: PLATFORM_SPOTIFY,
|
platform: PLATFORM_SPOTIFY,
|
||||||
platform_id: id,
|
platform_id: id,
|
||||||
name,
|
name,
|
||||||
popularity,
|
popularity,
|
||||||
type,
|
type,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.onConflictDoNothing();
|
.onConflictDoNothing();
|
||||||
await upsertImages(
|
await upsertImages(
|
||||||
artists.flatMap((a) => a.images),
|
artists.flatMap((a) => a.images),
|
||||||
dbClient,
|
dbClient,
|
||||||
);
|
);
|
||||||
await upsertGenres(
|
await upsertGenres(
|
||||||
artists.flatMap((a) => a.genres),
|
artists.flatMap((a) => a.genres),
|
||||||
dbClient,
|
dbClient,
|
||||||
);
|
);
|
||||||
for (const spotifyArtist of artists) {
|
for (const spotifyArtist of artists) {
|
||||||
await dbClient
|
await dbClient
|
||||||
.insert(artistImage)
|
.insert(artistImage)
|
||||||
.select(
|
.select(
|
||||||
dbClient
|
dbClient
|
||||||
.select({
|
.select({
|
||||||
artistId: artist.id,
|
artistId: artist.id,
|
||||||
imageId: platformImage.id,
|
imageId: platformImage.id,
|
||||||
})
|
})
|
||||||
.from(platformImage)
|
.from(platformImage)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(platformImage.platform, PLATFORM_SPOTIFY),
|
eq(platformImage.platform, PLATFORM_SPOTIFY),
|
||||||
inArray(
|
inArray(
|
||||||
platformImage.url,
|
platformImage.url,
|
||||||
spotifyArtist.images.map((t) => t.url),
|
spotifyArtist.images.map((t) => t.url),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.innerJoin(artist, eq(artist.platform_id, spotifyArtist.id)),
|
.innerJoin(artist, eq(artist.platform_id, spotifyArtist.id)),
|
||||||
)
|
)
|
||||||
.onConflictDoNothing();
|
.onConflictDoNothing();
|
||||||
await dbClient
|
await dbClient
|
||||||
.insert(artistGenre)
|
.insert(artistGenre)
|
||||||
.select(
|
.select(
|
||||||
dbClient
|
dbClient
|
||||||
.select({
|
.select({
|
||||||
artistId: artist.id,
|
artistId: artist.id,
|
||||||
genreId: genre.id,
|
genreId: genre.id,
|
||||||
})
|
})
|
||||||
.from(genre)
|
.from(genre)
|
||||||
.where(inArray(genre.name, spotifyArtist.genres))
|
.where(inArray(genre.name, spotifyArtist.genres))
|
||||||
.innerJoin(artist, eq(artist.platform_id, spotifyArtist.id)),
|
.innerJoin(artist, eq(artist.platform_id, spotifyArtist.id)),
|
||||||
)
|
)
|
||||||
.onConflictDoNothing();
|
.onConflictDoNothing();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getMissingArtists(artistIds: string[], dbClient: DbLike = db) {
|
async function getMissingArtists(artistIds: string[], dbClient: DbLike = db) {
|
||||||
const existingArtists = await dbClient
|
const existingArtists = await dbClient
|
||||||
.select({ id: artist.id })
|
.select({ id: artist.id })
|
||||||
.from(artist)
|
.from(artist)
|
||||||
.where(inArray(artist.platform_id, artistIds));
|
.where(inArray(artist.platform_id, artistIds));
|
||||||
return artistIds.filter((id) => !existingArtists.some((a) => a.id === id));
|
return artistIds.filter((id) => !existingArtists.some((a) => a.id === id));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function lookupMissingArtists(
|
async function lookupMissingArtists(
|
||||||
artistIds: string[],
|
artistIds: string[],
|
||||||
dbClient: DbLike = db,
|
dbClient: DbLike = db,
|
||||||
) {
|
) {
|
||||||
const missingArtistIds = await getMissingArtists(artistIds, dbClient);
|
const missingArtistIds = await getMissingArtists(artistIds, dbClient);
|
||||||
if (missingArtistIds.length === 0) return [];
|
if (missingArtistIds.length === 0) return [];
|
||||||
let missingArtists: Artist[] = [];
|
const missingArtists: Artist[] = [];
|
||||||
for (let i = 0; i < missingArtistIds.length / 50; i++) {
|
for (let i = 0; i < missingArtistIds.length / 50; i++) {
|
||||||
missingArtists.push(
|
missingArtists.push(
|
||||||
...(await defaultSdk.artists.get(
|
...(await defaultSdk.artists.get(
|
||||||
missingArtistIds.slice(i * 50, (i + 1) * 50),
|
missingArtistIds.slice(i * 50, (i + 1) * 50),
|
||||||
)),
|
)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await upsertArtists(missingArtists, dbClient);
|
await upsertArtists(missingArtists, dbClient);
|
||||||
return missingArtists;
|
return missingArtists;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isFullArtistArray(
|
function isFullArtistArray(
|
||||||
artists: Artist[] | SimplifiedArtist[],
|
artists: Artist[] | SimplifiedArtist[],
|
||||||
): artists is Artist[] {
|
): artists is Artist[] {
|
||||||
return "images" in artists[0]!;
|
const firstArtist = artists[0];
|
||||||
|
if (!firstArtist) return false;
|
||||||
|
return "images" in firstArtist;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function upsertMissingArtists(
|
async function upsertMissingArtists(
|
||||||
artists: SimplifiedArtist[] | Artist[],
|
artists: SimplifiedArtist[] | Artist[],
|
||||||
dbClient: DbLike = db,
|
dbClient: DbLike = db,
|
||||||
) {
|
) {
|
||||||
if (artists.length === 0) return;
|
if (artists.length === 0) return;
|
||||||
let missingArtists: Artist[];
|
let missingArtists: Artist[];
|
||||||
if (isFullArtistArray(artists)) {
|
if (isFullArtistArray(artists)) {
|
||||||
const missingArtistIds = await getMissingArtists(
|
const missingArtistIds = await getMissingArtists(
|
||||||
artists.map((t) => t.id),
|
artists.map((t) => t.id),
|
||||||
dbClient,
|
dbClient,
|
||||||
);
|
);
|
||||||
if (missingArtistIds.length === 0) return;
|
if (missingArtistIds.length === 0) return;
|
||||||
missingArtists = artists.filter((a) => missingArtistIds.includes(a.id));
|
missingArtists = artists.filter((a) => missingArtistIds.includes(a.id));
|
||||||
} else {
|
} else {
|
||||||
missingArtists = await lookupMissingArtists(
|
missingArtists = await lookupMissingArtists(
|
||||||
artists.map((t) => t.id),
|
artists.map((t) => t.id),
|
||||||
dbClient,
|
dbClient,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (missingArtists.length === 0) return;
|
if (missingArtists.length === 0) return;
|
||||||
await upsertArtists(missingArtists, dbClient);
|
await upsertArtists(missingArtists, dbClient);
|
||||||
return missingArtists;
|
return missingArtists;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getArtistIdMap(artistIds: string[], dbClient: DbLike = db) {
|
async function getArtistIdMap(artistIds: string[], dbClient: DbLike = db) {
|
||||||
if (artistIds.length === 0) return new Map<string, string>();
|
if (artistIds.length === 0) return new Map<string, string>();
|
||||||
const rows = await dbClient
|
const rows = await dbClient
|
||||||
.select({ id: artist.id, platform_id: artist.platform_id })
|
.select({ id: artist.id, platform_id: artist.platform_id })
|
||||||
.from(artist)
|
.from(artist)
|
||||||
.where(inArray(artist.platform_id, artistIds));
|
.where(inArray(artist.platform_id, artistIds));
|
||||||
return new Map(rows.map((row) => [row.platform_id, row.id]));
|
return new Map(rows.map((row) => [row.platform_id, row.id]));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getAlbumIdMap(albumIds: string[], dbClient: DbLike = db) {
|
async function getAlbumIdMap(albumIds: string[], dbClient: DbLike = db) {
|
||||||
if (albumIds.length === 0) return new Map<string, string>();
|
if (albumIds.length === 0) return new Map<string, string>();
|
||||||
const rows = await dbClient
|
const rows = await dbClient
|
||||||
.select({ id: album.id, platform_id: album.platform_id })
|
.select({ id: album.id, platform_id: album.platform_id })
|
||||||
.from(album)
|
.from(album)
|
||||||
.where(inArray(album.platform_id, albumIds));
|
.where(inArray(album.platform_id, albumIds));
|
||||||
return new Map(rows.map((row) => [row.platform_id ?? "", row.id]));
|
return new Map(
|
||||||
|
rows
|
||||||
|
.filter((row) => row.platform_id)
|
||||||
|
.map((row) => [row.platform_id as string, row.id]),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getTrackIdMap(trackIds: string[], dbClient: DbLike = db) {
|
async function getTrackIdMap(trackIds: string[], dbClient: DbLike = db) {
|
||||||
if (trackIds.length === 0) return new Map<string, string>();
|
if (trackIds.length === 0) return new Map<string, string>();
|
||||||
const rows = await dbClient
|
const rows = await dbClient
|
||||||
.select({ id: track.id, platform_id: track.platform_id })
|
.select({ id: track.id, platform_id: track.platform_id })
|
||||||
.from(track)
|
.from(track)
|
||||||
.where(inArray(track.platform_id, trackIds));
|
.where(inArray(track.platform_id, trackIds));
|
||||||
return new Map(rows.map((row) => [row.platform_id ?? "", row.id]));
|
return new Map(
|
||||||
|
rows
|
||||||
|
.filter((row) => row.platform_id)
|
||||||
|
.map((row) => [row.platform_id as string, row.id]),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function upsertAlbums(
|
export async function upsertAlbums(
|
||||||
albums: Album[] | SimplifiedAlbum[],
|
albums: Album[] | SimplifiedAlbum[],
|
||||||
dbClient: DbLike = db,
|
dbClient: DbLike = db,
|
||||||
) {
|
) {
|
||||||
await upsertMissingArtists(
|
await upsertMissingArtists(
|
||||||
albums.flatMap((a) => a.artists),
|
albums.flatMap((a) => a.artists),
|
||||||
dbClient,
|
dbClient,
|
||||||
);
|
);
|
||||||
await dbClient
|
await dbClient
|
||||||
.insert(album)
|
.insert(album)
|
||||||
.values(
|
.values(
|
||||||
albums.map(({ id, name, type, popularity, release_date, label }) => ({
|
albums.map(({ id, name, type, popularity, release_date, label }) => ({
|
||||||
platform: PLATFORM_SPOTIFY,
|
platform: PLATFORM_SPOTIFY,
|
||||||
platform_id: id,
|
platform_id: id,
|
||||||
name,
|
name,
|
||||||
type,
|
type,
|
||||||
popularity,
|
popularity,
|
||||||
release_date: new Date(release_date),
|
release_date: new Date(release_date),
|
||||||
label,
|
label,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.onConflictDoNothing();
|
.onConflictDoNothing();
|
||||||
await upsertImages(
|
await upsertImages(
|
||||||
albums.flatMap((a) => a.images),
|
albums.flatMap((a) => a.images),
|
||||||
dbClient,
|
dbClient,
|
||||||
);
|
);
|
||||||
await upsertGenres(
|
await upsertGenres(
|
||||||
albums.flatMap((a) => a.genres),
|
albums.flatMap((a) => a.genres),
|
||||||
dbClient,
|
dbClient,
|
||||||
);
|
);
|
||||||
for (const spotifyAlbum of albums) {
|
for (const spotifyAlbum of albums) {
|
||||||
await dbClient
|
await dbClient
|
||||||
.insert(albumImage)
|
.insert(albumImage)
|
||||||
.select(
|
.select(
|
||||||
dbClient
|
dbClient
|
||||||
.select({
|
.select({
|
||||||
albumId: album.id,
|
albumId: album.id,
|
||||||
imageId: platformImage.id,
|
imageId: platformImage.id,
|
||||||
})
|
})
|
||||||
.from(platformImage)
|
.from(platformImage)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(platformImage.platform, PLATFORM_SPOTIFY),
|
eq(platformImage.platform, PLATFORM_SPOTIFY),
|
||||||
inArray(
|
inArray(
|
||||||
platformImage.url,
|
platformImage.url,
|
||||||
spotifyAlbum.images.map((t) => t.url),
|
spotifyAlbum.images.map((t) => t.url),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.innerJoin(album, eq(album.platform_id, spotifyAlbum.id)),
|
.innerJoin(album, eq(album.platform_id, spotifyAlbum.id)),
|
||||||
)
|
)
|
||||||
.onConflictDoNothing();
|
.onConflictDoNothing();
|
||||||
await dbClient
|
await dbClient
|
||||||
.insert(albumArtist)
|
.insert(albumArtist)
|
||||||
.select(
|
.select(
|
||||||
dbClient
|
dbClient
|
||||||
.select({
|
.select({
|
||||||
albumId: album.id,
|
albumId: album.id,
|
||||||
artistId: artist.id,
|
artistId: artist.id,
|
||||||
})
|
})
|
||||||
.from(artist)
|
.from(artist)
|
||||||
.where(
|
.where(
|
||||||
inArray(
|
inArray(
|
||||||
artist.platform_id,
|
artist.platform_id,
|
||||||
spotifyAlbum.artists.map((t) => t.id),
|
spotifyAlbum.artists.map((t) => t.id),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.innerJoin(album, eq(album.platform_id, spotifyAlbum.id)),
|
.innerJoin(album, eq(album.platform_id, spotifyAlbum.id)),
|
||||||
)
|
)
|
||||||
.onConflictDoNothing();
|
.onConflictDoNothing();
|
||||||
if (spotifyAlbum.genres?.length > 0)
|
if (spotifyAlbum.genres?.length > 0)
|
||||||
await dbClient
|
await dbClient
|
||||||
.insert(albumGenre)
|
.insert(albumGenre)
|
||||||
.select(
|
.select(
|
||||||
dbClient
|
dbClient
|
||||||
.select({
|
.select({
|
||||||
albumId: album.id,
|
albumId: album.id,
|
||||||
genreId: genre.id,
|
genreId: genre.id,
|
||||||
})
|
})
|
||||||
.from(genre)
|
.from(genre)
|
||||||
.where(inArray(genre.name, sql`${spotifyAlbum.genres}`))
|
.where(inArray(genre.name, sql`${spotifyAlbum.genres}`))
|
||||||
.innerJoin(album, eq(album.platform_id, spotifyAlbum.id)),
|
.innerJoin(album, eq(album.platform_id, spotifyAlbum.id)),
|
||||||
)
|
)
|
||||||
.onConflictDoNothing();
|
.onConflictDoNothing();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function upsertTracks(tracks: Track[], dbClient: DbLike = db) {
|
export async function upsertTracks(tracks: Track[], dbClient: DbLike = db) {
|
||||||
if (tracks.length === 0) return;
|
if (tracks.length === 0) return;
|
||||||
await upsertAlbums(
|
await upsertAlbums(
|
||||||
tracks.map((t) => t.album),
|
tracks.map((t) => t.album),
|
||||||
dbClient,
|
dbClient,
|
||||||
);
|
);
|
||||||
await upsertMissingArtists(
|
await upsertMissingArtists(
|
||||||
tracks.flatMap((t) => t.artists),
|
tracks.flatMap((t) => t.artists),
|
||||||
dbClient,
|
dbClient,
|
||||||
);
|
);
|
||||||
const albumIdMap = await getAlbumIdMap(
|
const albumIdMap = await getAlbumIdMap(
|
||||||
tracks.map((t) => t.album.id),
|
tracks.map((t) => t.album.id),
|
||||||
dbClient,
|
dbClient,
|
||||||
);
|
);
|
||||||
await dbClient
|
await dbClient
|
||||||
.insert(track)
|
.insert(track)
|
||||||
.values(
|
.values(
|
||||||
tracks.map((spotifyTrack) => ({
|
tracks.map((spotifyTrack) => ({
|
||||||
albumId: albumIdMap.get(spotifyTrack.album.id)!,
|
albumId: requireMapEntry(albumIdMap, spotifyTrack.album.id, "albumId"),
|
||||||
name: spotifyTrack.name,
|
name: spotifyTrack.name,
|
||||||
platform: PLATFORM_SPOTIFY,
|
platform: PLATFORM_SPOTIFY,
|
||||||
platform_id: spotifyTrack.id,
|
platform_id: spotifyTrack.id,
|
||||||
popularity: spotifyTrack.popularity,
|
popularity: spotifyTrack.popularity,
|
||||||
duration: spotifyTrack.duration_ms,
|
duration: spotifyTrack.duration_ms,
|
||||||
explicit: spotifyTrack.explicit,
|
explicit: spotifyTrack.explicit,
|
||||||
disc_number: spotifyTrack.disc_number,
|
disc_number: spotifyTrack.disc_number,
|
||||||
track_number: spotifyTrack.track_number,
|
track_number: spotifyTrack.track_number,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.onConflictDoNothing();
|
.onConflictDoNothing();
|
||||||
for (const spotifyTrack of tracks) {
|
for (const spotifyTrack of tracks) {
|
||||||
await dbClient
|
await dbClient
|
||||||
.insert(trackArtist)
|
.insert(trackArtist)
|
||||||
.select(
|
.select(
|
||||||
dbClient
|
dbClient
|
||||||
.select({
|
.select({
|
||||||
trackId: track.id,
|
trackId: track.id,
|
||||||
artistId: artist.id,
|
artistId: artist.id,
|
||||||
})
|
})
|
||||||
.from(artist)
|
.from(artist)
|
||||||
.where(
|
.where(
|
||||||
inArray(
|
inArray(
|
||||||
artist.platform_id,
|
artist.platform_id,
|
||||||
spotifyTrack.artists.map((t) => t.id),
|
spotifyTrack.artists.map((t) => t.id),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.innerJoin(track, eq(track.platform_id, spotifyTrack.id)),
|
.innerJoin(track, eq(track.platform_id, spotifyTrack.id)),
|
||||||
)
|
)
|
||||||
.onConflictDoNothing();
|
.onConflictDoNothing();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function upsertTopArtists(
|
export async function upsertTopArtists(
|
||||||
userId: string,
|
userId: string,
|
||||||
timeline: "short_term" | "medium_term" | "long_term",
|
timeline: "short_term" | "medium_term" | "long_term",
|
||||||
artists: Artist[],
|
artists: Artist[],
|
||||||
dbClient: DbLike = db,
|
dbClient: DbLike = db,
|
||||||
) {
|
) {
|
||||||
if (artists.length === 0) return;
|
if (artists.length === 0) return;
|
||||||
await upsertArtists(artists, dbClient);
|
await upsertArtists(artists, dbClient);
|
||||||
const artistIdMap = await getArtistIdMap(
|
const artistIdMap = await getArtistIdMap(
|
||||||
artists.map((t) => t.id),
|
artists.map((t) => t.id),
|
||||||
dbClient,
|
dbClient,
|
||||||
);
|
);
|
||||||
await dbClient
|
await dbClient
|
||||||
.insert(topArtist)
|
.insert(topArtist)
|
||||||
.values(
|
.values(
|
||||||
artists.map((spotifyArtist, index) => ({
|
artists.map((spotifyArtist, index) => ({
|
||||||
artistId: artistIdMap.get(spotifyArtist.id)!,
|
artistId: requireMapEntry(artistIdMap, spotifyArtist.id, "artistId"),
|
||||||
position: index + 1,
|
position: index + 1,
|
||||||
userId,
|
userId,
|
||||||
timeline,
|
timeline,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.onConflictDoNothing();
|
.onConflictDoNothing();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function upsertTopTracks(
|
export async function upsertTopTracks(
|
||||||
userId: string,
|
userId: string,
|
||||||
timeline: "short_term" | "medium_term" | "long_term",
|
timeline: "short_term" | "medium_term" | "long_term",
|
||||||
tracks: Track[],
|
tracks: Track[],
|
||||||
dbClient: DbLike = db,
|
dbClient: DbLike = db,
|
||||||
) {
|
) {
|
||||||
if (tracks.length === 0) return;
|
if (tracks.length === 0) return;
|
||||||
await upsertTracks(tracks, dbClient);
|
await upsertTracks(tracks, dbClient);
|
||||||
const trackIdMap = await getTrackIdMap(
|
const trackIdMap = await getTrackIdMap(
|
||||||
tracks.map((t) => t.id),
|
tracks.map((t) => t.id),
|
||||||
dbClient,
|
dbClient,
|
||||||
);
|
);
|
||||||
await dbClient
|
await dbClient
|
||||||
.insert(topTrack)
|
.insert(topTrack)
|
||||||
.values(
|
.values(
|
||||||
tracks.map((spotifyTrack, index) => ({
|
tracks.map((spotifyTrack, index) => ({
|
||||||
trackId: trackIdMap.get(spotifyTrack.id)!,
|
trackId: requireMapEntry(trackIdMap, spotifyTrack.id, "trackId"),
|
||||||
position: index + 1,
|
position: index + 1,
|
||||||
userId,
|
userId,
|
||||||
timeline,
|
timeline,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.onConflictDoNothing();
|
.onConflictDoNothing();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function upsertSavedAlbums(
|
export async function upsertSavedAlbums(
|
||||||
userId: string,
|
userId: string,
|
||||||
saved: SavedAlbum[],
|
saved: SavedAlbum[],
|
||||||
dbClient: DbLike = db,
|
dbClient: DbLike = db,
|
||||||
) {
|
) {
|
||||||
if (saved.length === 0) return;
|
if (saved.length === 0) return;
|
||||||
const albums = saved.map((item) => item.album);
|
const albums = saved.map((item) => item.album);
|
||||||
await upsertAlbums(albums, dbClient);
|
await upsertAlbums(albums, dbClient);
|
||||||
const albumIdMap = await getAlbumIdMap(
|
const albumIdMap = await getAlbumIdMap(
|
||||||
albums.map((t) => t.id),
|
albums.map((t) => t.id),
|
||||||
dbClient,
|
dbClient,
|
||||||
);
|
);
|
||||||
await dbClient
|
await dbClient
|
||||||
.insert(savedAlbum)
|
.insert(savedAlbum)
|
||||||
.values(
|
.values(
|
||||||
saved.map((item) => ({
|
saved.map((item) => ({
|
||||||
albumId: albumIdMap.get(item.album.id)!,
|
albumId: requireMapEntry(albumIdMap, item.album.id, "albumId"),
|
||||||
userId,
|
userId,
|
||||||
saved_at: new Date(item.added_at),
|
saved_at: new Date(item.added_at),
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.onConflictDoNothing();
|
.onConflictDoNothing();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function upsertSavedTracks(
|
export async function upsertSavedTracks(
|
||||||
userId: string,
|
userId: string,
|
||||||
saved: SavedTrack[],
|
saved: SavedTrack[],
|
||||||
dbClient: DbLike = db,
|
dbClient: DbLike = db,
|
||||||
) {
|
) {
|
||||||
if (saved.length === 0) return;
|
if (saved.length === 0) return;
|
||||||
const tracks = saved.map((item) => item.track);
|
const tracks = saved.map((item) => item.track);
|
||||||
await upsertTracks(tracks, dbClient);
|
await upsertTracks(tracks, dbClient);
|
||||||
const trackIdMap = await getTrackIdMap(
|
const trackIdMap = await getTrackIdMap(
|
||||||
tracks.map((t) => t.id),
|
tracks.map((t) => t.id),
|
||||||
dbClient,
|
dbClient,
|
||||||
);
|
);
|
||||||
await dbClient
|
await dbClient
|
||||||
.insert(savedTrack)
|
.insert(savedTrack)
|
||||||
.values(
|
.values(
|
||||||
saved.map((item) => ({
|
saved.map((item) => ({
|
||||||
trackId: trackIdMap.get(item.track.id)!,
|
trackId: requireMapEntry(trackIdMap, item.track.id, "trackId"),
|
||||||
userId,
|
userId,
|
||||||
saved_at: new Date(item.added_at),
|
saved_at: new Date(item.added_at),
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.onConflictDoNothing();
|
.onConflictDoNothing();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function upsertFollowedArtists(
|
export async function upsertFollowedArtists(
|
||||||
userId: string,
|
userId: string,
|
||||||
artists: Artist[],
|
artists: Artist[],
|
||||||
dbClient: DbLike = db,
|
dbClient: DbLike = db,
|
||||||
) {
|
) {
|
||||||
if (artists.length === 0) return;
|
if (artists.length === 0) return;
|
||||||
await upsertArtists(artists, dbClient);
|
await upsertArtists(artists, dbClient);
|
||||||
const artistIdMap = await getArtistIdMap(
|
const artistIdMap = await getArtistIdMap(
|
||||||
artists.map((t) => t.id),
|
artists.map((t) => t.id),
|
||||||
dbClient,
|
dbClient,
|
||||||
);
|
);
|
||||||
await dbClient
|
await dbClient
|
||||||
.insert(followedArtist)
|
.insert(followedArtist)
|
||||||
.values(
|
.values(
|
||||||
artists.map((spotifyArtist) => ({
|
artists.map((spotifyArtist) => ({
|
||||||
artistId: artistIdMap.get(spotifyArtist.id)!,
|
artistId: requireMapEntry(artistIdMap, spotifyArtist.id, "artistId"),
|
||||||
userId,
|
userId,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.onConflictDoNothing();
|
.onConflictDoNothing();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function upsertPlaybackHistory(
|
export async function upsertPlaybackHistory(
|
||||||
userId: string,
|
userId: string,
|
||||||
items: PlayHistory[],
|
items: PlayHistory[],
|
||||||
dbClient: DbLike = db,
|
dbClient: DbLike = db,
|
||||||
) {
|
) {
|
||||||
if (items.length === 0) return;
|
if (items.length === 0) return;
|
||||||
const tracks = items.map((item) => item.track);
|
const tracks = items.map((item) => item.track);
|
||||||
await upsertTracks(tracks, dbClient);
|
await upsertTracks(tracks, dbClient);
|
||||||
const trackIdMap = await getTrackIdMap(
|
const trackIdMap = await getTrackIdMap(
|
||||||
tracks.map((t) => t.id),
|
tracks.map((t) => t.id),
|
||||||
dbClient,
|
dbClient,
|
||||||
);
|
);
|
||||||
await dbClient
|
await dbClient
|
||||||
.insert(playbackHistory)
|
.insert(playbackHistory)
|
||||||
.values(
|
.values(
|
||||||
items.map((item) => ({
|
items.map((item) => ({
|
||||||
trackId: trackIdMap.get(item.track.id)!,
|
trackId: requireMapEntry(trackIdMap, item.track.id, "trackId"),
|
||||||
userId,
|
userId,
|
||||||
played_at: new Date(item.played_at),
|
played_at: new Date(item.played_at),
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.onConflictDoNothing();
|
.onConflictDoNothing();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,6 @@ import { DBOS } from "@dbos-inc/dbos-sdk";
|
||||||
import "./workflows/sync";
|
import "./workflows/sync";
|
||||||
|
|
||||||
DBOS.setConfig({
|
DBOS.setConfig({
|
||||||
name: "itpdp",
|
name: "itpdp",
|
||||||
systemDatabaseUrl: process.env.DATABASE_URL,
|
systemDatabaseUrl: process.env.DATABASE_URL,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,22 @@
|
||||||
import { Elysia, t } from "elysia";
|
import { DBOS } from "@dbos-inc/dbos-sdk";
|
||||||
|
import { Elysia } from "elysia";
|
||||||
import { betterAuthElysia } from "./auth";
|
import { betterAuthElysia } from "./auth";
|
||||||
import { syncApp } from "./routes/sync";
|
import { syncApp } from "./routes/sync";
|
||||||
import { DBOS } from "@dbos-inc/dbos-sdk";
|
|
||||||
import "./workflows/sync";
|
import "./workflows/sync";
|
||||||
import "./dbos.ts";
|
import "./dbos.ts";
|
||||||
import { statsApp } from "./routes/stats.ts";
|
|
||||||
import { partyApp } from "./routes/party";
|
import { partyApp } from "./routes/party";
|
||||||
|
import { partySocketApp } from "./routes/party-socket";
|
||||||
|
import { statsApp } from "./routes/stats.ts";
|
||||||
|
|
||||||
const app = new Elysia()
|
const app = new Elysia()
|
||||||
.use(betterAuthElysia)
|
.use(betterAuthElysia)
|
||||||
.group("/api", (app) => app.use(syncApp).use(statsApp).use(partyApp))
|
.group("/api", (app) =>
|
||||||
.listen(4000);
|
app.use(syncApp).use(statsApp).use(partyApp).use(partySocketApp),
|
||||||
|
)
|
||||||
|
.listen(4000);
|
||||||
|
|
||||||
export type App = typeof app;
|
export type App = typeof app;
|
||||||
|
|
||||||
await DBOS.launch({
|
await DBOS.launch({
|
||||||
conductorKey: process.env.DBOS_CONDUCTOR_KEY,
|
conductorKey: process.env.DBOS_CONDUCTOR_KEY,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
106
api/src/party-data.ts
Normal file
106
api/src/party-data.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { db } from "./db";
|
||||||
|
import { party, partyMember } from "./db/schema";
|
||||||
|
|
||||||
|
type DbClient = typeof db;
|
||||||
|
type DbTransaction = Parameters<typeof db.transaction>[0] extends (
|
||||||
|
tx: infer T,
|
||||||
|
) => Promise<unknown>
|
||||||
|
? T
|
||||||
|
: never;
|
||||||
|
export type DbLike = DbClient | DbTransaction;
|
||||||
|
|
||||||
|
export async function getPartyForUser(userId: string) {
|
||||||
|
const memberships = await db.query.partyMember.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
with: {
|
||||||
|
party: true,
|
||||||
|
},
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
return memberships[0]?.party ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMemberRecord(dbClient: DbLike, userId: string) {
|
||||||
|
return (
|
||||||
|
(await dbClient.query.partyMember.findFirst({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
})) ?? null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPartyStatus(partyId: string) {
|
||||||
|
const partyRecord = await db.query.party.findFirst({
|
||||||
|
where: {
|
||||||
|
id: partyId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!partyRecord) return null;
|
||||||
|
const members = await db.query.partyMember.findMany({
|
||||||
|
where: {
|
||||||
|
partyId,
|
||||||
|
},
|
||||||
|
with: {
|
||||||
|
user: true,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
joinedAt: "asc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
party: partyRecord,
|
||||||
|
members,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cleanupPartyIfEmpty(dbClient: DbLike, partyId: string) {
|
||||||
|
const members = await dbClient.query.partyMember.findMany({
|
||||||
|
where: {
|
||||||
|
partyId,
|
||||||
|
},
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
if (members.length > 0) return;
|
||||||
|
await dbClient.delete(party).where(eq(party.id, partyId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function leaveParty(dbClient: DbLike, userId: string) {
|
||||||
|
const member = await getMemberRecord(dbClient, userId);
|
||||||
|
if (!member) return null;
|
||||||
|
await dbClient.delete(partyMember).where(eq(partyMember.id, member.id));
|
||||||
|
const nextHost = await dbClient.query.partyMember.findFirst({
|
||||||
|
where: {
|
||||||
|
partyId: member.partyId,
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
joinedAt: "asc",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
let newHostId: string | null = null;
|
||||||
|
if (nextHost) {
|
||||||
|
const currentParty = await dbClient.query.party.findFirst({
|
||||||
|
where: {
|
||||||
|
id: member.partyId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (currentParty?.hostId === userId) {
|
||||||
|
await dbClient
|
||||||
|
.update(party)
|
||||||
|
.set({
|
||||||
|
hostId: nextHost.userId,
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(party.id, member.partyId));
|
||||||
|
newHostId = nextHost.userId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await cleanupPartyIfEmpty(dbClient, member.partyId);
|
||||||
|
return {
|
||||||
|
partyId: member.partyId,
|
||||||
|
newHostId,
|
||||||
|
};
|
||||||
|
}
|
||||||
159
api/src/party-sockets.ts
Normal file
159
api/src/party-sockets.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
type PartySocketEvent = {
|
||||||
|
type: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WebSocketLike = {
|
||||||
|
send: (data: string) => void;
|
||||||
|
close?: (code?: number, reason?: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const partySockets = new Map<string, Map<string, Set<WebSocketLike>>>();
|
||||||
|
const userSockets = new Map<string, Set<WebSocketLike>>();
|
||||||
|
|
||||||
|
function getPartyUserSockets(partyId: string, userId: string) {
|
||||||
|
const partyMap = partySockets.get(partyId);
|
||||||
|
if (!partyMap) return null;
|
||||||
|
return partyMap.get(userId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerPartySocket(
|
||||||
|
partyId: string,
|
||||||
|
userId: string,
|
||||||
|
ws: WebSocketLike,
|
||||||
|
) {
|
||||||
|
let partyMap = partySockets.get(partyId);
|
||||||
|
if (!partyMap) {
|
||||||
|
partyMap = new Map();
|
||||||
|
partySockets.set(partyId, partyMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
let userSockets = partyMap.get(userId);
|
||||||
|
if (!userSockets) {
|
||||||
|
userSockets = new Set();
|
||||||
|
partyMap.set(userId, userSockets);
|
||||||
|
}
|
||||||
|
|
||||||
|
userSockets.add(ws);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unregisterPartySocket(
|
||||||
|
partyId: string,
|
||||||
|
userId: string,
|
||||||
|
ws: WebSocketLike,
|
||||||
|
) {
|
||||||
|
const partyMap = partySockets.get(partyId);
|
||||||
|
if (!partyMap) return;
|
||||||
|
|
||||||
|
const userSockets = partyMap.get(userId);
|
||||||
|
if (!userSockets) return;
|
||||||
|
|
||||||
|
userSockets.delete(ws);
|
||||||
|
|
||||||
|
if (userSockets.size === 0) {
|
||||||
|
partyMap.delete(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (partyMap.size === 0) {
|
||||||
|
partySockets.delete(partyId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerUserSocket(userId: string, ws: WebSocketLike) {
|
||||||
|
let sockets = userSockets.get(userId);
|
||||||
|
if (!sockets) {
|
||||||
|
sockets = new Set();
|
||||||
|
userSockets.set(userId, sockets);
|
||||||
|
}
|
||||||
|
|
||||||
|
sockets.add(ws);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unregisterUserSocket(userId: string, ws: WebSocketLike) {
|
||||||
|
const sockets = userSockets.get(userId);
|
||||||
|
if (!sockets) return;
|
||||||
|
|
||||||
|
sockets.delete(ws);
|
||||||
|
|
||||||
|
if (sockets.size === 0) {
|
||||||
|
userSockets.delete(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unregisterUserSocketFromAllParties(
|
||||||
|
userId: string,
|
||||||
|
ws: WebSocketLike,
|
||||||
|
) {
|
||||||
|
for (const [partyId, partyMap] of partySockets) {
|
||||||
|
const userSockets = partyMap.get(userId);
|
||||||
|
if (!userSockets) continue;
|
||||||
|
userSockets.delete(ws);
|
||||||
|
if (userSockets.size === 0) {
|
||||||
|
partyMap.delete(userId);
|
||||||
|
}
|
||||||
|
if (partyMap.size === 0) {
|
||||||
|
partySockets.delete(partyId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function broadcastPartyEvent(partyId: string, event: PartySocketEvent) {
|
||||||
|
const partyMap = partySockets.get(partyId);
|
||||||
|
if (!partyMap) return;
|
||||||
|
|
||||||
|
const payload = JSON.stringify(event);
|
||||||
|
for (const userSockets of partyMap.values()) {
|
||||||
|
for (const ws of userSockets) {
|
||||||
|
ws.send(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendPartyEventToUser(
|
||||||
|
partyId: string,
|
||||||
|
userId: string,
|
||||||
|
event: PartySocketEvent,
|
||||||
|
) {
|
||||||
|
const userSockets = getPartyUserSockets(partyId, userId);
|
||||||
|
if (!userSockets) return;
|
||||||
|
|
||||||
|
const payload = JSON.stringify(event);
|
||||||
|
for (const ws of userSockets) {
|
||||||
|
ws.send(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendDirectEventToUser(userId: string, event: PartySocketEvent) {
|
||||||
|
const sockets = userSockets.get(userId);
|
||||||
|
if (!sockets) return;
|
||||||
|
|
||||||
|
const payload = JSON.stringify(event);
|
||||||
|
for (const ws of sockets) {
|
||||||
|
ws.send(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reassignUserSocketsToParty(
|
||||||
|
userId: string,
|
||||||
|
partyId: string | null,
|
||||||
|
) {
|
||||||
|
for (const [existingPartyId, partyMap] of partySockets) {
|
||||||
|
if (!partyMap.has(userId)) continue;
|
||||||
|
partyMap.delete(userId);
|
||||||
|
if (partyMap.size === 0) {
|
||||||
|
partySockets.delete(existingPartyId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!partyId) return;
|
||||||
|
const sockets = userSockets.get(userId);
|
||||||
|
if (!sockets) return;
|
||||||
|
|
||||||
|
let partyMap = partySockets.get(partyId);
|
||||||
|
if (!partyMap) {
|
||||||
|
partyMap = new Map();
|
||||||
|
partySockets.set(partyId, partyMap);
|
||||||
|
}
|
||||||
|
|
||||||
|
partyMap.set(userId, new Set(sockets));
|
||||||
|
}
|
||||||
137
api/src/routes/party-socket.ts
Normal file
137
api/src/routes/party-socket.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
import Elysia, { t } from "elysia";
|
||||||
|
import { auth, betterAuthElysia } from "../auth";
|
||||||
|
import { db } from "../db";
|
||||||
|
import { getMemberRecord, getPartyStatus } from "../party-data";
|
||||||
|
import {
|
||||||
|
registerPartySocket,
|
||||||
|
registerUserSocket,
|
||||||
|
sendPartyEventToUser,
|
||||||
|
unregisterPartySocket,
|
||||||
|
unregisterUserSocket,
|
||||||
|
unregisterUserSocketFromAllParties,
|
||||||
|
} from "../party-sockets";
|
||||||
|
|
||||||
|
type PartySocketMessage =
|
||||||
|
| {
|
||||||
|
type: "member_payload";
|
||||||
|
payload: unknown;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "ping";
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_MEMBER_PAYLOAD_SIZE = 8_000;
|
||||||
|
|
||||||
|
type PartyWsData = {
|
||||||
|
user?: { id: string };
|
||||||
|
partyId?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getPayloadSize(payload: unknown) {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(payload).length;
|
||||||
|
} catch {
|
||||||
|
return Infinity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const partySocketApp = new Elysia()
|
||||||
|
.use(betterAuthElysia)
|
||||||
|
.group("/party-socket", (app) =>
|
||||||
|
app.ws("/ws", {
|
||||||
|
beforeHandle: async ({ request, set }) => {
|
||||||
|
const session = await auth.api.getSession({
|
||||||
|
headers: request.headers,
|
||||||
|
});
|
||||||
|
if (!session) {
|
||||||
|
set.status = 401;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
user: session.user,
|
||||||
|
session: session.session,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
open: async (ws) => {
|
||||||
|
const data = ws.data as unknown as PartyWsData;
|
||||||
|
const user = data.user;
|
||||||
|
if (!user) return;
|
||||||
|
registerUserSocket(user.id, ws);
|
||||||
|
const membership = await getMemberRecord(db, user.id);
|
||||||
|
if (!membership) {
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "snapshot",
|
||||||
|
party: null,
|
||||||
|
members: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = await getPartyStatus(membership.partyId);
|
||||||
|
data.partyId = membership.partyId;
|
||||||
|
registerPartySocket(membership.partyId, user.id, ws);
|
||||||
|
if (snapshot) {
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "snapshot",
|
||||||
|
party: snapshot.party,
|
||||||
|
members: snapshot.members,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
message: async (ws, message: PartySocketMessage) => {
|
||||||
|
const data = ws.data as unknown as PartyWsData;
|
||||||
|
const user = data.user;
|
||||||
|
if (!user) return;
|
||||||
|
if (message.type === "ping") {
|
||||||
|
ws.send(JSON.stringify({ type: "pong" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type !== "member_payload") return;
|
||||||
|
const membership = await getMemberRecord(db, user.id);
|
||||||
|
if (!membership) return;
|
||||||
|
|
||||||
|
if (getPayloadSize(message.payload) > MAX_MEMBER_PAYLOAD_SIZE) {
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "error",
|
||||||
|
message: "Payload too large.",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentParty = await db.query.party.findFirst({
|
||||||
|
where: { id: membership.partyId },
|
||||||
|
});
|
||||||
|
if (!currentParty) return;
|
||||||
|
|
||||||
|
sendPartyEventToUser(membership.partyId, currentParty.hostId, {
|
||||||
|
type: "member_payload",
|
||||||
|
fromUserId: user.id,
|
||||||
|
payload: message.payload,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
close: async (ws) => {
|
||||||
|
const data = ws.data as unknown as PartyWsData;
|
||||||
|
const user = data.user;
|
||||||
|
const { partyId } = data;
|
||||||
|
if (!user) return;
|
||||||
|
if (!partyId) {
|
||||||
|
unregisterUserSocketFromAllParties(user.id, ws);
|
||||||
|
unregisterUserSocket(user.id, ws);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unregisterPartySocket(partyId, user.id, ws);
|
||||||
|
unregisterUserSocket(user.id, ws);
|
||||||
|
},
|
||||||
|
body: t.Union([
|
||||||
|
t.Object({ type: t.Literal("ping") }),
|
||||||
|
t.Object({ type: t.Literal("member_payload"), payload: t.Any() }),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
@ -1,298 +1,300 @@
|
||||||
import Elysia, { t } from "elysia";
|
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
|
import Elysia, { t } from "elysia";
|
||||||
import { betterAuthElysia } from "../auth";
|
import { betterAuthElysia } from "../auth";
|
||||||
import { db } from "../db";
|
import { db } from "../db";
|
||||||
import { party, partyMember } from "../db/schema";
|
import { party, partyMember } from "../db/schema";
|
||||||
|
import {
|
||||||
|
cleanupPartyIfEmpty,
|
||||||
|
getMemberRecord,
|
||||||
|
getPartyForUser,
|
||||||
|
getPartyStatus,
|
||||||
|
leaveParty,
|
||||||
|
} from "../party-data";
|
||||||
|
import {
|
||||||
|
broadcastPartyEvent,
|
||||||
|
reassignUserSocketsToParty,
|
||||||
|
sendDirectEventToUser,
|
||||||
|
} from "../party-sockets";
|
||||||
|
|
||||||
const PARTY_STATUS = ["created", "started", "ended"] as const;
|
const PARTY_STATUS = ["created", "started", "ended"] as const;
|
||||||
|
|
||||||
type PartyStatus = (typeof PARTY_STATUS)[number];
|
type PartyStatus = (typeof PARTY_STATUS)[number];
|
||||||
|
|
||||||
type DbClient = typeof db;
|
type PartySnapshot = NonNullable<Awaited<ReturnType<typeof getPartyStatus>>>;
|
||||||
type DbTransaction = Parameters<typeof db.transaction>[0] extends (
|
|
||||||
tx: infer T,
|
|
||||||
) => Promise<any>
|
|
||||||
? T
|
|
||||||
: never;
|
|
||||||
type DbLike = DbClient | DbTransaction;
|
|
||||||
|
|
||||||
async function getPartyForUser(userId: string) {
|
function broadcastSnapshot(partyId: string, snapshot: PartySnapshot | null) {
|
||||||
const memberships = await db.query.partyMember.findMany({
|
if (!snapshot) return;
|
||||||
where: {
|
broadcastPartyEvent(partyId, {
|
||||||
userId,
|
type: "party_status",
|
||||||
},
|
party: snapshot.party,
|
||||||
with: {
|
members: snapshot.members,
|
||||||
party: true,
|
});
|
||||||
},
|
|
||||||
limit: 1,
|
|
||||||
});
|
|
||||||
return memberships[0]?.party ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getMemberRecord(dbClient: DbLike, userId: string) {
|
|
||||||
return (
|
|
||||||
(await dbClient.query.partyMember.findFirst({
|
|
||||||
where: {
|
|
||||||
userId,
|
|
||||||
},
|
|
||||||
})) ?? null
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getPartyStatus(partyId: string) {
|
|
||||||
const party = await db.query.party.findFirst({
|
|
||||||
where: {
|
|
||||||
id: partyId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!party) return null;
|
|
||||||
const members = await db.query.partyMember.findMany({
|
|
||||||
where: {
|
|
||||||
partyId,
|
|
||||||
},
|
|
||||||
with: {
|
|
||||||
user: true,
|
|
||||||
},
|
|
||||||
orderBy: {
|
|
||||||
joinedAt: "asc",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
party,
|
|
||||||
members,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function cleanupPartyIfEmpty(dbClient: DbLike, partyId: string) {
|
|
||||||
const members = await dbClient.query.partyMember.findMany({
|
|
||||||
where: {
|
|
||||||
partyId,
|
|
||||||
},
|
|
||||||
limit: 1,
|
|
||||||
});
|
|
||||||
if (members.length > 0) return;
|
|
||||||
await dbClient.delete(party).where(eq(party.id, partyId));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function leaveParty(dbClient: DbLike, userId: string) {
|
|
||||||
const member = await getMemberRecord(dbClient, userId);
|
|
||||||
if (!member) return null;
|
|
||||||
await dbClient.delete(partyMember).where(eq(partyMember.id, member.id));
|
|
||||||
const nextHost = await dbClient.query.partyMember.findFirst({
|
|
||||||
where: {
|
|
||||||
partyId: member.partyId,
|
|
||||||
},
|
|
||||||
orderBy: {
|
|
||||||
joinedAt: "asc",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (nextHost) {
|
|
||||||
const currentParty = await dbClient.query.party.findFirst({
|
|
||||||
where: {
|
|
||||||
id: member.partyId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (currentParty?.hostId === userId) {
|
|
||||||
await dbClient
|
|
||||||
.update(party)
|
|
||||||
.set({
|
|
||||||
hostId: nextHost.userId,
|
|
||||||
lastUpdated: new Date(),
|
|
||||||
})
|
|
||||||
.where(eq(party.id, member.partyId));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await cleanupPartyIfEmpty(dbClient, member.partyId);
|
|
||||||
return member.partyId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isValidStatus(status: string): status is PartyStatus {
|
function isValidStatus(status: string): status is PartyStatus {
|
||||||
return PARTY_STATUS.includes(status as PartyStatus);
|
return PARTY_STATUS.includes(status as PartyStatus);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const partyApp = new Elysia()
|
export const partyApp = new Elysia()
|
||||||
.use(betterAuthElysia)
|
.use(betterAuthElysia)
|
||||||
.group("/party", (app) =>
|
.group("/party", (app) =>
|
||||||
app
|
app
|
||||||
.get(
|
.get(
|
||||||
"/status",
|
"/status",
|
||||||
async ({ user }) => {
|
async ({ user }) => {
|
||||||
const currentParty = await getPartyForUser(user.id);
|
const currentParty = await getPartyForUser(user.id);
|
||||||
if (!currentParty) return { party: null, members: [] };
|
if (!currentParty) return { party: null, members: [] };
|
||||||
const status = await getPartyStatus(currentParty.id);
|
const status = await getPartyStatus(currentParty.id);
|
||||||
return status ?? { party: null, members: [] };
|
return status ?? { party: null, members: [] };
|
||||||
},
|
},
|
||||||
{ auth: true },
|
{ auth: true },
|
||||||
)
|
)
|
||||||
.post(
|
.post(
|
||||||
"/join",
|
"/join",
|
||||||
async ({ user, body, set }) => {
|
async ({ user, body, set }) => {
|
||||||
const targetUserId = body.targetUserId;
|
const targetUserId = body.targetUserId;
|
||||||
const targetUser = await db.query.user.findFirst({
|
const targetUser = await db.query.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: targetUserId,
|
id: targetUserId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!targetUser) {
|
if (!targetUser) {
|
||||||
set.status = 404;
|
set.status = 404;
|
||||||
return { error: "Target user not found." };
|
return { error: "Target user not found." };
|
||||||
}
|
}
|
||||||
|
|
||||||
let partyId: string | null = null;
|
const { partyId, hostChanged, leaveResult } = await db.transaction(
|
||||||
await db.transaction(async (tx) => {
|
async (tx) => {
|
||||||
await leaveParty(tx, user.id);
|
const leaveResult = await leaveParty(tx, user.id);
|
||||||
|
let partyId: string | null = null;
|
||||||
|
let hostChanged = false;
|
||||||
|
|
||||||
const targetMembership = await getMemberRecord(tx, targetUserId);
|
const targetMembership = await getMemberRecord(tx, targetUserId);
|
||||||
if (targetMembership) {
|
if (targetMembership) {
|
||||||
partyId = targetMembership.partyId;
|
partyId = targetMembership.partyId;
|
||||||
await tx
|
await tx
|
||||||
.update(party)
|
.update(party)
|
||||||
.set({
|
.set({
|
||||||
hostId: targetUserId,
|
hostId: targetUserId,
|
||||||
lastUpdated: new Date(),
|
lastUpdated: new Date(),
|
||||||
})
|
})
|
||||||
.where(eq(party.id, partyId));
|
.where(eq(party.id, partyId));
|
||||||
} else {
|
hostChanged = true;
|
||||||
const created = await tx
|
} else {
|
||||||
.insert(party)
|
const created = await tx
|
||||||
.values({
|
.insert(party)
|
||||||
status: "created",
|
.values({
|
||||||
hostId: targetUserId,
|
status: "created",
|
||||||
})
|
hostId: targetUserId,
|
||||||
.returning({ id: party.id });
|
})
|
||||||
partyId = created[0]!.id;
|
.returning({ id: party.id });
|
||||||
await tx.insert(partyMember).values({
|
const createdId = created[0]?.id ?? null;
|
||||||
partyId,
|
if (!createdId) {
|
||||||
userId: targetUserId,
|
return {
|
||||||
});
|
partyId: null,
|
||||||
}
|
hostChanged,
|
||||||
|
leaveResult,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
partyId = createdId;
|
||||||
|
await tx.insert(partyMember).values({
|
||||||
|
partyId,
|
||||||
|
userId: targetUserId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await tx
|
if (!partyId) {
|
||||||
.insert(partyMember)
|
return {
|
||||||
.values({ partyId, userId: user.id })
|
partyId: null,
|
||||||
.onConflictDoNothing();
|
hostChanged,
|
||||||
});
|
leaveResult,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (!partyId) return { party: null, members: [] };
|
await tx
|
||||||
const status = await getPartyStatus(partyId);
|
.insert(partyMember)
|
||||||
return status ?? { party: null, members: [] };
|
.values({ partyId, userId: user.id })
|
||||||
},
|
.onConflictDoNothing();
|
||||||
{
|
|
||||||
auth: true,
|
|
||||||
body: t.Object({
|
|
||||||
targetUserId: t.String(),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.post(
|
|
||||||
"/leave",
|
|
||||||
async ({ user }) => {
|
|
||||||
const partyId = await db.transaction(async (tx) => {
|
|
||||||
return await leaveParty(tx, user.id);
|
|
||||||
});
|
|
||||||
if (!partyId) return { party: null, members: [] };
|
|
||||||
const status = await getPartyStatus(partyId);
|
|
||||||
return status ?? { party: null, members: [] };
|
|
||||||
},
|
|
||||||
{ auth: true },
|
|
||||||
)
|
|
||||||
.post(
|
|
||||||
"/kick",
|
|
||||||
async ({ user, body, set }) => {
|
|
||||||
const currentMembership = await getMemberRecord(db, user.id);
|
|
||||||
if (!currentMembership) {
|
|
||||||
set.status = 400;
|
|
||||||
return { error: "You are not in a party." };
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentParty = await db.query.party.findFirst({
|
return {
|
||||||
where: {
|
partyId,
|
||||||
id: currentMembership.partyId,
|
hostChanged,
|
||||||
},
|
leaveResult,
|
||||||
});
|
};
|
||||||
if (!currentParty || currentParty.hostId !== user.id) {
|
},
|
||||||
set.status = 403;
|
);
|
||||||
return { error: "Only the host can kick members." };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (body.memberUserId === user.id) {
|
if (!partyId) return { party: null, members: [] };
|
||||||
set.status = 400;
|
const status = await getPartyStatus(partyId);
|
||||||
return { error: "Host cannot kick themselves." };
|
if (leaveResult?.newHostId) {
|
||||||
}
|
broadcastPartyEvent(leaveResult.partyId, {
|
||||||
|
type: "host_changed",
|
||||||
|
hostId: leaveResult.newHostId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (hostChanged) {
|
||||||
|
broadcastPartyEvent(partyId, {
|
||||||
|
type: "host_changed",
|
||||||
|
hostId: targetUserId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
broadcastPartyEvent(partyId, {
|
||||||
|
type: "member_joined",
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
broadcastSnapshot(partyId, status);
|
||||||
|
reassignUserSocketsToParty(user.id, partyId);
|
||||||
|
reassignUserSocketsToParty(targetUserId, partyId);
|
||||||
|
if (status) {
|
||||||
|
sendDirectEventToUser(targetUserId, {
|
||||||
|
type: "party_status",
|
||||||
|
party: status.party,
|
||||||
|
members: status.members,
|
||||||
|
});
|
||||||
|
sendDirectEventToUser(user.id, {
|
||||||
|
type: "party_status",
|
||||||
|
party: status.party,
|
||||||
|
members: status.members,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return status ?? { party: null, members: [] };
|
||||||
|
},
|
||||||
|
{
|
||||||
|
auth: true,
|
||||||
|
body: t.Object({
|
||||||
|
targetUserId: t.String(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.post(
|
||||||
|
"/leave",
|
||||||
|
async ({ user }) => {
|
||||||
|
const result = await db.transaction(async (tx) => {
|
||||||
|
return await leaveParty(tx, user.id);
|
||||||
|
});
|
||||||
|
if (!result) return { party: null, members: [] };
|
||||||
|
const status = await getPartyStatus(result.partyId);
|
||||||
|
broadcastPartyEvent(result.partyId, {
|
||||||
|
type: "member_left",
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
if (result.newHostId) {
|
||||||
|
broadcastPartyEvent(result.partyId, {
|
||||||
|
type: "host_changed",
|
||||||
|
hostId: result.newHostId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
broadcastSnapshot(result.partyId, status);
|
||||||
|
reassignUserSocketsToParty(user.id, null);
|
||||||
|
return status ?? { party: null, members: [] };
|
||||||
|
},
|
||||||
|
{ auth: true },
|
||||||
|
)
|
||||||
|
.post(
|
||||||
|
"/kick",
|
||||||
|
async ({ user, body, set }) => {
|
||||||
|
const currentMembership = await getMemberRecord(db, user.id);
|
||||||
|
if (!currentMembership) {
|
||||||
|
set.status = 400;
|
||||||
|
return { error: "You are not in a party." };
|
||||||
|
}
|
||||||
|
|
||||||
await db.transaction(async (tx) => {
|
const currentParty = await db.query.party.findFirst({
|
||||||
await tx
|
where: {
|
||||||
.delete(partyMember)
|
id: currentMembership.partyId,
|
||||||
.where(
|
},
|
||||||
and(
|
});
|
||||||
eq(partyMember.partyId, currentMembership.partyId),
|
if (!currentParty || currentParty.hostId !== user.id) {
|
||||||
eq(partyMember.userId, body.memberUserId),
|
set.status = 403;
|
||||||
),
|
return { error: "Only the host can kick members." };
|
||||||
);
|
}
|
||||||
await cleanupPartyIfEmpty(tx, currentMembership.partyId);
|
|
||||||
});
|
|
||||||
const status = await getPartyStatus(currentMembership.partyId);
|
|
||||||
return status ?? { party: null, members: [] };
|
|
||||||
},
|
|
||||||
{
|
|
||||||
auth: true,
|
|
||||||
body: t.Object({
|
|
||||||
memberUserId: t.String(),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.post(
|
|
||||||
"/status",
|
|
||||||
async ({ user, body, set }) => {
|
|
||||||
const currentMembership = await getMemberRecord(db, user.id);
|
|
||||||
if (!currentMembership) {
|
|
||||||
set.status = 400;
|
|
||||||
return { error: "You are not in a party." };
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentParty = await db.query.party.findFirst({
|
if (body.memberUserId === user.id) {
|
||||||
where: {
|
set.status = 400;
|
||||||
id: currentMembership.partyId,
|
return { error: "Host cannot kick themselves." };
|
||||||
},
|
}
|
||||||
});
|
|
||||||
if (!currentParty || currentParty.hostId !== user.id) {
|
|
||||||
set.status = 403;
|
|
||||||
return { error: "Only the host can update party status." };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isValidStatus(body.status)) {
|
await db.transaction(async (tx) => {
|
||||||
set.status = 400;
|
await tx
|
||||||
return { error: "Invalid party status." };
|
.delete(partyMember)
|
||||||
}
|
.where(
|
||||||
|
and(
|
||||||
|
eq(partyMember.partyId, currentMembership.partyId),
|
||||||
|
eq(partyMember.userId, body.memberUserId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await cleanupPartyIfEmpty(tx, currentMembership.partyId);
|
||||||
|
});
|
||||||
|
const status = await getPartyStatus(currentMembership.partyId);
|
||||||
|
broadcastPartyEvent(currentMembership.partyId, {
|
||||||
|
type: "member_left",
|
||||||
|
userId: body.memberUserId,
|
||||||
|
kickedBy: user.id,
|
||||||
|
});
|
||||||
|
broadcastSnapshot(currentMembership.partyId, status);
|
||||||
|
reassignUserSocketsToParty(body.memberUserId, null);
|
||||||
|
return status ?? { party: null, members: [] };
|
||||||
|
},
|
||||||
|
{
|
||||||
|
auth: true,
|
||||||
|
body: t.Object({
|
||||||
|
memberUserId: t.String(),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.post(
|
||||||
|
"/status",
|
||||||
|
async ({ user, body, set }) => {
|
||||||
|
const currentMembership = await getMemberRecord(db, user.id);
|
||||||
|
if (!currentMembership) {
|
||||||
|
set.status = 400;
|
||||||
|
return { error: "You are not in a party." };
|
||||||
|
}
|
||||||
|
|
||||||
const currentData =
|
const currentParty = await db.query.party.findFirst({
|
||||||
currentParty?.data && typeof currentParty.data === "object"
|
where: {
|
||||||
? currentParty.data
|
id: currentMembership.partyId,
|
||||||
: {};
|
},
|
||||||
const nextData = body.data
|
});
|
||||||
? { ...currentData, ...body.data }
|
if (!currentParty || currentParty.hostId !== user.id) {
|
||||||
: currentData;
|
set.status = 403;
|
||||||
|
return { error: "Only the host can update party status." };
|
||||||
|
}
|
||||||
|
|
||||||
await db.transaction(async (tx) => {
|
if (!isValidStatus(body.status)) {
|
||||||
await tx
|
set.status = 400;
|
||||||
.update(party)
|
return { error: "Invalid party status." };
|
||||||
.set({
|
}
|
||||||
status: body.status,
|
|
||||||
data: nextData,
|
|
||||||
lastUpdated: new Date(),
|
|
||||||
})
|
|
||||||
.where(eq(party.id, currentMembership.partyId));
|
|
||||||
});
|
|
||||||
|
|
||||||
const status = await getPartyStatus(currentMembership.partyId);
|
const currentData =
|
||||||
return status ?? { party: null, members: [] };
|
currentParty?.data && typeof currentParty.data === "object"
|
||||||
},
|
? currentParty.data
|
||||||
{
|
: {};
|
||||||
auth: true,
|
const nextData = body.data
|
||||||
body: t.Object({
|
? { ...currentData, ...body.data }
|
||||||
status: t.Enum({ created: "created", started: "started" }),
|
: currentData;
|
||||||
data: t.Optional(t.Any()),
|
|
||||||
}),
|
await db.transaction(async (tx) => {
|
||||||
},
|
await tx
|
||||||
),
|
.update(party)
|
||||||
);
|
.set({
|
||||||
|
status: body.status,
|
||||||
|
data: nextData,
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(party.id, currentMembership.partyId));
|
||||||
|
});
|
||||||
|
|
||||||
|
const status = await getPartyStatus(currentMembership.partyId);
|
||||||
|
broadcastSnapshot(currentMembership.partyId, status);
|
||||||
|
return status ?? { party: null, members: [] };
|
||||||
|
},
|
||||||
|
{
|
||||||
|
auth: true,
|
||||||
|
body: t.Object({
|
||||||
|
status: t.Enum({ created: "created", started: "started" }),
|
||||||
|
data: t.Optional(t.Any()),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,79 +1,79 @@
|
||||||
import Elysia from "elysia";
|
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
|
import Elysia from "elysia";
|
||||||
import { betterAuthElysia } from "../auth";
|
import { betterAuthElysia } from "../auth";
|
||||||
import { db } from "../db";
|
import { db } from "../db";
|
||||||
import {
|
import {
|
||||||
artistGenre,
|
artistGenre,
|
||||||
genre,
|
genre,
|
||||||
savedTrack,
|
savedTrack,
|
||||||
topTrack,
|
topTrack,
|
||||||
trackArtist,
|
trackArtist,
|
||||||
} from "../db/schema";
|
} from "../db/schema";
|
||||||
|
|
||||||
export const statsApp = new Elysia().use(betterAuthElysia).get(
|
export const statsApp = new Elysia().use(betterAuthElysia).get(
|
||||||
"/stats",
|
"/stats",
|
||||||
async ({ user }) => {
|
async ({ user }) => {
|
||||||
const topArtists = await db.query.topArtist.findMany({
|
const topArtists = await db.query.topArtist.findMany({
|
||||||
limit: 10,
|
limit: 10,
|
||||||
with: {
|
with: {
|
||||||
artist: {
|
artist: {
|
||||||
with: {
|
with: {
|
||||||
genres: true,
|
genres: true,
|
||||||
images: true,
|
images: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const topTracks = await db.query.topTrack.findMany({
|
const topTracks = await db.query.topTrack.findMany({
|
||||||
limit: 10,
|
limit: 10,
|
||||||
with: {
|
with: {
|
||||||
track: {
|
track: {
|
||||||
with: {
|
with: {
|
||||||
album: {
|
album: {
|
||||||
with: {
|
with: {
|
||||||
images: true,
|
images: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
artists: {
|
artists: {
|
||||||
with: {
|
with: {
|
||||||
genres: true,
|
genres: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
timeline: "medium_term",
|
timeline: "medium_term",
|
||||||
},
|
},
|
||||||
orderBy: {
|
orderBy: {
|
||||||
position: "desc",
|
position: "desc",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const recentTracks = await db.query.playbackHistory.findMany({
|
const recentTracks = await db.query.playbackHistory.findMany({
|
||||||
limit: 10,
|
limit: 10,
|
||||||
with: {
|
with: {
|
||||||
track: {
|
track: {
|
||||||
with: {
|
with: {
|
||||||
album: {
|
album: {
|
||||||
with: {
|
with: {
|
||||||
images: true,
|
images: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const topGenresResult = await db.execute<{
|
const topGenresResult = await db.execute<{
|
||||||
name: string;
|
name: string;
|
||||||
count: number;
|
count: number;
|
||||||
}>(sql`
|
}>(sql`
|
||||||
select ${genre.name} as name, count(*)::int as count
|
select ${genre.name} as name, count(*)::int as count
|
||||||
from (
|
from (
|
||||||
select distinct ${trackArtist.trackId} as track_id, ${artistGenre.genreId} as genre_id
|
select distinct ${trackArtist.trackId} as track_id, ${artistGenre.genreId} as genre_id
|
||||||
|
|
@ -95,10 +95,10 @@ export const statsApp = new Elysia().use(betterAuthElysia).get(
|
||||||
order by count desc
|
order by count desc
|
||||||
limit 10
|
limit 10
|
||||||
`);
|
`);
|
||||||
const topGenres = topGenresResult.rows;
|
const topGenres = topGenresResult.rows;
|
||||||
return { topArtists, topTracks, recentTracks, topGenres };
|
return { topArtists, topTracks, recentTracks, topGenres };
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
auth: true,
|
auth: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,11 @@ import { betterAuthElysia } from "../auth";
|
||||||
import { spotifySyncWorkflow } from "../workflows/sync";
|
import { spotifySyncWorkflow } from "../workflows/sync";
|
||||||
|
|
||||||
export const syncApp = new Elysia().use(betterAuthElysia).post(
|
export const syncApp = new Elysia().use(betterAuthElysia).post(
|
||||||
"/sync",
|
"/sync",
|
||||||
async ({ user }) => {
|
async ({ user }) => {
|
||||||
return await spotifySyncWorkflow.syncUser(user.id);
|
return await spotifySyncWorkflow.syncUser(user.id);
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
auth: true,
|
auth: true,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,246 +1,247 @@
|
||||||
import { DBOS, ConfiguredInstance } from "@dbos-inc/dbos-sdk";
|
import { ConfiguredInstance, DBOS } from "@dbos-inc/dbos-sdk";
|
||||||
import { SpotifyApi } from "@spotify/web-api-ts-sdk";
|
|
||||||
import type {
|
import type {
|
||||||
Artist,
|
Artist,
|
||||||
PlayHistory,
|
PlayHistory,
|
||||||
SavedAlbum,
|
SavedAlbum,
|
||||||
SavedTrack,
|
SavedTrack,
|
||||||
Track,
|
Track,
|
||||||
} from "@spotify/web-api-ts-sdk";
|
} from "@spotify/web-api-ts-sdk";
|
||||||
|
import { SpotifyApi } from "@spotify/web-api-ts-sdk";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { auth, SPOTIFY_CLIENT_ID } from "../auth";
|
import { auth, SPOTIFY_CLIENT_ID } from "../auth";
|
||||||
import { db } from "../db";
|
import { db } from "../db";
|
||||||
import {
|
import {
|
||||||
followedArtist,
|
followedArtist,
|
||||||
savedAlbum,
|
savedAlbum,
|
||||||
savedTrack,
|
savedTrack,
|
||||||
topArtist,
|
topArtist,
|
||||||
topTrack,
|
topTrack,
|
||||||
} from "../db/schema";
|
} from "../db/schema";
|
||||||
import {
|
import {
|
||||||
upsertFollowedArtists,
|
upsertFollowedArtists,
|
||||||
upsertPlaybackHistory,
|
upsertPlaybackHistory,
|
||||||
upsertSavedAlbums,
|
upsertSavedAlbums,
|
||||||
upsertSavedTracks,
|
upsertSavedTracks,
|
||||||
upsertTopArtists,
|
upsertTopArtists,
|
||||||
upsertTopTracks,
|
upsertTopTracks,
|
||||||
} from "../db/spotify";
|
} from "../db/spotify";
|
||||||
import { eq } from "drizzle-orm";
|
|
||||||
|
|
||||||
const timelines = ["short_term", "medium_term", "long_term"] as const;
|
const timelines = ["short_term", "medium_term", "long_term"] as const;
|
||||||
type Timeline = (typeof timelines)[number];
|
type Timeline = (typeof timelines)[number];
|
||||||
|
|
||||||
type SyncPayload = {
|
type SyncPayload = {
|
||||||
topArtistsByTimeline: Record<Timeline, Artist[]>;
|
topArtistsByTimeline: Record<Timeline, Artist[]>;
|
||||||
topTracksByTimeline: Record<Timeline, Track[]>;
|
topTracksByTimeline: Record<Timeline, Track[]>;
|
||||||
followedArtists: Artist[];
|
followedArtists: Artist[];
|
||||||
savedAlbums: SavedAlbum[];
|
savedAlbums: SavedAlbum[];
|
||||||
savedTracks: SavedTrack[];
|
savedTracks: SavedTrack[];
|
||||||
recentlyPlayed: PlayHistory[];
|
recentlyPlayed: PlayHistory[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export class SpotifySyncWorkflow extends ConfiguredInstance {
|
export class SpotifySyncWorkflow extends ConfiguredInstance {
|
||||||
constructor(name: string) {
|
@DBOS.workflow()
|
||||||
super(name);
|
async syncUser(userId: string) {
|
||||||
}
|
console.log("Sync start");
|
||||||
|
const data = await this.fetchSpotifyData(userId);
|
||||||
|
console.log("Sync data fetched");
|
||||||
|
await this.persistSpotifyData(userId, data);
|
||||||
|
console.log("Synced");
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
@DBOS.workflow()
|
private async fetchSpotifyData(userId: string): Promise<SyncPayload> {
|
||||||
async syncUser(userId: string) {
|
const topArtistsByTimeline = await this.fetchTopArtists(userId);
|
||||||
console.log("Sync start");
|
const topTracksByTimeline = await this.fetchTopTracks(userId);
|
||||||
const data = await this.fetchSpotifyData(userId);
|
const followedArtists = await this.fetchFollowedArtists(userId);
|
||||||
console.log("Sync data fetched");
|
const savedAlbums = await this.fetchSavedAlbums(userId);
|
||||||
await this.persistSpotifyData(userId, data);
|
const savedTracks = await this.fetchSavedTracks(userId);
|
||||||
console.log("Synced");
|
const recentlyPlayed = await this.fetchRecentlyPlayed(userId);
|
||||||
return { ok: true };
|
return {
|
||||||
}
|
topArtistsByTimeline,
|
||||||
|
topTracksByTimeline,
|
||||||
|
followedArtists,
|
||||||
|
savedAlbums,
|
||||||
|
savedTracks,
|
||||||
|
recentlyPlayed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private async fetchSpotifyData(userId: string): Promise<SyncPayload> {
|
private async persistSpotifyData(userId: string, data: SyncPayload) {
|
||||||
const topArtistsByTimeline = await this.fetchTopArtists(userId);
|
await this.persistTopArtists(userId, data.topArtistsByTimeline);
|
||||||
const topTracksByTimeline = await this.fetchTopTracks(userId);
|
await this.persistTopTracks(userId, data.topTracksByTimeline);
|
||||||
const followedArtists = await this.fetchFollowedArtists(userId);
|
await this.persistFollowedArtists(userId, data.followedArtists);
|
||||||
const savedAlbums = await this.fetchSavedAlbums(userId);
|
await this.persistSavedAlbums(userId, data.savedAlbums);
|
||||||
const savedTracks = await this.fetchSavedTracks(userId);
|
await this.persistSavedTracks(userId, data.savedTracks);
|
||||||
const recentlyPlayed = await this.fetchRecentlyPlayed(userId);
|
await this.persistPlaybackHistory(userId, data.recentlyPlayed);
|
||||||
return {
|
}
|
||||||
topArtistsByTimeline,
|
|
||||||
topTracksByTimeline,
|
|
||||||
followedArtists,
|
|
||||||
savedAlbums,
|
|
||||||
savedTracks,
|
|
||||||
recentlyPlayed,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async persistSpotifyData(userId: string, data: SyncPayload) {
|
@DBOS.step()
|
||||||
await this.persistTopArtists(userId, data.topArtistsByTimeline);
|
private async persistTopArtists(
|
||||||
await this.persistTopTracks(userId, data.topTracksByTimeline);
|
userId: string,
|
||||||
await this.persistFollowedArtists(userId, data.followedArtists);
|
topArtistsByTimeline: Record<Timeline, Artist[]>,
|
||||||
await this.persistSavedAlbums(userId, data.savedAlbums);
|
) {
|
||||||
await this.persistSavedTracks(userId, data.savedTracks);
|
await db.transaction(async (tx) => {
|
||||||
await this.persistPlaybackHistory(userId, data.recentlyPlayed);
|
await tx.delete(topArtist).where(eq(topArtist.userId, userId));
|
||||||
}
|
for (const timeline of timelines) {
|
||||||
|
await upsertTopArtists(
|
||||||
|
userId,
|
||||||
|
timeline,
|
||||||
|
topArtistsByTimeline[timeline],
|
||||||
|
tx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@DBOS.step()
|
@DBOS.step()
|
||||||
private async persistTopArtists(
|
private async persistTopTracks(
|
||||||
userId: string,
|
userId: string,
|
||||||
topArtistsByTimeline: Record<Timeline, Artist[]>,
|
topTracksByTimeline: Record<Timeline, Track[]>,
|
||||||
) {
|
) {
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
await tx.delete(topArtist).where(eq(topArtist.userId, userId));
|
await tx.delete(topTrack).where(eq(topTrack.userId, userId));
|
||||||
for (const timeline of timelines) {
|
for (const timeline of timelines) {
|
||||||
await upsertTopArtists(
|
await upsertTopTracks(
|
||||||
userId,
|
userId,
|
||||||
timeline,
|
timeline,
|
||||||
topArtistsByTimeline[timeline],
|
topTracksByTimeline[timeline],
|
||||||
tx,
|
tx,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@DBOS.step()
|
@DBOS.step()
|
||||||
private async persistTopTracks(
|
private async persistFollowedArtists(userId: string, artists: Artist[]) {
|
||||||
userId: string,
|
await db.transaction(async (tx) => {
|
||||||
topTracksByTimeline: Record<Timeline, Track[]>,
|
await tx.delete(followedArtist).where(eq(followedArtist.userId, userId));
|
||||||
) {
|
await upsertFollowedArtists(userId, artists, tx);
|
||||||
await db.transaction(async (tx) => {
|
});
|
||||||
await tx.delete(topTrack).where(eq(topTrack.userId, userId));
|
}
|
||||||
for (const timeline of timelines) {
|
|
||||||
await upsertTopTracks(
|
|
||||||
userId,
|
|
||||||
timeline,
|
|
||||||
topTracksByTimeline[timeline],
|
|
||||||
tx,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@DBOS.step()
|
@DBOS.step()
|
||||||
private async persistFollowedArtists(userId: string, artists: Artist[]) {
|
private async persistSavedAlbums(userId: string, albums: SavedAlbum[]) {
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
await tx.delete(followedArtist).where(eq(followedArtist.userId, userId));
|
await tx.delete(savedAlbum).where(eq(savedAlbum.userId, userId));
|
||||||
await upsertFollowedArtists(userId, artists, tx);
|
await upsertSavedAlbums(userId, albums, tx);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@DBOS.step()
|
@DBOS.step()
|
||||||
private async persistSavedAlbums(userId: string, albums: SavedAlbum[]) {
|
private async persistSavedTracks(userId: string, tracks: SavedTrack[]) {
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
await tx.delete(savedAlbum).where(eq(savedAlbum.userId, userId));
|
await tx.delete(savedTrack).where(eq(savedTrack.userId, userId));
|
||||||
await upsertSavedAlbums(userId, albums, tx);
|
await upsertSavedTracks(userId, tracks, tx);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@DBOS.step()
|
@DBOS.step()
|
||||||
private async persistSavedTracks(userId: string, tracks: SavedTrack[]) {
|
private async persistPlaybackHistory(userId: string, items: PlayHistory[]) {
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
await tx.delete(savedTrack).where(eq(savedTrack.userId, userId));
|
await upsertPlaybackHistory(userId, items, tx);
|
||||||
await upsertSavedTracks(userId, tracks, tx);
|
});
|
||||||
});
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@DBOS.step()
|
@DBOS.step()
|
||||||
private async persistPlaybackHistory(userId: string, items: PlayHistory[]) {
|
private async fetchTopArtists(
|
||||||
await db.transaction(async (tx) => {
|
userId: string,
|
||||||
await upsertPlaybackHistory(userId, items, tx);
|
): Promise<Record<Timeline, Artist[]>> {
|
||||||
});
|
const sdk = await this.createSdk(userId);
|
||||||
}
|
const topArtistsByTimeline = {} as Record<Timeline, Artist[]>;
|
||||||
|
for (const timeline of timelines) {
|
||||||
|
const topArtists = await sdk.currentUser.topItems(
|
||||||
|
"artists",
|
||||||
|
timeline,
|
||||||
|
50,
|
||||||
|
);
|
||||||
|
topArtistsByTimeline[timeline] = topArtists.items;
|
||||||
|
}
|
||||||
|
return topArtistsByTimeline;
|
||||||
|
}
|
||||||
|
|
||||||
@DBOS.step()
|
@DBOS.step()
|
||||||
private async fetchTopArtists(
|
private async fetchTopTracks(
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<Record<Timeline, Artist[]>> {
|
): Promise<Record<Timeline, Track[]>> {
|
||||||
const sdk = await this.createSdk(userId);
|
const sdk = await this.createSdk(userId);
|
||||||
const topArtistsByTimeline = {} as Record<Timeline, Artist[]>;
|
const topTracksByTimeline = {} as Record<Timeline, Track[]>;
|
||||||
for (const timeline of timelines) {
|
for (const timeline of timelines) {
|
||||||
const topArtists = await sdk.currentUser.topItems(
|
const topTracks = await sdk.currentUser.topItems("tracks", timeline, 50);
|
||||||
"artists",
|
topTracksByTimeline[timeline] = topTracks.items;
|
||||||
timeline,
|
}
|
||||||
50,
|
return topTracksByTimeline;
|
||||||
);
|
}
|
||||||
topArtistsByTimeline[timeline] = topArtists.items;
|
|
||||||
}
|
|
||||||
return topArtistsByTimeline;
|
|
||||||
}
|
|
||||||
|
|
||||||
@DBOS.step()
|
@DBOS.step()
|
||||||
private async fetchTopTracks(
|
private async fetchFollowedArtists(userId: string): Promise<Artist[]> {
|
||||||
userId: string,
|
const sdk = await this.createSdk(userId);
|
||||||
): Promise<Record<Timeline, Track[]>> {
|
const followed: Artist[] = [];
|
||||||
const sdk = await this.createSdk(userId);
|
let after: string | undefined;
|
||||||
const topTracksByTimeline = {} as Record<Timeline, Track[]>;
|
while (true) {
|
||||||
for (const timeline of timelines) {
|
const page = await sdk.currentUser.followedArtists(after, 50);
|
||||||
const topTracks = await sdk.currentUser.topItems("tracks", timeline, 50);
|
const artists = page.artists;
|
||||||
topTracksByTimeline[timeline] = topTracks.items;
|
followed.push(...artists.items);
|
||||||
}
|
if (!artists.next || artists.items.length === 0) break;
|
||||||
return topTracksByTimeline;
|
const lastArtist = artists.items.at(-1);
|
||||||
}
|
after = lastArtist?.id;
|
||||||
|
}
|
||||||
|
return followed;
|
||||||
|
}
|
||||||
|
|
||||||
@DBOS.step()
|
@DBOS.step()
|
||||||
private async fetchFollowedArtists(userId: string): Promise<Artist[]> {
|
private async fetchSavedAlbums(userId: string): Promise<SavedAlbum[]> {
|
||||||
const sdk = await this.createSdk(userId);
|
const sdk = await this.createSdk(userId);
|
||||||
const followed: Artist[] = [];
|
const saved: SavedAlbum[] = [];
|
||||||
let after: string | undefined;
|
let offset = 0;
|
||||||
while (true) {
|
while (true) {
|
||||||
const page = await sdk.currentUser.followedArtists(after, 50);
|
const page = await sdk.currentUser.albums.savedAlbums(50, offset);
|
||||||
const artists = page.artists;
|
saved.push(...page.items);
|
||||||
followed.push(...artists.items);
|
offset += page.items.length;
|
||||||
if (!artists.next || artists.items.length === 0) break;
|
if (!page.next || offset >= page.total) break;
|
||||||
after = artists.items[artists.items.length - 1]!.id;
|
}
|
||||||
}
|
return saved;
|
||||||
return followed;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@DBOS.step()
|
@DBOS.step()
|
||||||
private async fetchSavedAlbums(userId: string): Promise<SavedAlbum[]> {
|
private async fetchSavedTracks(userId: string): Promise<SavedTrack[]> {
|
||||||
const sdk = await this.createSdk(userId);
|
const sdk = await this.createSdk(userId);
|
||||||
const saved: SavedAlbum[] = [];
|
const saved: SavedTrack[] = [];
|
||||||
let offset = 0;
|
let offset = 0;
|
||||||
while (true) {
|
while (true) {
|
||||||
const page = await sdk.currentUser.albums.savedAlbums(50, offset);
|
const page = await sdk.currentUser.tracks.savedTracks(50, offset);
|
||||||
saved.push(...page.items);
|
saved.push(...page.items);
|
||||||
offset += page.items.length;
|
offset += page.items.length;
|
||||||
if (!page.next || offset >= page.total) break;
|
if (!page.next || offset >= page.total) break;
|
||||||
}
|
}
|
||||||
return saved;
|
return saved;
|
||||||
}
|
}
|
||||||
|
|
||||||
@DBOS.step()
|
@DBOS.step()
|
||||||
private async fetchSavedTracks(userId: string): Promise<SavedTrack[]> {
|
private async fetchRecentlyPlayed(userId: string): Promise<PlayHistory[]> {
|
||||||
const sdk = await this.createSdk(userId);
|
const sdk = await this.createSdk(userId);
|
||||||
const saved: SavedTrack[] = [];
|
const recentlyPlayed = await sdk.player.getRecentlyPlayedTracks(50);
|
||||||
let offset = 0;
|
return recentlyPlayed.items;
|
||||||
while (true) {
|
}
|
||||||
const page = await sdk.currentUser.tracks.savedTracks(50, offset);
|
|
||||||
saved.push(...page.items);
|
|
||||||
offset += page.items.length;
|
|
||||||
if (!page.next || offset >= page.total) break;
|
|
||||||
}
|
|
||||||
return saved;
|
|
||||||
}
|
|
||||||
|
|
||||||
@DBOS.step()
|
private async createSdk(userId: string) {
|
||||||
private async fetchRecentlyPlayed(userId: string): Promise<PlayHistory[]> {
|
const accessToken = await auth.api.getAccessToken({
|
||||||
const sdk = await this.createSdk(userId);
|
body: {
|
||||||
const recentlyPlayed = await sdk.player.getRecentlyPlayedTracks(50);
|
userId,
|
||||||
return recentlyPlayed.items;
|
providerId: "spotify",
|
||||||
}
|
},
|
||||||
|
});
|
||||||
private async createSdk(userId: string) {
|
return SpotifyApi.withAccessToken(SPOTIFY_CLIENT_ID, {
|
||||||
const accessToken = await auth.api.getAccessToken({
|
access_token: accessToken.accessToken,
|
||||||
body: {
|
expires_in: accessToken.accessTokenExpiresAt
|
||||||
userId,
|
? Date.now() - Number(accessToken.accessTokenExpiresAt)
|
||||||
providerId: "spotify",
|
: 0,
|
||||||
},
|
expires: accessToken.accessTokenExpiresAt
|
||||||
});
|
? Number(accessToken.accessTokenExpiresAt)
|
||||||
return SpotifyApi.withAccessToken(SPOTIFY_CLIENT_ID, {
|
: 0,
|
||||||
access_token: accessToken.accessToken,
|
refresh_token: "",
|
||||||
expires_in: Date.now() - Number(accessToken.accessTokenExpiresAt!),
|
token_type: "",
|
||||||
expires: Number(accessToken.accessTokenExpiresAt),
|
});
|
||||||
refresh_token: "",
|
}
|
||||||
token_type: "",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const spotifySyncWorkflow = new SpotifySyncWorkflow("spotify-sync");
|
export const spotifySyncWorkflow = new SpotifySyncWorkflow("spotify-sync");
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,30 @@
|
||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
// Environment setup & latest features
|
// Environment setup & latest features
|
||||||
"lib": ["ESNext"],
|
"lib": ["ESNext"],
|
||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
"module": "Preserve",
|
"module": "Preserve",
|
||||||
"moduleDetection": "force",
|
"moduleDetection": "force",
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
|
|
||||||
// Bundler mode
|
// Bundler mode
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"verbatimModuleSyntax": true,
|
"verbatimModuleSyntax": true,
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
|
|
||||||
// Best practices
|
// Best practices
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"noUncheckedIndexedAccess": true,
|
"noUncheckedIndexedAccess": true,
|
||||||
"noImplicitOverride": true,
|
"noImplicitOverride": true,
|
||||||
|
|
||||||
// Some stricter flags (disabled by default)
|
// Some stricter flags (disabled by default)
|
||||||
"noUnusedLocals": false,
|
"noUnusedLocals": false,
|
||||||
"noUnusedParameters": false,
|
"noUnusedParameters": false,
|
||||||
"noPropertyAccessFromIndexSignature": false,
|
"noPropertyAccessFromIndexSignature": false
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
77
bun.lock
77
bun.lock
|
|
@ -52,6 +52,7 @@
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "^1.8.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"shadcn": "^4.3.0",
|
"shadcn": "^4.3.0",
|
||||||
|
|
@ -724,6 +725,8 @@
|
||||||
|
|
||||||
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
|
||||||
|
|
||||||
|
"camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="],
|
||||||
|
|
||||||
"caniuse-lite": ["caniuse-lite@1.0.30001788", "", {}, "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ=="],
|
"caniuse-lite": ["caniuse-lite@1.0.30001788", "", {}, "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ=="],
|
||||||
|
|
||||||
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
|
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
|
||||||
|
|
@ -746,7 +749,7 @@
|
||||||
|
|
||||||
"cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="],
|
"cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="],
|
||||||
|
|
||||||
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
|
"cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
|
||||||
|
|
||||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||||
|
|
||||||
|
|
@ -800,6 +803,8 @@
|
||||||
|
|
||||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
|
|
||||||
|
"decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="],
|
||||||
|
|
||||||
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
|
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
|
||||||
|
|
||||||
"dedent": ["dedent@1.7.2", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA=="],
|
"dedent": ["dedent@1.7.2", "", { "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, "optionalPeers": ["babel-plugin-macros"] }, "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA=="],
|
||||||
|
|
@ -826,6 +831,8 @@
|
||||||
|
|
||||||
"diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
|
"diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
|
||||||
|
|
||||||
|
"dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="],
|
||||||
|
|
||||||
"dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
|
"dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
|
||||||
|
|
||||||
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
|
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
|
||||||
|
|
@ -858,7 +865,7 @@
|
||||||
|
|
||||||
"embla-carousel-reactive-utils": ["embla-carousel-reactive-utils@8.6.0", "", { "peerDependencies": { "embla-carousel": "8.6.0" } }, "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A=="],
|
"embla-carousel-reactive-utils": ["embla-carousel-reactive-utils@8.6.0", "", { "peerDependencies": { "embla-carousel": "8.6.0" } }, "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A=="],
|
||||||
|
|
||||||
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
|
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||||
|
|
||||||
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
||||||
|
|
||||||
|
|
@ -940,6 +947,8 @@
|
||||||
|
|
||||||
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
|
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
|
||||||
|
|
||||||
|
"find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
|
||||||
|
|
||||||
"formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
|
"formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
|
||||||
|
|
||||||
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
|
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
|
||||||
|
|
@ -1120,6 +1129,8 @@
|
||||||
|
|
||||||
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
||||||
|
|
||||||
|
"locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||||
|
|
||||||
"log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="],
|
"log-symbols": ["log-symbols@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "is-unicode-supported": "^1.3.0" } }, "sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw=="],
|
||||||
|
|
||||||
"loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
|
"loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
|
||||||
|
|
@ -1206,6 +1217,12 @@
|
||||||
|
|
||||||
"outvariant": ["outvariant@1.4.3", "", {}, "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA=="],
|
"outvariant": ["outvariant@1.4.3", "", {}, "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA=="],
|
||||||
|
|
||||||
|
"p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
|
||||||
|
|
||||||
|
"p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="],
|
||||||
|
|
||||||
|
"p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
|
||||||
|
|
||||||
"packet-reader": ["packet-reader@1.0.0", "", {}, "sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ=="],
|
"packet-reader": ["packet-reader@1.0.0", "", {}, "sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ=="],
|
||||||
|
|
||||||
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
|
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
|
||||||
|
|
@ -1224,6 +1241,8 @@
|
||||||
|
|
||||||
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
|
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
|
||||||
|
|
||||||
|
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||||
|
|
||||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||||
|
|
||||||
"path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
|
"path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
|
||||||
|
|
@ -1256,6 +1275,8 @@
|
||||||
|
|
||||||
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
|
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
|
||||||
|
|
||||||
|
"pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="],
|
||||||
|
|
||||||
"postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="],
|
"postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="],
|
||||||
|
|
||||||
"postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="],
|
"postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="],
|
||||||
|
|
@ -1284,6 +1305,8 @@
|
||||||
|
|
||||||
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||||
|
|
||||||
|
"qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="],
|
||||||
|
|
||||||
"qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="],
|
"qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="],
|
||||||
|
|
||||||
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||||
|
|
@ -1318,6 +1341,8 @@
|
||||||
|
|
||||||
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
|
||||||
|
|
||||||
|
"require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="],
|
||||||
|
|
||||||
"reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
|
"reselect": ["reselect@5.1.1", "", {}, "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="],
|
||||||
|
|
||||||
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
|
||||||
|
|
@ -1362,6 +1387,8 @@
|
||||||
|
|
||||||
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
|
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
|
||||||
|
|
||||||
|
"set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="],
|
||||||
|
|
||||||
"set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="],
|
"set-cookie-parser": ["set-cookie-parser@3.1.0", "", {}, "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw=="],
|
||||||
|
|
||||||
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
||||||
|
|
@ -1410,7 +1437,7 @@
|
||||||
|
|
||||||
"strict-event-emitter": ["strict-event-emitter@0.5.1", "", {}, "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ=="],
|
"strict-event-emitter": ["strict-event-emitter@0.5.1", "", {}, "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ=="],
|
||||||
|
|
||||||
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
|
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||||
|
|
||||||
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
|
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
|
||||||
|
|
||||||
|
|
@ -1542,9 +1569,11 @@
|
||||||
|
|
||||||
"which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
|
"which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
|
||||||
|
|
||||||
|
"which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="],
|
||||||
|
|
||||||
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
|
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
|
||||||
|
|
||||||
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
"wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
|
||||||
|
|
||||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||||
|
|
||||||
|
|
@ -1560,15 +1589,15 @@
|
||||||
|
|
||||||
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
||||||
|
|
||||||
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
"y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="],
|
||||||
|
|
||||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||||
|
|
||||||
"yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="],
|
"yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="],
|
||||||
|
|
||||||
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
|
"yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="],
|
||||||
|
|
||||||
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
|
"yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="],
|
||||||
|
|
||||||
"yocto-spinner": ["yocto-spinner@1.1.0", "", { "dependencies": { "yoctocolors": "^2.1.1" } }, "sha512-/BY0AUXnS7IKO354uLLA2eRcWiqDifEbd6unXCsOxkFDAkhgUL3PH9X2bFoaU0YchnDXsF+iKleeTLJGckbXfA=="],
|
"yocto-spinner": ["yocto-spinner@1.1.0", "", { "dependencies": { "yoctocolors": "^2.1.1" } }, "sha512-/BY0AUXnS7IKO354uLLA2eRcWiqDifEbd6unXCsOxkFDAkhgUL3PH9X2bFoaU0YchnDXsF+iKleeTLJGckbXfA=="],
|
||||||
|
|
||||||
|
|
@ -1626,8 +1655,6 @@
|
||||||
|
|
||||||
"cheerio/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
|
"cheerio/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="],
|
||||||
|
|
||||||
"cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
|
||||||
|
|
||||||
"cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
"cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||||
|
|
||||||
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
"cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||||
|
|
@ -1654,8 +1681,12 @@
|
||||||
|
|
||||||
"msw/type-fest": ["type-fest@5.6.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA=="],
|
"msw/type-fest": ["type-fest@5.6.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA=="],
|
||||||
|
|
||||||
|
"msw/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
|
||||||
|
|
||||||
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
|
"npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="],
|
||||||
|
|
||||||
|
"ora/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
|
||||||
|
|
||||||
"parse5-htmlparser2-tree-adapter/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
"parse5-htmlparser2-tree-adapter/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
||||||
|
|
||||||
"parse5-parser-stream/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
"parse5-parser-stream/parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
|
||||||
|
|
@ -1678,6 +1709,8 @@
|
||||||
|
|
||||||
"shadcn/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
"shadcn/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||||
|
|
||||||
|
"string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||||
|
|
||||||
"strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
"strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
|
||||||
|
|
||||||
"strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
|
"strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
|
||||||
|
|
@ -1692,12 +1725,8 @@
|
||||||
|
|
||||||
"wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
"wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||||
|
|
||||||
"wrap-ansi/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
|
||||||
|
|
||||||
"wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
"wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||||
|
|
||||||
"yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
|
||||||
|
|
||||||
"@dotenvx/dotenvx/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
|
"@dotenvx/dotenvx/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
|
||||||
|
|
||||||
"@dotenvx/dotenvx/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
|
"@dotenvx/dotenvx/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="],
|
||||||
|
|
@ -1716,12 +1745,18 @@
|
||||||
|
|
||||||
"bun-types/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
|
"bun-types/@types/node/undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
|
||||||
|
|
||||||
"cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
|
||||||
|
|
||||||
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||||
|
|
||||||
"isomorphic-unfetch/node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
"isomorphic-unfetch/node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
||||||
|
|
||||||
|
"msw/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
|
||||||
|
|
||||||
|
"msw/yargs/y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
||||||
|
|
||||||
|
"msw/yargs/yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
|
||||||
|
|
||||||
|
"ora/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="],
|
"tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="],
|
||||||
|
|
||||||
"tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="],
|
"tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="],
|
||||||
|
|
@ -1778,16 +1813,14 @@
|
||||||
|
|
||||||
"vitest/vite/esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="],
|
"vitest/vite/esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="],
|
||||||
|
|
||||||
"wrap-ansi/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
|
||||||
|
|
||||||
"yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
|
||||||
|
|
||||||
"yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
|
||||||
|
|
||||||
"isomorphic-unfetch/node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
|
"isomorphic-unfetch/node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
|
||||||
|
|
||||||
"isomorphic-unfetch/node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
"isomorphic-unfetch/node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
|
||||||
|
|
||||||
|
"msw/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||||
|
|
||||||
|
"msw/yargs/cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||||
|
|
||||||
"vite-node/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="],
|
"vite-node/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="],
|
||||||
|
|
||||||
"vite-node/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="],
|
"vite-node/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="],
|
||||||
|
|
@ -1891,5 +1924,7 @@
|
||||||
"vitest/vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="],
|
"vitest/vite/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="],
|
||||||
|
|
||||||
"vitest/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="],
|
"vitest/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="],
|
||||||
|
|
||||||
|
"msw/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"lucide-react": "^1.8.0",
|
"lucide-react": "^1.8.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
"shadcn": "^4.3.0",
|
"shadcn": "^4.3.0",
|
||||||
|
|
|
||||||
98
web/src/components/party-qr.tsx
Normal file
98
web/src/components/party-qr.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import QRCode from "qrcode";
|
||||||
|
import { Button } from "#/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "#/components/ui/dialog";
|
||||||
|
import { Item, ItemActions, ItemDescription } from "#/components/ui/item";
|
||||||
|
import { useUser } from "#/hooks/user";
|
||||||
|
|
||||||
|
const QR_SIZE = 220;
|
||||||
|
|
||||||
|
export function PartyQr() {
|
||||||
|
const { user } = useUser();
|
||||||
|
const [dataUrl, setDataUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const joinUrl = useMemo(() => {
|
||||||
|
if (typeof window === "undefined" || !user?.id) return null;
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.delete("redirect");
|
||||||
|
url.searchParams.set("join", user.id);
|
||||||
|
return url.toString();
|
||||||
|
}, [user?.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
if (!joinUrl) {
|
||||||
|
setDataUrl(null);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
QRCode.toDataURL(joinUrl, { width: QR_SIZE, margin: 1 })
|
||||||
|
.then((url) => {
|
||||||
|
if (isMounted) {
|
||||||
|
setDataUrl(url);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (isMounted) {
|
||||||
|
setDataUrl(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, [joinUrl]);
|
||||||
|
|
||||||
|
if (!user?.id) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Item className="px-0 justify-between">
|
||||||
|
<ItemDescription>Invite someone to your party</ItemDescription>
|
||||||
|
<ItemActions>
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger render={<Button size="sm" variant="outline" />}>Show QR</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Party invite</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Scan to join your party on another device.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
{dataUrl ? (
|
||||||
|
<img
|
||||||
|
alt="Party invite QR code"
|
||||||
|
src={dataUrl}
|
||||||
|
width={QR_SIZE}
|
||||||
|
height={QR_SIZE}
|
||||||
|
className="rounded-xl border border-border bg-white p-2"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex size-[220px] items-center justify-center rounded-xl border border-dashed border-border text-muted-foreground">
|
||||||
|
Generating QR...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{joinUrl ? (
|
||||||
|
<p className="max-w-[280px] break-all text-xs text-muted-foreground">
|
||||||
|
{joinUrl}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose render={<Button variant="outline" />}>Close</DialogClose>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</ItemActions>
|
||||||
|
</Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
web/src/lib/party-join.ts
Normal file
66
web/src/lib/party-join.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
const pendingPartyKey = "pendingPartyJoin";
|
||||||
|
|
||||||
|
export function readPendingPartyJoin(): string | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
return window.localStorage.getItem(pendingPartyKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writePendingPartyJoin(partyHostId: string) {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
window.localStorage.setItem(pendingPartyKey, partyHostId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearPendingPartyJoin() {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
window.localStorage.removeItem(pendingPartyKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getJoinIdFromLocation(): string | null {
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const join = params.get("join");
|
||||||
|
if (join) return join;
|
||||||
|
const redirect = params.get("redirect");
|
||||||
|
if (!redirect) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const redirectUrl = new URL(redirect, window.location.origin);
|
||||||
|
return redirectUrl.searchParams.get("join");
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearJoinIdFromLocation() {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
const hasJoin = url.searchParams.has("join");
|
||||||
|
const redirect = url.searchParams.get("redirect");
|
||||||
|
let updatedRedirect: string | null = null;
|
||||||
|
|
||||||
|
if (redirect) {
|
||||||
|
try {
|
||||||
|
const redirectUrl = new URL(redirect, window.location.origin);
|
||||||
|
if (redirectUrl.searchParams.has("join")) {
|
||||||
|
redirectUrl.searchParams.delete("join");
|
||||||
|
updatedRedirect =
|
||||||
|
redirectUrl.pathname + redirectUrl.search + redirectUrl.hash;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
updatedRedirect = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasJoin && !updatedRedirect) return;
|
||||||
|
|
||||||
|
if (hasJoin) {
|
||||||
|
url.searchParams.delete("join");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updatedRedirect) {
|
||||||
|
url.searchParams.set("redirect", updatedRedirect);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextUrl = url.pathname + url.search + url.hash;
|
||||||
|
window.history.replaceState({}, "", nextUrl);
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,17 @@ import TanStackQueryDevtools from "../integrations/tanstack-query/devtools";
|
||||||
import appCss from "../styles.css?url";
|
import appCss from "../styles.css?url";
|
||||||
import type { AuthSession } from "#/lib/auth.serverfn";
|
import type { AuthSession } from "#/lib/auth.serverfn";
|
||||||
import { fetchSession, sessionQueryKey } from "#/lib/auth-client";
|
import { fetchSession, sessionQueryKey } from "#/lib/auth-client";
|
||||||
|
import type * as React from "react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { client } from "#/lib/eden";
|
||||||
|
import {
|
||||||
|
clearPendingPartyJoin,
|
||||||
|
clearJoinIdFromLocation,
|
||||||
|
getJoinIdFromLocation,
|
||||||
|
readPendingPartyJoin,
|
||||||
|
writePendingPartyJoin,
|
||||||
|
} from "#/lib/party-join";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
interface MyRouterContext {
|
interface MyRouterContext {
|
||||||
queryClient: QueryClient;
|
queryClient: QueryClient;
|
||||||
|
|
@ -68,6 +79,52 @@ export const Route = createRootRouteWithContext<MyRouterContext>()({
|
||||||
});
|
});
|
||||||
|
|
||||||
function RootDocument({ children }: { children: React.ReactNode }) {
|
function RootDocument({ children }: { children: React.ReactNode }) {
|
||||||
|
const { user } = Route.useRouteContext();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const joinId = getJoinIdFromLocation();
|
||||||
|
if (!joinId) return;
|
||||||
|
const storedId = readPendingPartyJoin();
|
||||||
|
if (storedId !== joinId) {
|
||||||
|
writePendingPartyJoin(joinId);
|
||||||
|
}
|
||||||
|
clearJoinIdFromLocation();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!user) return;
|
||||||
|
const joinId = readPendingPartyJoin();
|
||||||
|
if (!joinId) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
const attemptJoin = async () => {
|
||||||
|
try {
|
||||||
|
const result = await client.api.party.join.post({
|
||||||
|
targetUserId: joinId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result?.error) {
|
||||||
|
toast.error(result.error);
|
||||||
|
clearPendingPartyJoin();
|
||||||
|
} else {
|
||||||
|
toast.success("Joined party.");
|
||||||
|
clearPendingPartyJoin();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!cancelled) {
|
||||||
|
toast.error("Failed to join party.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
attemptJoin();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<head>
|
<head>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { SyncButton } from "#/components/sync-button";
|
import { SyncButton } from "#/components/sync-button";
|
||||||
import { MainContent } from "#/components/ui/main-content";
|
import { MainContent } from "#/components/ui/main-content";
|
||||||
import { UserInfo } from "#/components/user-info";
|
import { UserInfo } from "#/components/user-info";
|
||||||
|
import { PartyQr } from "#/components/party-qr";
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
|
||||||
export const Route = createFileRoute("/")({ component: App });
|
export const Route = createFileRoute("/")({ component: App });
|
||||||
|
|
@ -10,6 +11,7 @@ function App() {
|
||||||
<MainContent>
|
<MainContent>
|
||||||
<UserInfo />
|
<UserInfo />
|
||||||
<SyncButton />
|
<SyncButton />
|
||||||
|
<PartyQr />
|
||||||
</MainContent>
|
</MainContent>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue