@ -0,0 +1,56 @@
|
||||
# This workflow uses actions that are not certified by GitHub.
|
||||
# They are provided by a third-party and are governed by
|
||||
# separate terms of service, privacy policy, and support
|
||||
# documentation.
|
||||
|
||||
# GitHub recommends pinning actions to a commit SHA.
|
||||
# To get a newer version, you will need to update the SHA.
|
||||
# You can also reference a tag or branch, but the action may change without warning.
|
||||
|
||||
name: Publish Docker image
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
push_to_registry:
|
||||
name: Push Docker image to Docker Hub
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
attestations: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
|
||||
with:
|
||||
images: gabehf/koito
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: push
|
||||
uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
- name: Generate artifact attestation
|
||||
uses: actions/attest-build-provenance@v2
|
||||
with:
|
||||
subject-name: index.docker.io/gabehf/koito
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
push-to-registry: true
|
||||
@ -0,0 +1,3 @@
|
||||
{
|
||||
"makefile.configureOnOpen": false
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
# Koito
|
||||
## Dependencies
|
||||
### libvips
|
||||
```
|
||||
sudo apt install libvips
|
||||
```
|
||||
## Tools
|
||||
- goose
|
||||
- sqlc
|
||||
## Start dev env
|
||||
```
|
||||
make postgres.run
|
||||
make api.debug
|
||||
```
|
||||
@ -0,0 +1,43 @@
|
||||
FROM node AS frontend
|
||||
|
||||
WORKDIR /client
|
||||
COPY ./client/package.json ./client/yarn.lock ./
|
||||
RUN yarn install
|
||||
COPY ./client .
|
||||
ENV BUILD_TARGET=docker
|
||||
RUN yarn run build
|
||||
|
||||
|
||||
FROM golang:1.23 AS backend
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y libvips-dev pkg-config && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN CGO_ENABLED=1 GOOS=linux go build -o app ./cmd/api
|
||||
|
||||
|
||||
FROM debian:bookworm-slim AS final
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y libvips42 && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=backend /app/app ./app
|
||||
COPY --from=frontend /client/build ./client/build
|
||||
COPY ./client/public ./client/public
|
||||
COPY ./assets ./assets
|
||||
COPY ./db ./db
|
||||
|
||||
EXPOSE 4110
|
||||
|
||||
ENTRYPOINT ["./app"]
|
||||
@ -0,0 +1,24 @@
|
||||
# Must-haves
|
||||
- scrobble with listenbrainz api
|
||||
- import from maloja
|
||||
- import from spotify
|
||||
- natively host on subdirectory
|
||||
- good mobile ui
|
||||
- replace artist/album/track art from ui
|
||||
- fetch artist/album/track art from lastfm and/or spotify
|
||||
- edit artist/album/track name in ui (auto-merge colliding names)
|
||||
- built with being exposed to the internet in mind
|
||||
- track artist aliases
|
||||
- hold a cache of musicbrainz responses, with a button to clear it out
|
||||
# Want
|
||||
- use musibrainz ids from scrobble to automatically merge plays
|
||||
- use musicbrainz ids from scrobble to automatically add AKA fields (美波 aka Minami) and sort name
|
||||
- export playlist m3u8 files based on charts
|
||||
- track device/player listened from
|
||||
- webhooks on certain events (every scrobble, listening milestones, etc.)
|
||||
- Time of day, day of week, etc. graphs
|
||||
- "pause" mode that temporarily disables recieving scrobbles until turned back on
|
||||
# Stretch
|
||||
- "Listening Digest" wrapped-esque digestable recap of the last week/month/year
|
||||
# Could explore
|
||||
- Federation/ActivityPub
|
||||
@ -0,0 +1,48 @@
|
||||
.PHONY: all test clean client
|
||||
|
||||
db.up:
|
||||
GOOSE_MIGRATION_DIR=db/migrations GOOSE_DRIVER=postgres GOOSE_DBSTRING=postgres://postgres:secret@localhost:5432 goose up
|
||||
|
||||
db.down:
|
||||
GOOSE_MIGRATION_DIR=db/migrations GOOSE_DRIVER=postgres GOOSE_DBSTRING=postgres://postgres:secret@localhost:5432 goose down
|
||||
|
||||
db.reset:
|
||||
GOOSE_MIGRATION_DIR=db/migrations GOOSE_DRIVER=postgres GOOSE_DBSTRING=postgres://postgres:secret@localhost:5432 goose down-to 0
|
||||
|
||||
db.schemadump:
|
||||
docker run --rm --network=host --env PGPASSWORD=secret -v "./db:/tmp/dump" \
|
||||
postgres pg_dump \
|
||||
--schema-only \
|
||||
--host=192.168.0.153 \
|
||||
--port=5432 \
|
||||
--username=postgres \
|
||||
-v --dbname="koitodb" -f "/tmp/dump/schema.sql"
|
||||
|
||||
postgres.run:
|
||||
docker run --name koito-db -p 5432:5432 -e POSTGRES_PASSWORD=secret -d postgres
|
||||
|
||||
postgres.start:
|
||||
docker start koito-db
|
||||
|
||||
postgres.stop:
|
||||
docker stop koito-db
|
||||
|
||||
postgres.rm:
|
||||
docker rm bamsort-db
|
||||
|
||||
api.debug:
|
||||
KOITO_ALLOWED_HOSTS=* KOITO_LOG_LEVEL=debug KOITO_CONFIG_DIR=test_config_dir KOITO_DATABASE_URL=postgres://postgres:secret@192.168.0.153:5432/koitodb?sslmode=disable go run cmd/api/main.go
|
||||
|
||||
api.test:
|
||||
go test ./... -timeout 60s
|
||||
|
||||
client.dev:
|
||||
cd client && yarn run dev
|
||||
|
||||
docs.dev:
|
||||
cd docs && yarn dev
|
||||
|
||||
client.build:
|
||||
cd client && yarn run build
|
||||
|
||||
test: api.test
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
@ -0,0 +1,4 @@
|
||||
.react-router
|
||||
build
|
||||
node_modules
|
||||
README.md
|
||||
@ -0,0 +1,6 @@
|
||||
.DS_Store
|
||||
/node_modules/
|
||||
|
||||
# React Router
|
||||
/.react-router/
|
||||
/build/
|
||||
@ -0,0 +1,22 @@
|
||||
FROM node:20-alpine AS development-dependencies-env
|
||||
COPY . /app
|
||||
WORKDIR /app
|
||||
RUN npm ci
|
||||
|
||||
FROM node:20-alpine AS production-dependencies-env
|
||||
COPY ./package.json package-lock.json /app/
|
||||
WORKDIR /app
|
||||
RUN npm ci --omit=dev
|
||||
|
||||
FROM node:20-alpine AS build-env
|
||||
COPY . /app/
|
||||
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
|
||||
WORKDIR /app
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine
|
||||
COPY ./package.json package-lock.json /app/
|
||||
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
|
||||
COPY --from=build-env /app/build /app/build
|
||||
WORKDIR /app
|
||||
CMD ["npm", "run", "start"]
|
||||
@ -0,0 +1,87 @@
|
||||
# Welcome to React Router!
|
||||
|
||||
A modern, production-ready template for building full-stack React applications using React Router.
|
||||
|
||||
[](https://stackblitz.com/github/remix-run/react-router-templates/tree/main/default)
|
||||
|
||||
## Features
|
||||
|
||||
- 🚀 Server-side rendering
|
||||
- ⚡️ Hot Module Replacement (HMR)
|
||||
- 📦 Asset bundling and optimization
|
||||
- 🔄 Data loading and mutations
|
||||
- 🔒 TypeScript by default
|
||||
- 🎉 TailwindCSS for styling
|
||||
- 📖 [React Router docs](https://reactrouter.com/)
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Installation
|
||||
|
||||
Install the dependencies:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Development
|
||||
|
||||
Start the development server with HMR:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Your application will be available at `http://localhost:5173`.
|
||||
|
||||
## Building for Production
|
||||
|
||||
Create a production build:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
### Docker Deployment
|
||||
|
||||
To build and run using Docker:
|
||||
|
||||
```bash
|
||||
docker build -t my-app .
|
||||
|
||||
# Run the container
|
||||
docker run -p 3000:3000 my-app
|
||||
```
|
||||
|
||||
The containerized application can be deployed to any platform that supports Docker, including:
|
||||
|
||||
- AWS ECS
|
||||
- Google Cloud Run
|
||||
- Azure Container Apps
|
||||
- Digital Ocean App Platform
|
||||
- Fly.io
|
||||
- Railway
|
||||
|
||||
### DIY Deployment
|
||||
|
||||
If you're familiar with deploying Node applications, the built-in app server is production-ready.
|
||||
|
||||
Make sure to deploy the output of `npm run build`
|
||||
|
||||
```
|
||||
├── package.json
|
||||
├── package-lock.json (or pnpm-lock.yaml, or bun.lockb)
|
||||
├── build/
|
||||
│ ├── client/ # Static assets
|
||||
│ └── server/ # Server-side code
|
||||
```
|
||||
|
||||
## Styling
|
||||
|
||||
This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever CSS framework you prefer.
|
||||
|
||||
---
|
||||
|
||||
Built with ❤️ using React Router.
|
||||
@ -0,0 +1,278 @@
|
||||
interface getItemsArgs {
|
||||
limit: number,
|
||||
period: string,
|
||||
page: number,
|
||||
artist_id?: number,
|
||||
album_id?: number,
|
||||
track_id?: number
|
||||
}
|
||||
interface getActivityArgs {
|
||||
step: string
|
||||
range: number
|
||||
month: number
|
||||
year: number
|
||||
artist_id: number
|
||||
album_id: number
|
||||
track_id: number
|
||||
}
|
||||
|
||||
function getLastListens(args: getItemsArgs): Promise<PaginatedResponse<Listen>> {
|
||||
return fetch(`/apis/web/v1/listens?period=${args.period}&limit=${args.limit}&artist_id=${args.artist_id}&album_id=${args.album_id}&track_id=${args.track_id}&page=${args.page}`).then(r => r.json() as Promise<PaginatedResponse<Listen>>)
|
||||
}
|
||||
|
||||
function getTopTracks(args: getItemsArgs): Promise<PaginatedResponse<Track>> {
|
||||
if (args.artist_id) {
|
||||
return fetch(`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&artist_id=${args.artist_id}&page=${args.page}`).then(r => r.json() as Promise<PaginatedResponse<Track>>)
|
||||
} else if (args.album_id) {
|
||||
return fetch(`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&album_id=${args.album_id}&page=${args.page}`).then(r => r.json() as Promise<PaginatedResponse<Track>>)
|
||||
} else {
|
||||
return fetch(`/apis/web/v1/top-tracks?period=${args.period}&limit=${args.limit}&page=${args.page}`).then(r => r.json() as Promise<PaginatedResponse<Track>>)
|
||||
}
|
||||
}
|
||||
|
||||
function getTopAlbums(args: getItemsArgs): Promise<PaginatedResponse<Album>> {
|
||||
const baseUri = `/apis/web/v1/top-albums?period=${args.period}&limit=${args.limit}&page=${args.page}`
|
||||
if (args.artist_id) {
|
||||
return fetch(baseUri+`&artist_id=${args.artist_id}`).then(r => r.json() as Promise<PaginatedResponse<Album>>)
|
||||
} else {
|
||||
return fetch(baseUri).then(r => r.json() as Promise<PaginatedResponse<Album>>)
|
||||
}
|
||||
}
|
||||
|
||||
function getTopArtists(args: getItemsArgs): Promise<PaginatedResponse<Artist>> {
|
||||
const baseUri = `/apis/web/v1/top-artists?period=${args.period}&limit=${args.limit}&page=${args.page}`
|
||||
return fetch(baseUri).then(r => r.json() as Promise<PaginatedResponse<Artist>>)
|
||||
}
|
||||
|
||||
function getActivity(args: getActivityArgs): Promise<ListenActivityItem[]> {
|
||||
return fetch(`/apis/web/v1/listen-activity?step=${args.step}&range=${args.range}&month=${args.month}&year=${args.year}&album_id=${args.album_id}&artist_id=${args.artist_id}&track_id=${args.track_id}`).then(r => r.json() as Promise<ListenActivityItem[]>)
|
||||
}
|
||||
|
||||
function getStats(period: string): Promise<Stats> {
|
||||
return fetch(`/apis/web/v1/stats?period=${period}`).then(r => r.json() as Promise<Stats>)
|
||||
}
|
||||
|
||||
function search(q: string): Promise<SearchResponse> {
|
||||
return fetch(`/apis/web/v1/search?q=${q}`).then(r => r.json() as Promise<SearchResponse>)
|
||||
}
|
||||
|
||||
function imageUrl(id: string, size: string) {
|
||||
if (!id) {
|
||||
id = 'default'
|
||||
}
|
||||
return `/images/${size}/${id}`
|
||||
}
|
||||
function replaceImage(form: FormData): Promise<Response> {
|
||||
return fetch(`/apis/web/v1/replace-image`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
})
|
||||
}
|
||||
|
||||
function mergeTracks(from: number, to: number): Promise<Response> {
|
||||
return fetch(`/apis/web/v1/merge/tracks?from_id=${from}&to_id=${to}`, {
|
||||
method: "POST",
|
||||
})
|
||||
}
|
||||
function mergeAlbums(from: number, to: number): Promise<Response> {
|
||||
return fetch(`/apis/web/v1/merge/albums?from_id=${from}&to_id=${to}`, {
|
||||
method: "POST",
|
||||
})
|
||||
}
|
||||
function mergeArtists(from: number, to: number): Promise<Response> {
|
||||
return fetch(`/apis/web/v1/merge/artists?from_id=${from}&to_id=${to}`, {
|
||||
method: "POST",
|
||||
})
|
||||
}
|
||||
function login(username: string, password: string, remember: boolean): Promise<Response> {
|
||||
return fetch(`/apis/web/v1/login?username=${username}&password=${password}&remember_me=${remember}`, {
|
||||
method: "POST",
|
||||
})
|
||||
}
|
||||
function logout(): Promise<Response> {
|
||||
return fetch(`/apis/web/v1/logout`, {
|
||||
method: "POST",
|
||||
})
|
||||
}
|
||||
|
||||
function getApiKeys(): Promise<ApiKey[]> {
|
||||
return fetch(`/apis/web/v1/user/apikeys`).then((r) => r.json() as Promise<ApiKey[]>)
|
||||
}
|
||||
const createApiKey = async (label: string): Promise<ApiKey> => {
|
||||
const r = await fetch(`/apis/web/v1/user/apikeys?label=${label}`, {
|
||||
method: "POST"
|
||||
});
|
||||
if (!r.ok) {
|
||||
let errorMessage = `error: ${r.status}`;
|
||||
try {
|
||||
const errorData: ApiError = await r.json();
|
||||
if (errorData && typeof errorData.error === 'string') {
|
||||
errorMessage = errorData.error;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("unexpected api error:", e);
|
||||
}
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
const data: ApiKey = await r.json();
|
||||
return data;
|
||||
};
|
||||
function deleteApiKey(id: number): Promise<Response> {
|
||||
return fetch(`/apis/web/v1/user/apikeys?id=${id}`, {
|
||||
method: "DELETE"
|
||||
})
|
||||
}
|
||||
function updateApiKeyLabel(id: number, label: string): Promise<Response> {
|
||||
return fetch(`/apis/web/v1/user/apikeys?id=${id}&label=${label}`, {
|
||||
method: "PATCH"
|
||||
})
|
||||
}
|
||||
|
||||
function deleteItem(itemType: string, id: number): Promise<Response> {
|
||||
return fetch(`/apis/web/v1/${itemType}?id=${id}`, {
|
||||
method: "DELETE"
|
||||
})
|
||||
}
|
||||
function updateUser(username: string, password: string) {
|
||||
return fetch(`/apis/web/v1/user?username=${username}&password=${password}`, {
|
||||
method: "PATCH"
|
||||
})
|
||||
}
|
||||
function getAliases(type: string, id: number): Promise<Alias[]> {
|
||||
return fetch(`/apis/web/v1/aliases?${type}_id=${id}`).then(r => r.json() as Promise<Alias[]>)
|
||||
}
|
||||
function createAlias(type: string, id: number, alias: string): Promise<Response> {
|
||||
return fetch(`/apis/web/v1/aliases?${type}_id=${id}&alias=${alias}`, {
|
||||
method: 'POST'
|
||||
})
|
||||
}
|
||||
function deleteAlias(type: string, id: number, alias: string): Promise<Response> {
|
||||
return fetch(`/apis/web/v1/aliases?${type}_id=${id}&alias=${alias}`, {
|
||||
method: "DELETE"
|
||||
})
|
||||
}
|
||||
function setPrimaryAlias(type: string, id: number, alias: string): Promise<Response> {
|
||||
return fetch(`/apis/web/v1/aliases/primary?${type}_id=${id}&alias=${alias}`, {
|
||||
method: "POST"
|
||||
})
|
||||
}
|
||||
|
||||
export {
|
||||
getLastListens,
|
||||
getTopTracks,
|
||||
getTopAlbums,
|
||||
getTopArtists,
|
||||
getActivity,
|
||||
getStats,
|
||||
search,
|
||||
replaceImage,
|
||||
mergeTracks,
|
||||
mergeAlbums,
|
||||
mergeArtists,
|
||||
imageUrl,
|
||||
login,
|
||||
logout,
|
||||
deleteItem,
|
||||
updateUser,
|
||||
getAliases,
|
||||
createAlias,
|
||||
deleteAlias,
|
||||
setPrimaryAlias,
|
||||
getApiKeys,
|
||||
createApiKey,
|
||||
deleteApiKey,
|
||||
updateApiKeyLabel,
|
||||
}
|
||||
type Track = {
|
||||
id: number
|
||||
title: string
|
||||
artists: SimpleArtists[]
|
||||
listen_count: number
|
||||
image: string
|
||||
album_id: number
|
||||
musicbrainz_id: string
|
||||
}
|
||||
type Artist = {
|
||||
id: number
|
||||
name: string
|
||||
image: string,
|
||||
aliases: string[]
|
||||
listen_count: number
|
||||
musicbrainz_id: string
|
||||
}
|
||||
type Album = {
|
||||
id: number,
|
||||
title: string
|
||||
image: string
|
||||
listen_count: number
|
||||
is_various_artists: boolean
|
||||
artists: SimpleArtists[]
|
||||
musicbrainz_id: string
|
||||
}
|
||||
type Alias = {
|
||||
id: number
|
||||
alias: string
|
||||
source: string
|
||||
is_primary: boolean
|
||||
}
|
||||
type Listen = {
|
||||
time: string,
|
||||
track: Track,
|
||||
}
|
||||
type PaginatedResponse<T> = {
|
||||
items: T[],
|
||||
total_record_count: number,
|
||||
has_next_page: boolean,
|
||||
current_page: number,
|
||||
items_per_page: number,
|
||||
}
|
||||
type ListenActivityItem = {
|
||||
start_time: Date,
|
||||
listens: number
|
||||
}
|
||||
type SimpleArtists = {
|
||||
name: string
|
||||
id: number
|
||||
}
|
||||
type Stats = {
|
||||
listen_count: number
|
||||
track_count: number
|
||||
album_count: number
|
||||
artist_count: number
|
||||
hours_listened: number
|
||||
}
|
||||
type SearchResponse = {
|
||||
albums: Album[]
|
||||
artists: Artist[]
|
||||
tracks: Track[]
|
||||
}
|
||||
type User = {
|
||||
id: number
|
||||
username: string
|
||||
role: 'user' | 'admin'
|
||||
}
|
||||
type ApiKey = {
|
||||
id: number
|
||||
key: string
|
||||
label: string
|
||||
created_at: Date
|
||||
}
|
||||
type ApiError = {
|
||||
error: string
|
||||
}
|
||||
|
||||
export type {
|
||||
getItemsArgs,
|
||||
getActivityArgs,
|
||||
Track,
|
||||
Artist,
|
||||
Album,
|
||||
Listen,
|
||||
SearchResponse,
|
||||
PaginatedResponse,
|
||||
ListenActivityItem,
|
||||
User,
|
||||
Alias,
|
||||
ApiKey,
|
||||
ApiError
|
||||
}
|
||||
@ -0,0 +1,181 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Jost:ital,wght@0,100..900;1,100..900&family=League+Spartan:wght@100..900&display=swap');
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--font-sans: "Jost", "Inter", ui-sans-serif, system-ui, sans-serif,
|
||||
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
--animate-fade-in-scale: fade-in-scale 0.1s ease forwards;
|
||||
--animate-fade-out-scale: fade-out-scale 0.1s ease forwards;
|
||||
|
||||
@keyframes fade-in-scale {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-out-scale {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
|
||||
--animate-fade-in: fade-in 0.1s ease forwards;
|
||||
--animate-fade-out: fade-out 0.1s ease forwards;
|
||||
|
||||
@keyframes fade-in {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-out {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
:root {
|
||||
--header-xl: 78px;
|
||||
--header-lg: 28px;
|
||||
--header-md: 22px;
|
||||
--header-sm: 16px;
|
||||
--header-xl-weight: 600;
|
||||
--header-weight: 600;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-fg);
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
select option {
|
||||
margin: 40px;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-fg);
|
||||
text-shadow: 0 1px 0 rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
select {
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
/* a {
|
||||
color: var(--color-fg);
|
||||
}
|
||||
a:hover {
|
||||
color: var(--color-link-hover);
|
||||
} */
|
||||
|
||||
h1 {
|
||||
font-family: "League Spartan";
|
||||
font-weight: var(--header-weight);
|
||||
font-size: var(--header-xl);
|
||||
}
|
||||
h2 {
|
||||
font-family: "League Spartan";
|
||||
font-weight: var(--header-weight);
|
||||
font-size: var(--header-md);
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
h3 {
|
||||
font-family: "League Spartan";
|
||||
font-size: var(--header-sm);
|
||||
font-weight: var(--header-weight);
|
||||
}
|
||||
h4 {
|
||||
font-size: var(--header-md);
|
||||
}
|
||||
.header-font {
|
||||
font-family: "League Spartan";
|
||||
}
|
||||
|
||||
.icon-hover-fill:hover > svg > path {
|
||||
fill: var(--color-fg-secondary);
|
||||
}
|
||||
.icon-hover-stroke:hover > svg > path {
|
||||
stroke: var(--color-fg-secondary);
|
||||
}
|
||||
|
||||
.link-underline:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
input[type="text"] {
|
||||
border: 1px solid var(--color-bg);
|
||||
}
|
||||
input[type="text"]:focus {
|
||||
outline: none;
|
||||
border: 1px solid var(--color-fg-tertiary);
|
||||
}
|
||||
input[type="password"] {
|
||||
border: 1px solid var(--color-bg);
|
||||
}
|
||||
input[type="password"]:focus {
|
||||
outline: none;
|
||||
border: 1px solid var(--color-fg-tertiary);
|
||||
}
|
||||
input[type="checkbox"]:focus {
|
||||
outline: none;
|
||||
border: 1px solid var(--color-fg-tertiary);
|
||||
}
|
||||
|
||||
button:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
button:disabled:hover,
|
||||
button[disabled]:hover {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
button.large-button {
|
||||
background: var(--color-bg);
|
||||
}
|
||||
button.large-button:hover {
|
||||
background: var(--color-bg-tertiary);
|
||||
}
|
||||
button.large-button:disabled:hover,
|
||||
button.large-button[disabled]:hover {
|
||||
background: var(--color-bg);
|
||||
}
|
||||
|
||||
button.period-selector {
|
||||
color: var(--color-fg-secondary);
|
||||
}
|
||||
button.period-selector:disabled,
|
||||
button.period-selector[disabled]:hover {
|
||||
color: var(--color-fg);
|
||||
}
|
||||
button.period-selector:hover {
|
||||
color: var(--color-fg);
|
||||
}
|
||||
|
||||
button.default {
|
||||
color: var(--color-fg);
|
||||
}
|
||||
button.default:disabled,
|
||||
button.default[disabled]:hover {
|
||||
color: var(--color-fg-secondary);
|
||||
}
|
||||
button.default:hover {
|
||||
color: var(--color-fg-secondary);
|
||||
}
|
||||
@ -0,0 +1,185 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getActivity, type getActivityArgs } from "api/api"
|
||||
import Popup from "./Popup"
|
||||
import { useEffect, useState } from "react"
|
||||
import { useTheme } from "~/hooks/useTheme"
|
||||
import ActivityOptsSelector from "./ActivityOptsSelector"
|
||||
|
||||
function getPrimaryColor(): string {
|
||||
const value = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--color-primary')
|
||||
.trim();
|
||||
|
||||
const rgbMatch = value.match(/^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/);
|
||||
if (rgbMatch) {
|
||||
const [, r, g, b] = rgbMatch.map(Number);
|
||||
return (
|
||||
'#' +
|
||||
[r, g, b]
|
||||
.map((n) => n.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
);
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
step?: string
|
||||
range?: number
|
||||
month?: number
|
||||
year?: number
|
||||
artistId?: number
|
||||
albumId?: number
|
||||
trackId?: number
|
||||
configurable?: boolean
|
||||
autoAdjust?: boolean
|
||||
}
|
||||
|
||||
export default function ActivityGrid({
|
||||
step = 'day',
|
||||
range = 182,
|
||||
month = 0,
|
||||
year = 0,
|
||||
artistId = 0,
|
||||
albumId = 0,
|
||||
trackId = 0,
|
||||
configurable = false,
|
||||
autoAdjust = false,
|
||||
}: Props) {
|
||||
|
||||
const [color, setColor] = useState(getPrimaryColor())
|
||||
const [stepState, setStep] = useState(step)
|
||||
const [rangeState, setRange] = useState(range)
|
||||
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
queryKey: [
|
||||
'listen-activity',
|
||||
{
|
||||
step: stepState,
|
||||
range: rangeState,
|
||||
month: month,
|
||||
year: year,
|
||||
artist_id: artistId,
|
||||
album_id: albumId,
|
||||
track_id: trackId
|
||||
},
|
||||
],
|
||||
queryFn: ({ queryKey }) => getActivity(queryKey[1] as getActivityArgs),
|
||||
});
|
||||
|
||||
|
||||
const { theme } = useTheme();
|
||||
useEffect(() => {
|
||||
const raf = requestAnimationFrame(() => {
|
||||
const color = getPrimaryColor()
|
||||
setColor(color);
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [theme]);
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="w-[500px]">
|
||||
<h2>Activity</h2>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (isError) return <p className="error">Error:{error.message}</p>
|
||||
|
||||
// from https://css-tricks.com/snippets/javascript/lighten-darken-color/
|
||||
function LightenDarkenColor(hex: string, lum: number) {
|
||||
// validate hex string
|
||||
hex = String(hex).replace(/[^0-9a-f]/gi, '');
|
||||
if (hex.length < 6) {
|
||||
hex = hex[0]+hex[0]+hex[1]+hex[1]+hex[2]+hex[2];
|
||||
}
|
||||
lum = lum || 0;
|
||||
|
||||
// convert to decimal and change luminosity
|
||||
var rgb = "#", c, i;
|
||||
for (i = 0; i < 3; i++) {
|
||||
c = parseInt(hex.substring(i*2,(i*2)+2), 16);
|
||||
c = Math.round(Math.min(Math.max(0, c + (c * lum)), 255)).toString(16);
|
||||
rgb += ("00"+c).substring(c.length);
|
||||
}
|
||||
|
||||
return rgb;
|
||||
}
|
||||
|
||||
const getDarkenAmount = (v: number, t: number): number => {
|
||||
|
||||
if (autoAdjust) {
|
||||
// automatically adjust the target value based on step
|
||||
// the smartest way to do this would be to have the api return the
|
||||
// highest value in the range. too bad im not smart
|
||||
switch (stepState) {
|
||||
case 'day':
|
||||
t = 10
|
||||
break;
|
||||
case 'week':
|
||||
t = 20
|
||||
break;
|
||||
case 'month':
|
||||
t = 50
|
||||
break;
|
||||
case 'year':
|
||||
t = 100
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
v = Math.min(v, t)
|
||||
if (theme === "pearl") {
|
||||
// special case for the only light theme lol
|
||||
// could be generalized by pragmatically comparing the
|
||||
// lightness of the bg vs the primary but eh
|
||||
return ((t-v) / t)
|
||||
} else {
|
||||
return ((v-t) / t) * .8
|
||||
}
|
||||
}
|
||||
|
||||
const dotSize = 12;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start">
|
||||
<h2>Activity</h2>
|
||||
{configurable ?
|
||||
<ActivityOptsSelector rangeSetter={setRange} currentRange={rangeState} stepSetter={setStep} currentStep={stepState} />
|
||||
:
|
||||
''
|
||||
}
|
||||
<div className="grid grid-flow-col grid-rows-7 gap-[5px]">
|
||||
{data.map((item) => (
|
||||
<div
|
||||
key={new Date(item.start_time).toString()}
|
||||
style={{ width: dotSize, height: dotSize }}
|
||||
>
|
||||
<Popup
|
||||
position="top"
|
||||
space={dotSize}
|
||||
extraClasses="left-2"
|
||||
inner={`${new Date(item.start_time).toLocaleDateString()} ${item.listens} plays`}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: dotSize,
|
||||
height: dotSize,
|
||||
display: 'inline-block',
|
||||
background:
|
||||
item.listens > 0
|
||||
? LightenDarkenColor(color, getDarkenAmount(item.listens, 100))
|
||||
: 'var(--color-bg-secondary)',
|
||||
}}
|
||||
className={`rounded-[3px] ${item.listens > 0 ? '' : 'border-[0.5px] border-(--color-bg-tertiary)'}`}
|
||||
></div>
|
||||
</Popup>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,98 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
interface Props {
|
||||
stepSetter: (value: string) => void;
|
||||
currentStep: string;
|
||||
rangeSetter: (value: number) => void;
|
||||
currentRange: number;
|
||||
disableCache?: boolean;
|
||||
}
|
||||
|
||||
export default function ActivityOptsSelector({
|
||||
stepSetter,
|
||||
currentStep,
|
||||
rangeSetter,
|
||||
currentRange,
|
||||
disableCache = false,
|
||||
}: Props) {
|
||||
const stepPeriods = ['day', 'week', 'month', 'year'];
|
||||
const rangePeriods = [105, 182, 365];
|
||||
|
||||
const stepDisplay = (str: string): string => {
|
||||
return str.split('_').map(w =>
|
||||
w.split('').map((char, index) =>
|
||||
index === 0 ? char.toUpperCase() : char).join('')
|
||||
).join(' ');
|
||||
};
|
||||
|
||||
const rangeDisplay = (r: number): string => {
|
||||
return `${r}`
|
||||
}
|
||||
|
||||
const setStep = (val: string) => {
|
||||
stepSetter(val);
|
||||
if (!disableCache) {
|
||||
localStorage.setItem('activity_step_' + window.location.pathname.split('/')[1], val);
|
||||
}
|
||||
};
|
||||
|
||||
const setRange = (val: number) => {
|
||||
rangeSetter(val);
|
||||
if (!disableCache) {
|
||||
localStorage.setItem('activity_range_' + window.location.pathname.split('/')[1], String(val));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!disableCache) {
|
||||
const cachedRange = parseInt(localStorage.getItem('activity_range_' + window.location.pathname.split('/')[1]) ?? '35');
|
||||
if (cachedRange) {
|
||||
rangeSetter(cachedRange);
|
||||
}
|
||||
const cachedStep = localStorage.getItem('activity_step_' + window.location.pathname.split('/')[1]);
|
||||
if (cachedStep) {
|
||||
stepSetter(cachedStep);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="flex gap-2 items-center">
|
||||
<p>Step:</p>
|
||||
{stepPeriods.map((p, i) => (
|
||||
<div key={`step_selector_${p}`}>
|
||||
<button
|
||||
className={`period-selector ${p === currentStep ? 'color-fg' : 'color-fg-secondary'} ${i !== stepPeriods.length - 1 ? 'pr-2' : ''}`}
|
||||
onClick={() => setStep(p)}
|
||||
disabled={p === currentStep}
|
||||
>
|
||||
{stepDisplay(p)}
|
||||
</button>
|
||||
<span className="color-fg-secondary">
|
||||
{i !== stepPeriods.length - 1 ? '|' : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 items-center">
|
||||
<p>Range:</p>
|
||||
{rangePeriods.map((r, i) => (
|
||||
<div key={`range_selector_${r}`}>
|
||||
<button
|
||||
className={`period-selector ${r === currentRange ? 'color-fg' : 'color-fg-secondary'} ${i !== rangePeriods.length - 1 ? 'pr-2' : ''}`}
|
||||
onClick={() => setRange(r)}
|
||||
disabled={r === currentRange}
|
||||
>
|
||||
{rangeDisplay(r)}
|
||||
</button>
|
||||
<span className="color-fg-secondary">
|
||||
{i !== rangePeriods.length - 1 ? '|' : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
import { imageUrl, type Album } from "api/api";
|
||||
import { Link } from "react-router";
|
||||
|
||||
interface Props {
|
||||
album: Album
|
||||
size: number
|
||||
}
|
||||
|
||||
export default function AlbumDisplay({ album, size }: Props) {
|
||||
return (
|
||||
<div className="flex gap-3" key={album.id}>
|
||||
<div>
|
||||
<Link to={`/album/${album.id}`}>
|
||||
<img src={imageUrl(album.image, "large")} alt={album.title} style={{width: size}}/>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-col items-start" style={{width: size}}>
|
||||
<Link to={`/album/${album.id}`} className="hover:text-(--color-fg-secondary)">
|
||||
<h4>{album.title}</h4>
|
||||
</Link>
|
||||
<p className="color-fg-secondary">{album.listen_count} plays</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getStats } from "api/api"
|
||||
|
||||
export default function AllTimeStats() {
|
||||
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
queryKey: ['stats', 'all_time'],
|
||||
queryFn: ({ queryKey }) => getStats(queryKey[1]),
|
||||
})
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="w-[200px]">
|
||||
<h2>All Time Stats</h2>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (isError) {
|
||||
return <p className="error">Error:{error.message}</p>
|
||||
}
|
||||
|
||||
const numberClasses = 'header-font font-bold text-xl'
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>All Time Stats</h2>
|
||||
<div>
|
||||
<span className={numberClasses}>{data.hours_listened}</span> Hours Listened
|
||||
</div>
|
||||
<div>
|
||||
<span className={numberClasses}>{data.listen_count}</span> Plays
|
||||
</div>
|
||||
<div>
|
||||
<span className={numberClasses}>{data.artist_count}</span> Artists
|
||||
</div>
|
||||
<div>
|
||||
<span className={numberClasses}>{data.album_count}</span> Albums
|
||||
</div>
|
||||
<div>
|
||||
<span className={numberClasses}>{data.track_count}</span> Tracks
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,51 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getTopAlbums, imageUrl, type getItemsArgs } from "api/api"
|
||||
import { Link } from "react-router"
|
||||
|
||||
interface Props {
|
||||
artistId: number
|
||||
name: string
|
||||
period: string
|
||||
}
|
||||
|
||||
export default function ArtistAlbums({artistId, name, period}: Props) {
|
||||
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
queryKey: ['top-albums', {limit: 99, period: "all_time", artist_id: artistId, page: 0}],
|
||||
queryFn: ({ queryKey }) => getTopAlbums(queryKey[1] as getItemsArgs),
|
||||
})
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div>
|
||||
<h2>Albums From This Artist</h2>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (isError) {
|
||||
return (
|
||||
<div>
|
||||
<h2>Albums From This Artist</h2>
|
||||
<p className="error">Error:{error.message}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Albums featuring {name}</h2>
|
||||
<div className="flex flex-wrap gap-8">
|
||||
{data.items.map((item) => (
|
||||
<Link to={`/album/${item.id}`}className="flex gap-2 items-start">
|
||||
<img src={imageUrl(item.image, "medium")} alt={item.title} style={{width: 130}} />
|
||||
<div className="w-[180px] flex flex-col items-start gap-1">
|
||||
<p>{item.title}</p>
|
||||
<p className="text-sm color-fg-secondary">{item.listen_count} play{item.listen_count > 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
type Artist = {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type ArtistLinksProps = {
|
||||
artists: Artist[];
|
||||
};
|
||||
|
||||
const ArtistLinks: React.FC<ArtistLinksProps> = ({ artists }) => {
|
||||
return (
|
||||
<>
|
||||
{artists.map((artist, index) => (
|
||||
<span key={artist.id} className='color-fg-secondary'>
|
||||
<Link className="hover:text-(--color-fg-tertiary)" to={`/artist/${artist.id}`}>{artist.name}</Link>
|
||||
{index < artists.length - 1 ? ', ' : ''}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ArtistLinks;
|
||||
@ -0,0 +1,43 @@
|
||||
import React, { useState } from "react"
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode
|
||||
onClick: () => void
|
||||
loading?: boolean
|
||||
disabled?: boolean
|
||||
confirm?: boolean
|
||||
}
|
||||
|
||||
export function AsyncButton(props: Props) {
|
||||
const [awaitingConfirm, setAwaitingConfirm] = useState(false)
|
||||
|
||||
const handleClick = () => {
|
||||
if (props.confirm) {
|
||||
if (!awaitingConfirm) {
|
||||
setAwaitingConfirm(true)
|
||||
setTimeout(() => setAwaitingConfirm(false), 3000)
|
||||
return
|
||||
}
|
||||
setAwaitingConfirm(false)
|
||||
}
|
||||
|
||||
props.onClick()
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={props.loading || props.disabled}
|
||||
className={`relative px-5 py-2 rounded-md large-button flex disabled:opacity-50 items-center`}
|
||||
>
|
||||
<span className={props.loading ? 'invisible' : 'visible'}>
|
||||
{awaitingConfirm ? 'Are you sure?' : props.children}
|
||||
</span>
|
||||
{props.loading && (
|
||||
<span className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="animate-spin h-4 w-4 border-2 border-white border-t-transparent rounded-full"></span>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
import { ExternalLinkIcon } from 'lucide-react'
|
||||
import pkg from '../../package.json'
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<div className="mx-auto py-10 pt-20 color-fg-tertiary text-sm">
|
||||
<ul className="flex flex-col items-center w-sm justify-around">
|
||||
<li>Koito {pkg.version}</li>
|
||||
<li><a href="https://github.com/gabehf/koito" target="_blank" className="link-underline">View the source on GitHub <ExternalLinkIcon className='inline mb-1' size={14}/></a></li>
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
// import { css } from '@emotion/css';
|
||||
// import { themes } from '../providers/ThemeProvider';
|
||||
|
||||
// export default function GlobalThemes() {
|
||||
// return (
|
||||
// <div
|
||||
// styles={css`
|
||||
// ${themes
|
||||
// .map(
|
||||
// (theme) => `
|
||||
// [data-theme=${theme.name}] {
|
||||
// --color-bg: ${theme.bg};
|
||||
// --color-bg-secondary: ${theme.bgSecondary};
|
||||
// --color-bg-tertiary:${theme.bgTertiary};
|
||||
// --color-fg: ${theme.fg};
|
||||
// --color-fg-secondary: ${theme.fgSecondary};
|
||||
// --color-fg-tertiary: ${theme.fgTertiary};
|
||||
// --color-primary: ${theme.primary};
|
||||
// --color-primary-dim: ${theme.primaryDim};
|
||||
// --color-secondary: ${theme.secondary};
|
||||
// --color-secondary-dim: ${theme.secondaryDim};
|
||||
// --color-error: ${theme.error};
|
||||
// --color-success: ${theme.success};
|
||||
// --color-warning: ${theme.warning};
|
||||
// --color-info: ${theme.info};
|
||||
// --color-border: var(--color-bg-tertiary);
|
||||
// --color-shadow: rgba(0, 0, 0, 0.5);
|
||||
// --color-link: var(--color-primary);
|
||||
// --color-link-hover: var(--color-primary-dim);
|
||||
// }
|
||||
// `).join('\n')
|
||||
// }
|
||||
// `}
|
||||
// />
|
||||
// )
|
||||
// }
|
||||
@ -0,0 +1,53 @@
|
||||
import { replaceImage } from 'api/api';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
interface Props {
|
||||
itemType: string,
|
||||
id: number,
|
||||
onComplete: Function
|
||||
}
|
||||
|
||||
export default function ImageDropHandler({ itemType, id, onComplete }: Props) {
|
||||
useEffect(() => {
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
console.log('dragover!!')
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
const handleDrop = async (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (!e.dataTransfer?.files.length) return;
|
||||
|
||||
const imageFile = Array.from(e.dataTransfer.files).find(file =>
|
||||
file.type.startsWith('image/')
|
||||
);
|
||||
if (!imageFile) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('image', imageFile);
|
||||
formData.append(itemType.toLowerCase()+'_id', String(id))
|
||||
replaceImage(formData).then((r) => {
|
||||
if (r.status >= 200 && r.status < 300) {
|
||||
onComplete()
|
||||
console.log("Replacement image uploaded successfully")
|
||||
} else {
|
||||
r.json().then((body) => {
|
||||
console.log(`Upload failed: ${r.statusText} - ${body}`)
|
||||
})
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.log(`Upload failed: ${err}`)
|
||||
})
|
||||
};
|
||||
|
||||
window.addEventListener('dragover', handleDragOver);
|
||||
window.addEventListener('drop', handleDrop);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('dragover', handleDragOver);
|
||||
window.removeEventListener('drop', handleDrop);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
@ -0,0 +1,57 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { timeSince } from "~/utils/utils"
|
||||
import ArtistLinks from "./ArtistLinks"
|
||||
import { getLastListens, type getItemsArgs } from "api/api"
|
||||
import { Link } from "react-router"
|
||||
|
||||
interface Props {
|
||||
limit: number
|
||||
artistId?: Number
|
||||
albumId?: Number
|
||||
trackId?: number
|
||||
hideArtists?: boolean
|
||||
}
|
||||
|
||||
export default function LastPlays(props: Props) {
|
||||
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
queryKey: ['last-listens', {limit: props.limit, period: 'all_time', artist_id: props.artistId, album_id: props.albumId, track_id: props.trackId}],
|
||||
queryFn: ({ queryKey }) => getLastListens(queryKey[1] as getItemsArgs),
|
||||
})
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="w-[500px]">
|
||||
<h2>Last Played</h2>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (isError) {
|
||||
return <p className="error">Error:{error.message}</p>
|
||||
}
|
||||
|
||||
let params = ''
|
||||
params += props.artistId ? `&artist_id=${props.artistId}` : ''
|
||||
params += props.albumId ? `&album_id=${props.albumId}` : ''
|
||||
params += props.trackId ? `&track_id=${props.trackId}` : ''
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="hover:underline"><Link to={`/listens?period=all_time${params}`}>Last Played</Link></h2>
|
||||
<table>
|
||||
<tbody>
|
||||
{data.items.map((item) => (
|
||||
<tr key={`last_listen_${item.time}`}>
|
||||
<td className="color-fg-tertiary pr-4 text-sm" title={new Date(item.time).toString()}>{timeSince(new Date(item.time))}</td>
|
||||
<td className="text-ellipsis overflow-hidden max-w-[600px]">
|
||||
{props.hideArtists ? <></> : <><ArtistLinks artists={item.track.artists} /> - </>}
|
||||
<Link className="hover:text-(--color-fg-secondary)" to={`/track/${item.track.id}`}>{item.track.title}</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,52 @@
|
||||
import { useEffect } from "react"
|
||||
|
||||
interface Props {
|
||||
setter: Function
|
||||
current: string
|
||||
disableCache?: boolean
|
||||
}
|
||||
|
||||
export default function PeriodSelector({ setter, current, disableCache = false }: Props) {
|
||||
const periods = ['day', 'week', 'month', 'year', 'all_time']
|
||||
|
||||
const periodDisplay = (str: string) => {
|
||||
return str.split('_').map(w => w.split('').map((char, index) =>
|
||||
index === 0 ? char.toUpperCase() : char).join('')).join(' ')
|
||||
}
|
||||
|
||||
const setPeriod = (val: string) => {
|
||||
setter(val)
|
||||
if (!disableCache) {
|
||||
localStorage.setItem('period_selection_'+window.location.pathname.split('/')[1], val)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!disableCache) {
|
||||
const cached = localStorage.getItem('period_selection_' + window.location.pathname.split('/')[1]);
|
||||
if (cached) {
|
||||
setter(cached);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<p>Showing stats for:</p>
|
||||
{periods.map((p, i) => (
|
||||
<div key={`period_setter_${p}`}>
|
||||
<button
|
||||
className={`period-selector ${p === current ? 'color-fg' : 'color-fg-secondary'} ${i !== periods.length - 1 ? 'pr-2' : ''}`}
|
||||
onClick={() => setPeriod(p)}
|
||||
disabled={p === current}
|
||||
>
|
||||
{periodDisplay(p)}
|
||||
</button>
|
||||
<span className="color-fg-secondary">
|
||||
{i !== periods.length - 1 ? '|' : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,48 @@
|
||||
import React, { type PropsWithChildren, useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
inner: React.ReactNode
|
||||
position: string
|
||||
space: number
|
||||
extraClasses?: string
|
||||
hint?: string
|
||||
}
|
||||
|
||||
export default function Popup({ inner, position, space, extraClasses, children }: PropsWithChildren<Props>) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
let positionClasses
|
||||
let spaceCSS = {}
|
||||
if (position == "top") {
|
||||
positionClasses = `top-${space} -bottom-2 -translate-y-1/2 -translate-x-1/2`
|
||||
} else if (position == "right") {
|
||||
positionClasses = `bottom-1 -translate-x-1/2`
|
||||
spaceCSS = {left: 70 + space}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative"
|
||||
onMouseEnter={() => setIsVisible(true)}
|
||||
onMouseLeave={() => setIsVisible(false)}
|
||||
>
|
||||
{children}
|
||||
<div
|
||||
className={`
|
||||
absolute
|
||||
${positionClasses}
|
||||
${extraClasses ? extraClasses : ''}
|
||||
bg-(--color-bg) color-fg border-1 border-(--color-bg-tertiary)
|
||||
px-3 py-2 rounded-lg
|
||||
transition-opacity duration-100
|
||||
${isVisible ? 'opacity-100' : 'opacity-0 pointer-events-none'}
|
||||
z-50 text-center
|
||||
flex
|
||||
`}
|
||||
style={spaceCSS}
|
||||
>
|
||||
{inner}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
import { Link } from "react-router"
|
||||
|
||||
interface Props {
|
||||
to: string
|
||||
onClick: React.MouseEventHandler<HTMLAnchorElement>
|
||||
img: string
|
||||
text: string
|
||||
subtext?: string
|
||||
}
|
||||
|
||||
export default function SearchResultItem(props: Props) {
|
||||
return (
|
||||
<Link to={props.to} className="px-3 py-2 flex gap-3 items-center hover:text-(--color-fg-secondary)" onClick={props.onClick}>
|
||||
<img src={props.img} alt={props.text} />
|
||||
<div>
|
||||
{props.text}
|
||||
{props.subtext ? <><br/>
|
||||
<span className="color-fg-secondary">{props.subtext}</span>
|
||||
</> : ''}
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
import { Check } from "lucide-react"
|
||||
import CheckCircleIcon from "./icons/CheckCircleIcon"
|
||||
|
||||
interface Props {
|
||||
id: number
|
||||
onClick: React.MouseEventHandler<HTMLButtonElement>
|
||||
img: string
|
||||
text: string
|
||||
subtext?: string
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export default function SearchResultSelectorItem(props: Props) {
|
||||
return (
|
||||
<button className="px-3 py-2 flex gap-3 items-center hover:text-(--color-fg-secondary) hover:cursor-pointer w-full" style={{ border: props.active ? "1px solid var(--color-fg-tertiary" : ''}} onClick={props.onClick}>
|
||||
<img src={props.img} alt={props.text} />
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<div className="flex flex-col items-start text-start">
|
||||
{props.text}
|
||||
{props.subtext ? <><br/>
|
||||
<span className="color-fg-secondary">{props.subtext}</span>
|
||||
</> : ''}
|
||||
</div>
|
||||
{
|
||||
props.active ?
|
||||
<div className="px-2"><Check size={24} /></div> : ''
|
||||
}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,107 @@
|
||||
import { imageUrl, type SearchResponse } from "api/api"
|
||||
import { useState } from "react"
|
||||
import SearchResultItem from "./SearchResultItem"
|
||||
import SearchResultSelectorItem from "./SearchResultSelectorItem"
|
||||
|
||||
interface Props {
|
||||
data?: SearchResponse
|
||||
onSelect: Function
|
||||
selectorMode?: boolean
|
||||
}
|
||||
export default function SearchResults({ data, onSelect, selectorMode }: Props) {
|
||||
const [selected, setSelected] = useState(0)
|
||||
const classes = "flex flex-col items-start bg rounded w-full"
|
||||
const hClasses = "pt-4 pb-2"
|
||||
|
||||
const selectItem = (title: string, id: number) => {
|
||||
if (selected === id) {
|
||||
setSelected(0)
|
||||
onSelect({id: id, title: title})
|
||||
} else {
|
||||
setSelected(id)
|
||||
onSelect({id: id, title: title})
|
||||
}
|
||||
}
|
||||
|
||||
if (data === undefined) {
|
||||
return <></>
|
||||
}
|
||||
return (
|
||||
<div className="w-full">
|
||||
{ data.artists.length > 0 &&
|
||||
<>
|
||||
<h3 className={hClasses}>Artists</h3>
|
||||
<div className={classes}>
|
||||
{data.artists.map((artist) => (
|
||||
selectorMode ?
|
||||
<SearchResultSelectorItem
|
||||
id={artist.id}
|
||||
onClick={() => selectItem(artist.name, artist.id)}
|
||||
text={artist.name}
|
||||
img={imageUrl(artist.image, "small")}
|
||||
active={selected === artist.id}
|
||||
/> :
|
||||
<SearchResultItem
|
||||
to={`/artist/${artist.id}`}
|
||||
onClick={() => onSelect(artist.id)}
|
||||
text={artist.name}
|
||||
img={imageUrl(artist.image, "small")}
|
||||
/>
|
||||
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
{ data.albums.length > 0 &&
|
||||
<>
|
||||
<h3 className={hClasses}>Albums</h3>
|
||||
<div className={classes}>
|
||||
{data.albums.map((album) => (
|
||||
selectorMode ?
|
||||
<SearchResultSelectorItem
|
||||
id={album.id}
|
||||
onClick={() => selectItem(album.title, album.id)}
|
||||
text={album.title}
|
||||
subtext={album.is_various_artists ? "Various Artists" : album.artists[0].name}
|
||||
img={imageUrl(album.image, "small")}
|
||||
active={selected === album.id}
|
||||
/> :
|
||||
<SearchResultItem
|
||||
to={`/album/${album.id}`}
|
||||
onClick={() => onSelect(album.id)}
|
||||
text={album.title}
|
||||
subtext={album.is_various_artists ? "Various Artists" : album.artists[0].name}
|
||||
img={imageUrl(album.image, "small")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
{ data.tracks.length > 0 &&
|
||||
<>
|
||||
<h3 className={hClasses}>Tracks</h3>
|
||||
<div className={classes}>
|
||||
{data.tracks.map((track) => (
|
||||
selectorMode ?
|
||||
<SearchResultSelectorItem
|
||||
id={track.id}
|
||||
onClick={() => selectItem(track.title, track.id)}
|
||||
text={track.title}
|
||||
subtext={track.artists.map((a) => a.name).join(', ')}
|
||||
img={imageUrl(track.image, "small")}
|
||||
active={selected === track.id}
|
||||
/> :
|
||||
<SearchResultItem
|
||||
to={`/track/${track.id}`}
|
||||
onClick={() => onSelect(track.id)}
|
||||
text={track.title}
|
||||
subtext={track.artists.map((a) => a.name).join(', ')}
|
||||
img={imageUrl(track.image, "small")}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import ArtistLinks from "./ArtistLinks"
|
||||
import { getTopAlbums, getTopTracks, imageUrl, type getItemsArgs } from "api/api"
|
||||
import { Link } from "react-router"
|
||||
import TopListSkeleton from "./skeletons/TopListSkeleton"
|
||||
import TopItemList from "./TopItemList"
|
||||
|
||||
interface Props {
|
||||
limit: number,
|
||||
period: string,
|
||||
artistId?: Number
|
||||
}
|
||||
|
||||
export default function TopAlbums (props: Props) {
|
||||
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
queryKey: ['top-albums', {limit: props.limit, period: props.period, artistId: props.artistId, page: 0 }],
|
||||
queryFn: ({ queryKey }) => getTopAlbums(queryKey[1] as getItemsArgs),
|
||||
})
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="w-[300px]">
|
||||
<h2>Top Albums</h2>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (isError) {
|
||||
return <p className="error">Error:{error.message}</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="hover:underline"><Link to={`/chart/top-albums?period=${props.period}${props.artistId ? `&artist_id=${props.artistId}` : ''}`}>Top Albums</Link></h2>
|
||||
<div className="max-w-[300px]">
|
||||
<TopItemList type="album" data={data} />
|
||||
{data.items.length < 1 ? 'Nothing to show' : ''}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import ArtistLinks from "./ArtistLinks"
|
||||
import { getTopArtists, imageUrl, type getItemsArgs } from "api/api"
|
||||
import { Link } from "react-router"
|
||||
import TopListSkeleton from "./skeletons/TopListSkeleton"
|
||||
import TopItemList from "./TopItemList"
|
||||
|
||||
interface Props {
|
||||
limit: number,
|
||||
period: string,
|
||||
artistId?: Number
|
||||
albumId?: Number
|
||||
}
|
||||
|
||||
export default function TopArtists (props: Props) {
|
||||
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
queryKey: ['top-artists', {limit: props.limit, period: props.period, page: 0 }],
|
||||
queryFn: ({ queryKey }) => getTopArtists(queryKey[1] as getItemsArgs),
|
||||
})
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="w-[300px]">
|
||||
<h2>Top Artists</h2>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (isError) {
|
||||
return <p className="error">Error:{error.message}</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="hover:underline"><Link to={`/chart/top-artists?period=${props.period}`}>Top Artists</Link></h2>
|
||||
<div className="max-w-[300px]">
|
||||
<TopItemList type="artist" data={data} />
|
||||
{data.items.length < 1 ? 'Nothing to show' : ''}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,142 @@
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import ArtistLinks from "./ArtistLinks";
|
||||
import { imageUrl, type Album, type Artist, type Track, type PaginatedResponse } from "api/api";
|
||||
|
||||
type Item = Album | Track | Artist;
|
||||
|
||||
interface Props<T extends Item> {
|
||||
data: PaginatedResponse<T>
|
||||
separators?: ConstrainBoolean
|
||||
width?: number
|
||||
type: "album" | "track" | "artist";
|
||||
}
|
||||
|
||||
export default function TopItemList<T extends Item>({ data, separators, type, width }: Props<T>) {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1" style={{width: width ?? 300}}>
|
||||
{data.items.map((item, index) => {
|
||||
const key = `${type}-${item.id}`;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
style={{ fontSize: 12 }}
|
||||
className={`${
|
||||
separators && index !== data.items.length - 1 ? 'border-b border-(--color-fg-tertiary) mb-1 pb-2' : ''
|
||||
}`}
|
||||
>
|
||||
<ItemCard item={item} type={type} key={type+item.id} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemCard({ item, type }: { item: Item; type: "album" | "track" | "artist" }) {
|
||||
|
||||
const itemClasses = `flex items-center gap-2 hover:text-(--color-fg-secondary)`
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleItemClick = (type: string, id: number) => {
|
||||
navigate(`/${type.toLowerCase()}/${id}`);
|
||||
};
|
||||
|
||||
const handleArtistClick = (event: React.MouseEvent) => {
|
||||
// Stop the click from navigating to the album page
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
// Also stop keyboard events on the inner links from bubbling up
|
||||
const handleArtistKeyDown = (event: React.KeyboardEvent) => {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case "album": {
|
||||
const album = item as Album;
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (event.key === 'Enter') {
|
||||
handleItemClick("album", album.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{fontSize: 12}}>
|
||||
<div
|
||||
className={itemClasses}
|
||||
onClick={() => handleItemClick("album", album.id)}
|
||||
onKeyDown={handleKeyDown}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
aria-label={`View album: ${album.title}`}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<img src={imageUrl(album.image, "small")} alt={album.title} />
|
||||
<div>
|
||||
<span style={{fontSize: 14}}>{album.title}</span>
|
||||
<br />
|
||||
{album.is_various_artists ?
|
||||
<span className="color-fg-secondary">Various Artists</span>
|
||||
:
|
||||
<div onClick={handleArtistClick} onKeyDown={handleArtistKeyDown}>
|
||||
<ArtistLinks artists={album.artists || [{id: 0, Name: 'Unknown Artist'}]}/>
|
||||
</div>
|
||||
}
|
||||
<div className="color-fg-secondary">{album.listen_count} plays</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "track": {
|
||||
const track = item as Track;
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (event.key === 'Enter') {
|
||||
handleItemClick("track", track.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{fontSize: 12}}>
|
||||
<div
|
||||
className={itemClasses}
|
||||
onClick={() => handleItemClick("track", track.id)}
|
||||
onKeyDown={handleKeyDown}
|
||||
role="link"
|
||||
tabIndex={0}
|
||||
aria-label={`View track: ${track.title}`}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<img src={imageUrl(track.image, "small")} alt={track.title} />
|
||||
<div>
|
||||
<span style={{fontSize: 14}}>{track.title}</span>
|
||||
<br />
|
||||
<div onClick={handleArtistClick} onKeyDown={handleArtistKeyDown}>
|
||||
<ArtistLinks artists={track.artists || [{id: 0, Name: 'Unknown Artist'}]}/>
|
||||
</div>
|
||||
<div className="color-fg-secondary">{track.listen_count} plays</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "artist": {
|
||||
const artist = item as Artist;
|
||||
return (
|
||||
<div style={{fontSize: 12}}>
|
||||
<Link className={itemClasses+' mt-1 mb-[6px]'} to={`/artist/${artist.id}`}>
|
||||
<img src={imageUrl(artist.image, "small")} alt={artist.name} />
|
||||
<div>
|
||||
<span style={{fontSize: 14}}>{artist.name}</span>
|
||||
<div className="color-fg-secondary">{artist.listen_count} plays</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,38 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import { getTopAlbums, type getItemsArgs } from "api/api"
|
||||
import AlbumDisplay from "./AlbumDisplay"
|
||||
|
||||
interface Props {
|
||||
period: string
|
||||
artistId?: Number
|
||||
vert?: boolean
|
||||
hideTitle?: boolean
|
||||
}
|
||||
|
||||
export default function TopThreeAlbums(props: Props) {
|
||||
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
queryKey: ['top-albums', {limit: 3, period: props.period, artist_id: props.artistId, page: 0}],
|
||||
queryFn: ({ queryKey }) => getTopAlbums(queryKey[1] as getItemsArgs),
|
||||
})
|
||||
|
||||
if (isPending) {
|
||||
return <p>Loading...</p>
|
||||
}
|
||||
if (isError) {
|
||||
return <p className="error">Error:{error.message}</p>
|
||||
}
|
||||
|
||||
console.log(data)
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!props.hideTitle && <h2>Top Three Albums</h2>}
|
||||
<div className={`flex ${props.vert ? 'flex-col' : ''}`} style={{gap: 15}}>
|
||||
{data.items.map((item, index) => (
|
||||
<AlbumDisplay album={item} size={index === 0 ? 190 : 130} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,50 @@
|
||||
import { useQuery } from "@tanstack/react-query"
|
||||
import ArtistLinks from "./ArtistLinks"
|
||||
import { getTopTracks, imageUrl, type getItemsArgs } from "api/api"
|
||||
import { Link } from "react-router"
|
||||
import TopListSkeleton from "./skeletons/TopListSkeleton"
|
||||
import { useEffect } from "react"
|
||||
import TopItemList from "./TopItemList"
|
||||
|
||||
interface Props {
|
||||
limit: number,
|
||||
period: string,
|
||||
artistId?: Number
|
||||
albumId?: Number
|
||||
}
|
||||
|
||||
const TopTracks = (props: Props) => {
|
||||
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
queryKey: ['top-tracks', {limit: props.limit, period: props.period, artist_id: props.artistId, album_id: props.albumId, page: 0}],
|
||||
queryFn: ({ queryKey }) => getTopTracks(queryKey[1] as getItemsArgs),
|
||||
})
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<div className="w-[300px]">
|
||||
<h2>Top Tracks</h2>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (isError) {
|
||||
return <p className="error">Error:{error.message}</p>
|
||||
}
|
||||
|
||||
let params = ''
|
||||
params += props.artistId ? `&artist_id=${props.artistId}` : ''
|
||||
params += props.albumId ? `&album_id=${props.albumId}` : ''
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="hover:underline"><Link to={`/chart/top-tracks?period=${props.period}${params}`}>Top Tracks</Link></h2>
|
||||
<div className="max-w-[300px]">
|
||||
<TopItemList type="track" data={data}/>
|
||||
{data.items.length < 1 ? 'Nothing to show' : ''}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TopTracks
|
||||
@ -0,0 +1,16 @@
|
||||
interface Props {
|
||||
size: number,
|
||||
hover?: boolean,
|
||||
}
|
||||
export default function ChartIcon({size, hover}: Props) {
|
||||
let classNames = ""
|
||||
if (hover) {
|
||||
classNames += "icon-hover-stroke"
|
||||
}
|
||||
return (
|
||||
<div className={classNames}>
|
||||
<svg width={`${size}px`} height={`${size}px`} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M12 0C7.58172 0 4 3.58172 4 8C4 10.0289 4.75527 11.8814 6 13.2916V23C6 23.3565 6.18976 23.686 6.49807 23.8649C6.80639 24.0438 7.18664 24.0451 7.49614 23.8682L12 21.2946L16.5039 23.8682C16.8134 24.0451 17.1936 24.0438 17.5019 23.8649C17.8102 23.686 18 23.3565 18 23V13.2916C19.2447 11.8814 20 10.0289 20 8C20 3.58172 16.4183 0 12 0ZM6 8C6 4.68629 8.68629 2 12 2C15.3137 2 18 4.68629 18 8C18 11.3137 15.3137 14 12 14C8.68629 14 6 11.3137 6 8ZM16 14.9297C14.8233 15.6104 13.4571 16 12 16C10.5429 16 9.17669 15.6104 8 14.9297V21.2768L11.5039 19.2746C11.8113 19.0989 12.1887 19.0989 12.4961 19.2746L16 21.2768V14.9297Z" fill="var(--color-fg)"/> </svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
interface Props {
|
||||
size: number,
|
||||
hover?: boolean,
|
||||
color?: string
|
||||
}
|
||||
export default function CheckCircleIcon({size, hover, color}: Props) {
|
||||
let classNames = ""
|
||||
if (hover) {
|
||||
classNames += "icon-hover-fill"
|
||||
}
|
||||
return (
|
||||
<div className={classNames}>
|
||||
<svg width={`${size}px`} height={`${size}px`} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill={color !== undefined ? `var(--${color})` : 'var(--color-fg)'} fill-rule="evenodd" d="M3 10a7 7 0 019.307-6.611 1 1 0 00.658-1.889 9 9 0 105.98 7.501 1 1 0 00-1.988.22A7 7 0 113 10zm14.75-5.338a1 1 0 00-1.5-1.324l-6.435 7.28-3.183-2.593a1 1 0 00-1.264 1.55l3.929 3.2a1 1 0 001.38-.113l7.072-8z"/> </svg></div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
interface Props {
|
||||
size: number,
|
||||
hover?: boolean,
|
||||
}
|
||||
export default function GraphIcon({size, hover}: Props) {
|
||||
let classNames = ""
|
||||
if (hover) {
|
||||
classNames += "icon-hover-stroke"
|
||||
}
|
||||
return (
|
||||
<div className={classNames}>
|
||||
<svg width={`${size}px`} height={`${size}px`} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M21 21H7.8C6.11984 21 5.27976 21 4.63803 20.673C4.07354 20.3854 3.6146 19.9265 3.32698 19.362C3 18.7202 3 17.8802 3 16.2V3M15 10V17M7 13V17M19 5V17M11 7V17" stroke="var(--color-fg)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
interface Props {
|
||||
size: number,
|
||||
hover?: boolean,
|
||||
}
|
||||
export default function HomeIcon({size, hover}: Props) {
|
||||
let classNames = ""
|
||||
if (hover) {
|
||||
classNames += "icon-hover-fill"
|
||||
}
|
||||
return (
|
||||
<div className={classNames}>
|
||||
<svg width={`${size}px`} height={`${size}px`} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.3103 1.77586C11.6966 1.40805 12.3034 1.40805 12.6897 1.77586L20.6897 9.39491L23.1897 11.7759C23.5896 12.1567 23.605 12.7897 23.2241 13.1897C22.8433 13.5896 22.2103 13.605 21.8103 13.2241L21 12.4524V20C21 21.1046 20.1046 22 19 22H14H10H5C3.89543 22 3 21.1046 3 20V12.4524L2.18966 13.2241C1.78972 13.605 1.15675 13.5896 0.775862 13.1897C0.394976 12.7897 0.410414 12.1567 0.810345 11.7759L3.31034 9.39491L11.3103 1.77586ZM5 10.5476V20H9V15C9 13.3431 10.3431 12 12 12C13.6569 12 15 13.3431 15 15V20H19V10.5476L12 3.88095L5 10.5476ZM13 20V15C13 14.4477 12.5523 14 12 14C11.4477 14 11 14.4477 11 15V20H13Z" fill="var(--color-fg)"/>
|
||||
</svg></div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
interface Props {
|
||||
size: number,
|
||||
hover?: boolean,
|
||||
}
|
||||
export default function ImageIcon({size, hover}: Props) {
|
||||
let classNames = ""
|
||||
if (hover) {
|
||||
classNames += "icon-hover-stroke"
|
||||
}
|
||||
return (
|
||||
<div className={classNames}>
|
||||
<svg width={`${size}px`} height={`${size}px`} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14.2639 15.9375L12.5958 14.2834C11.7909 13.4851 11.3884 13.086 10.9266 12.9401C10.5204 12.8118 10.0838 12.8165 9.68048 12.9536C9.22188 13.1095 8.82814 13.5172 8.04068 14.3326L4.04409 18.2801M14.2639 15.9375L14.6053 15.599C15.4112 14.7998 15.8141 14.4002 16.2765 14.2543C16.6831 14.126 17.12 14.1311 17.5236 14.2687C17.9824 14.4251 18.3761 14.8339 19.1634 15.6514L20 16.4934M14.2639 15.9375L18.275 19.9565M18.275 19.9565C17.9176 20 17.4543 20 16.8 20H7.2C6.07989 20 5.51984 20 5.09202 19.782C4.71569 19.5903 4.40973 19.2843 4.21799 18.908C4.12796 18.7313 4.07512 18.5321 4.04409 18.2801M18.275 19.9565C18.5293 19.9256 18.7301 19.8727 18.908 19.782C19.2843 19.5903 19.5903 19.2843 19.782 18.908C20 18.4802 20 17.9201 20 16.8V16.4934M4.04409 18.2801C4 17.9221 4 17.4575 4 16.8V7.2C4 6.0799 4 5.51984 4.21799 5.09202C4.40973 4.71569 4.71569 4.40973 5.09202 4.21799C5.51984 4 6.07989 4 7.2 4H16.8C17.9201 4 18.4802 4 18.908 4.21799C19.2843 4.40973 19.5903 4.71569 19.782 5.09202C20 5.51984 20 6.0799 20 7.2V16.4934M17 8.99989C17 10.1045 16.1046 10.9999 15 10.9999C13.8954 10.9999 13 10.1045 13 8.99989C13 7.89532 13.8954 6.99989 15 6.99989C16.1046 6.99989 17 7.89532 17 8.99989Z" stroke="var(--color-fg)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg></div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
interface Props {
|
||||
size: number,
|
||||
hover?: boolean,
|
||||
}
|
||||
export default function MergeIcon({size, hover}: Props) {
|
||||
let classNames = ""
|
||||
if (hover) {
|
||||
classNames += "icon-hover-fill"
|
||||
}
|
||||
return (
|
||||
<div className={classNames}>
|
||||
<svg width={`${size}px`} height={`${size}px`} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="var(--color-fg)" fill-rule="evenodd" d="M10,0 L10,2.60002 C12.2108812,3.04881281 13.8920863,4.95644867 13.9950026,7.27443311 L14,7.5 L14,11.2676 C14.5978,11.6134 15,12.2597 15,13 C15,14.1046 14.1046,15 13,15 C11.8954,15 11,14.1046 11,13 C11,12.3166462 11.342703,11.713387 11.8656124,11.3526403 L12,11.2676 L12,7.5 C12,6.259091 11.246593,5.19415145 10.1722389,4.73766702 L10,4.67071 L10,7 L6,3.5 L10,0 Z M3,1 C4.10457,1 5,1.89543 5,3 C5,3.68333538 4.65729704,4.28663574 4.13438762,4.6473967 L4,4.73244 L4,11.2676 C4.5978,11.6134 5,12.2597 5,13 C5,14.1046 4.10457,15 3,15 C1.89543,15 1,14.1046 1,13 C1,12.3166462 1.34270296,11.713387 1.86561238,11.3526403 L2,11.2676 L2,4.73244 C1.4022,4.38663 1,3.74028 1,3 C1,1.89543 1.89543,1 3,1 Z"/> </svg></div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
interface Props {
|
||||
size: number,
|
||||
hover?: boolean,
|
||||
}
|
||||
export default function SearchIcon({size, hover}: Props) {
|
||||
let classNames = ""
|
||||
if (hover) {
|
||||
classNames += "icon-hover-stroke"
|
||||
}
|
||||
return (
|
||||
<div className={classNames}>
|
||||
<svg width={`${size}px`} height={`${size}px`} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M11 6C13.7614 6 16 8.23858 16 11M16.6588 16.6549L21 21M19 11C19 15.4183 15.4183 19 11 19C6.58172 19 3 15.4183 3 11C3 6.58172 6.58172 3 11 3C15.4183 3 19 6.58172 19 11Z" stroke="var(--color-fg)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg></div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,97 @@
|
||||
import { logout, updateUser } from "api/api"
|
||||
import { useState } from "react"
|
||||
import { AsyncButton } from "../AsyncButton"
|
||||
import { useAppContext } from "~/providers/AppProvider"
|
||||
|
||||
export default function Account() {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirmPw, setConfirmPw] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState('')
|
||||
const { user, setUsername: setCtxUsername } = useAppContext()
|
||||
|
||||
const logoutHandler = () => {
|
||||
setLoading(true)
|
||||
logout()
|
||||
.then(r => {
|
||||
if (r.ok) {
|
||||
window.location.reload()
|
||||
} else {
|
||||
r.json().then(r => setError(r.error))
|
||||
}
|
||||
}).catch(err => setError(err))
|
||||
setLoading(false)
|
||||
}
|
||||
const updateHandler = () => {
|
||||
if (password != "" && confirmPw === "") {
|
||||
setError("confirm your password before submitting")
|
||||
return
|
||||
}
|
||||
setError('')
|
||||
setSuccess('')
|
||||
setLoading(true)
|
||||
updateUser(username, password)
|
||||
.then(r => {
|
||||
if (r.ok) {
|
||||
setSuccess("sucessfully updated user")
|
||||
if (username != "") {
|
||||
setCtxUsername(username)
|
||||
}
|
||||
setUsername('')
|
||||
setPassword('')
|
||||
setConfirmPw('')
|
||||
} else {
|
||||
r.json().then((r) => setError(r.error))
|
||||
}
|
||||
}).catch(err => setError(err))
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2>Account</h2>
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-4 items-center">
|
||||
<p>You're logged in as <strong>{user?.username}</strong></p>
|
||||
<AsyncButton loading={loading} onClick={logoutHandler}>Logout</AsyncButton>
|
||||
</div>
|
||||
<h2>Update User</h2>
|
||||
<div className="flex flex gap-4">
|
||||
<input
|
||||
name="koito-update-username"
|
||||
type="text"
|
||||
placeholder="Update username"
|
||||
className="w-full mx-auto fg bg rounded p-2"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex gap-4">
|
||||
<input
|
||||
name="koito-update-password"
|
||||
type="password"
|
||||
placeholder="Update password"
|
||||
className="w-full mx-auto fg bg rounded p-2"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
name="koito-confirm-password"
|
||||
type="password"
|
||||
placeholder="Confirm password"
|
||||
className="w-full mx-auto fg bg rounded p-2"
|
||||
value={confirmPw}
|
||||
onChange={(e) => setConfirmPw(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-sm">
|
||||
<AsyncButton loading={loading} onClick={updateHandler}>Submit</AsyncButton>
|
||||
</div>
|
||||
{success != "" && <p className="success">{success}</p>}
|
||||
{error != "" && <p className="error">{error}</p>}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,17 @@
|
||||
import { useAppContext } from "~/providers/AppProvider"
|
||||
import LoginForm from "./LoginForm"
|
||||
import Account from "./Account"
|
||||
|
||||
export default function AuthForm() {
|
||||
const { user } = useAppContext()
|
||||
|
||||
return (
|
||||
<>
|
||||
{ user ?
|
||||
<Account />
|
||||
:
|
||||
<LoginForm />
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,129 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createApiKey, deleteApiKey, getApiKeys, type ApiKey } from "api/api";
|
||||
import { AsyncButton } from "../AsyncButton";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Copy, Trash } from "lucide-react";
|
||||
|
||||
type CopiedState = {
|
||||
x: number;
|
||||
y: number;
|
||||
visible: boolean;
|
||||
};
|
||||
|
||||
export default function ApiKeysModal() {
|
||||
const [input, setInput] = useState('')
|
||||
const [loading, setLoading ] = useState(false)
|
||||
const [err, setError ] = useState<string>()
|
||||
const [displayData, setDisplayData] = useState<ApiKey[]>([])
|
||||
const [copied, setCopied] = useState<CopiedState | null>(null);
|
||||
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
queryKey: [
|
||||
'api-keys'
|
||||
],
|
||||
queryFn: () => {
|
||||
return getApiKeys();
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setDisplayData(data)
|
||||
}
|
||||
}, [data])
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<p className="error">Error: {error.message}</p>
|
||||
)
|
||||
}
|
||||
if (isPending) {
|
||||
return (
|
||||
<p>Loading...</p>
|
||||
)
|
||||
}
|
||||
|
||||
const handleCopy = (e: React.MouseEvent<HTMLButtonElement>, text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
|
||||
const parentRect = (e.currentTarget.closest(".relative") as HTMLElement).getBoundingClientRect();
|
||||
const buttonRect = e.currentTarget.getBoundingClientRect();
|
||||
|
||||
setCopied({
|
||||
x: buttonRect.left - parentRect.left + buttonRect.width / 2, // center of button
|
||||
y: buttonRect.top - parentRect.top - 8, // above the button
|
||||
visible: true,
|
||||
});
|
||||
|
||||
setTimeout(() => setCopied(null), 1500);
|
||||
};
|
||||
|
||||
const handleCreateApiKey = () => {
|
||||
setError(undefined)
|
||||
if (input === "") {
|
||||
setError("a label must be provided")
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
createApiKey(input)
|
||||
.then(r => {
|
||||
setDisplayData([r, ...displayData])
|
||||
setInput('')
|
||||
}).catch((err) => setError(err.message))
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const handleDeleteApiKey = (id: number) => {
|
||||
setError(undefined)
|
||||
setLoading(true)
|
||||
deleteApiKey(id)
|
||||
.then(r => {
|
||||
if (r.ok) {
|
||||
setDisplayData(displayData.filter((v) => v.id != id))
|
||||
} else {
|
||||
r.json().then((r) => setError(r.error))
|
||||
}
|
||||
})
|
||||
setLoading(false)
|
||||
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="">
|
||||
<h2>API Keys</h2>
|
||||
<div className="flex flex-col gap-4 relative">
|
||||
{displayData.map((v) => (
|
||||
<div className="flex gap-2">
|
||||
<div className="bg p-3 rounded-md flex-grow" key={v.key}>{v.key.slice(0, 8)+'...'} {v.label}</div>
|
||||
<button onClick={(e) => handleCopy(e, v.key)} className="large-button px-5 rounded-md"><Copy size={16} /></button>
|
||||
<AsyncButton loading={loading} onClick={() => handleDeleteApiKey(v.id)} confirm><Trash size={16} /></AsyncButton>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex gap-2 w-3/5">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Add a label for a new API key"
|
||||
className="mx-auto fg bg rounded-md p-3 flex-grow"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
/>
|
||||
<AsyncButton loading={loading} onClick={handleCreateApiKey}>Create</AsyncButton>
|
||||
</div>
|
||||
{err && <p className="error">{err}</p>}
|
||||
{copied?.visible && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: copied.y,
|
||||
left: copied.x,
|
||||
transform: "translate(-50%, -100%)",
|
||||
}}
|
||||
className="pointer-events-none bg-black text-white text-sm px-2 py-1 rounded shadow-lg opacity-90 animate-fade"
|
||||
>
|
||||
Copied!
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
import { deleteItem } from "api/api"
|
||||
import { AsyncButton } from "../AsyncButton"
|
||||
import { Modal } from "./Modal"
|
||||
import { useNavigate } from "react-router"
|
||||
import { useState } from "react"
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
setOpen: Function
|
||||
title: string,
|
||||
id: number,
|
||||
type: string
|
||||
}
|
||||
|
||||
export default function DeleteModal({ open, setOpen, title, id, type }: Props) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const doDelete = () => {
|
||||
setLoading(true)
|
||||
deleteItem(type.toLowerCase(), id)
|
||||
.then(r => {
|
||||
if (r.ok) {
|
||||
navigate('/')
|
||||
} else {
|
||||
console.log(r)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={open} onClose={() => setOpen(false)}>
|
||||
<h2>Delete "{title}"?</h2>
|
||||
<p>This action is irreversible!</p>
|
||||
<div className="flex flex-col mt-3 items-center">
|
||||
<AsyncButton loading={loading} onClick={doDelete}>Yes, Delete It</AsyncButton>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,90 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Modal } from "./Modal";
|
||||
import { replaceImage, search, type SearchResponse } from "api/api";
|
||||
import SearchResults from "../SearchResults";
|
||||
import { AsyncButton } from "../AsyncButton";
|
||||
|
||||
interface Props {
|
||||
type: string
|
||||
id: number
|
||||
musicbrainzId?: string
|
||||
open: boolean
|
||||
setOpen: Function
|
||||
}
|
||||
|
||||
export default function ImageReplaceModal({ musicbrainzId, type, id, open, setOpen }: Props) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [suggestedImgLoading, setSuggestedImgLoading] = useState(true)
|
||||
|
||||
const doImageReplace = (url: string) => {
|
||||
setLoading(true)
|
||||
const formData = new FormData
|
||||
formData.set(`${type.toLowerCase()}_id`, id.toString())
|
||||
formData.set("image_url", url)
|
||||
replaceImage(formData)
|
||||
.then((r) => {
|
||||
if (r.ok) {
|
||||
window.location.reload()
|
||||
} else {
|
||||
console.log(r)
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
.catch((err) => console.log(err))
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isOpen={open} onClose={closeModal}>
|
||||
<h2>Replace Image</h2>
|
||||
<div className="flex flex-col items-center">
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
// i find my stupid a(n) logic to be a little silly so im leaving it in even if its not optimal
|
||||
placeholder={`Image URL`}
|
||||
className="w-full mx-auto fg bg rounded p-2"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
{ query != "" ?
|
||||
<div className="flex gap-2 mt-4">
|
||||
<AsyncButton loading={loading} onClick={() => doImageReplace(query)}>Submit</AsyncButton>
|
||||
</div> :
|
||||
''}
|
||||
{ type === "Album" && musicbrainzId ?
|
||||
<>
|
||||
<h3 className="mt-5">Suggested Image (Click to Apply)</h3>
|
||||
<button
|
||||
className="mt-4"
|
||||
disabled={loading}
|
||||
onClick={() => doImageReplace(`https://coverartarchive.org/release/${musicbrainzId}/front`)}
|
||||
>
|
||||
<div className={`relative`}>
|
||||
{suggestedImgLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div
|
||||
className="animate-spin rounded-full border-2 border-gray-300 border-t-transparent"
|
||||
style={{ width: 20, height: 20 }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
src={`https://coverartarchive.org/release/${musicbrainzId}/front`}
|
||||
onLoad={() => setSuggestedImgLoading(false)}
|
||||
onError={() => setSuggestedImgLoading(false)}
|
||||
className={`block w-[130px] h-auto ${suggestedImgLoading ? 'opacity-0' : 'opacity-100'} transition-opacity duration-300`} />
|
||||
</div>
|
||||
</button>
|
||||
</>
|
||||
: ''
|
||||
}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,59 @@
|
||||
import { login } from "api/api"
|
||||
import { useEffect, useState } from "react"
|
||||
import { AsyncButton } from "../AsyncButton"
|
||||
|
||||
export default function LoginForm() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [remember, setRemember] = useState(false)
|
||||
|
||||
const loginHandler = () => {
|
||||
if (username && password) {
|
||||
setLoading(true)
|
||||
login(username, password, remember)
|
||||
.then(r => {
|
||||
if (r.status >= 200 && r.status < 300) {
|
||||
window.location.reload()
|
||||
} else {
|
||||
r.json().then(r => setError(r.error))
|
||||
}
|
||||
}).catch(err => setError(err))
|
||||
setLoading(false)
|
||||
} else if (username || password) {
|
||||
setError("username and password are required")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2>Log In</h2>
|
||||
<div className="flex flex-col items-center gap-4 w-full">
|
||||
<p>Logging in gives you access to <strong>admin tools</strong>, such as updating images, merging items, deleting items, and more.</p>
|
||||
<form action="#" className="flex flex-col items-center gap-4 w-3/4" onSubmit={(e) => e.preventDefault()}>
|
||||
<input
|
||||
name="koito-username"
|
||||
type="text"
|
||||
placeholder="Username"
|
||||
className="w-full mx-auto fg bg rounded p-2"
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
name="koito-password"
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
className="w-full mx-auto fg bg rounded p-2"
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<input type="checkbox" name="koito-remember" id="koito-remember" onChange={() => setRemember(!remember)} />
|
||||
<label htmlFor="kotio-remember">Remember me</label>
|
||||
</div>
|
||||
<AsyncButton loading={loading} onClick={loginHandler}>Login</AsyncButton>
|
||||
</form>
|
||||
<p className="error">{error}</p>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,125 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Modal } from "./Modal";
|
||||
import { search, type SearchResponse } from "api/api";
|
||||
import SearchResults from "../SearchResults";
|
||||
import type { MergeFunc, MergeSearchCleanerFunc } from "~/routes/MediaItems/MediaLayout";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
setOpen: Function
|
||||
type: string
|
||||
currentId: number
|
||||
currentTitle: string
|
||||
mergeFunc: MergeFunc
|
||||
mergeCleanerFunc: MergeSearchCleanerFunc
|
||||
}
|
||||
|
||||
export default function MergeModal(props: Props) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [data, setData] = useState<SearchResponse>();
|
||||
const [debouncedQuery, setDebouncedQuery] = useState(query);
|
||||
const [mergeTarget, setMergeTarget] = useState<{title: string, id: number}>({title: '', id: 0})
|
||||
const [mergeOrderReversed, setMergeOrderReversed] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
|
||||
|
||||
const closeMergeModal = () => {
|
||||
props.setOpen(false)
|
||||
setQuery('')
|
||||
setData(undefined)
|
||||
setMergeOrderReversed(false)
|
||||
setMergeTarget({title: '', id: 0})
|
||||
}
|
||||
|
||||
const toggleSelect = ({title, id}: {title: string, id: number}) => {
|
||||
if (mergeTarget.id === 0) {
|
||||
setMergeTarget({title: title, id: id})
|
||||
} else {
|
||||
setMergeTarget({title:"", id: 0})
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
console.log(mergeTarget)
|
||||
}, [mergeTarget])
|
||||
|
||||
const doMerge = () => {
|
||||
let from, to
|
||||
if (!mergeOrderReversed) {
|
||||
from = mergeTarget
|
||||
to = {id: props.currentId, title: props.currentTitle}
|
||||
} else {
|
||||
from = {id: props.currentId, title: props.currentTitle}
|
||||
to = mergeTarget
|
||||
}
|
||||
props.mergeFunc(from.id, to.id)
|
||||
.then(r => {
|
||||
if (r.ok) {
|
||||
if (mergeOrderReversed) {
|
||||
navigate(`/${props.type.toLowerCase()}/${mergeTarget}`)
|
||||
closeMergeModal()
|
||||
} else {
|
||||
window.location.reload()
|
||||
}
|
||||
} else {
|
||||
// TODO: handle error
|
||||
console.log(r)
|
||||
}
|
||||
})
|
||||
.catch((err) => console.log(err))
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedQuery(query);
|
||||
if (query === '') {
|
||||
setData(undefined)
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedQuery) {
|
||||
search(debouncedQuery).then((r) => {
|
||||
r = props.mergeCleanerFunc(r, props.currentId)
|
||||
setData(r);
|
||||
});
|
||||
}
|
||||
}, [debouncedQuery]);
|
||||
|
||||
return (
|
||||
<Modal isOpen={props.open} onClose={closeMergeModal}>
|
||||
<h2>Merge {props.type}s</h2>
|
||||
<div className="flex flex-col items-center">
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
// i find my stupid a(n) logic to be a little silly so im leaving it in even if its not optimal
|
||||
placeholder={`Search for a${props.type.toLowerCase()[0] === 'a' ? 'n' : ''} ${props.type.toLowerCase()} to be merged into the current ${props.type.toLowerCase()}`}
|
||||
className="w-full mx-auto fg bg rounded p-2"
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<SearchResults selectorMode data={data} onSelect={toggleSelect}/>
|
||||
{ mergeTarget.id !== 0 ?
|
||||
<>
|
||||
{mergeOrderReversed ?
|
||||
<p className="mt-5"><strong>{props.currentTitle}</strong> will be merged into <strong>{mergeTarget.title}</strong></p>
|
||||
:
|
||||
<p className="mt-5"><strong>{mergeTarget.title}</strong> will be merged into <strong>{props.currentTitle}</strong></p>
|
||||
}
|
||||
<button className="hover:cursor-pointer px-5 py-2 rounded-md mt-5 bg-(--color-bg) hover:bg-(--color-bg-tertiary)" onClick={doMerge}>Merge Items</button>
|
||||
<div className="flex gap-2 mt-3">
|
||||
<input type="checkbox" name="reverse-merge-order" checked={mergeOrderReversed} onChange={() => setMergeOrderReversed(!mergeOrderReversed)} />
|
||||
<label htmlFor="reverse-merge-order">Reverse merge order</label>
|
||||
</div>
|
||||
</> :
|
||||
''}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,84 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
export function Modal({
|
||||
isOpen,
|
||||
onClose,
|
||||
children,
|
||||
maxW,
|
||||
h
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
children: React.ReactNode;
|
||||
maxW?: number;
|
||||
h?: number;
|
||||
}) {
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const [shouldRender, setShouldRender] = useState(isOpen);
|
||||
const [isClosing, setIsClosing] = useState(false);
|
||||
|
||||
// Show/hide logic
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setShouldRender(true);
|
||||
setIsClosing(false);
|
||||
} else if (shouldRender) {
|
||||
setIsClosing(true);
|
||||
const timeout = setTimeout(() => {
|
||||
setShouldRender(false);
|
||||
}, 100); // Match fade-out duration
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [isOpen, shouldRender]);
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
if (isOpen) document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (
|
||||
modalRef.current &&
|
||||
!modalRef.current.contains(e.target as Node)
|
||||
) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
if (isOpen) document.addEventListener('mousedown', handleClick);
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!shouldRender) return null;
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<div
|
||||
className={`fixed inset-0 z-50 flex items-center justify-center bg-black/50 transition-opacity duration-100 ${
|
||||
isClosing ? 'animate-fade-out' : 'animate-fade-in'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
ref={modalRef}
|
||||
className={`bg-secondary rounded-lg shadow-md p-6 w-full relative max-h-3/4 overflow-y-auto transition-all duration-100 ${
|
||||
isClosing ? 'animate-fade-out-scale' : 'animate-fade-in-scale'
|
||||
}`}
|
||||
style={{ maxWidth: maxW ?? 600, height: h ?? '' }}
|
||||
>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-2 right-2 color-fg-tertiary hover:cursor-pointer"
|
||||
>
|
||||
🞪
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,124 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createAlias, deleteAlias, getAliases, setPrimaryAlias, type Alias } from "api/api";
|
||||
import { Modal } from "./Modal";
|
||||
import { AsyncButton } from "../AsyncButton";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Trash } from "lucide-react";
|
||||
|
||||
interface Props {
|
||||
type: string
|
||||
id: number
|
||||
open: boolean
|
||||
setOpen: Function
|
||||
}
|
||||
|
||||
export default function RenameModal({ open, setOpen, type, id }: Props) {
|
||||
const [input, setInput] = useState('')
|
||||
const [loading, setLoading ] = useState(false)
|
||||
const [err, setError ] = useState<string>()
|
||||
const [displayData, setDisplayData] = useState<Alias[]>([])
|
||||
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
queryKey: [
|
||||
'aliases',
|
||||
{
|
||||
type: type,
|
||||
id: id
|
||||
},
|
||||
],
|
||||
queryFn: ({ queryKey }) => {
|
||||
const params = queryKey[1] as { type: string; id: number };
|
||||
return getAliases(params.type, params.id);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setDisplayData(data)
|
||||
}
|
||||
}, [data])
|
||||
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<p className="error">Error: {error.message}</p>
|
||||
)
|
||||
}
|
||||
if (isPending) {
|
||||
return (
|
||||
<p>Loading...</p>
|
||||
)
|
||||
}
|
||||
const handleSetPrimary = (alias: string) => {
|
||||
setError(undefined)
|
||||
setLoading(true)
|
||||
setPrimaryAlias(type, id, alias)
|
||||
.then(r => {
|
||||
if (r.ok) {
|
||||
window.location.reload()
|
||||
} else {
|
||||
r.json().then((r) => setError(r.error))
|
||||
}
|
||||
})
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const handleNewAlias = () => {
|
||||
setError(undefined)
|
||||
if (input === "") {
|
||||
setError("alias must be provided")
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
createAlias(type, id, input)
|
||||
.then(r => {
|
||||
if (r.ok) {
|
||||
setDisplayData([...displayData, {alias: input, source: "Manual", is_primary: false, id: id}])
|
||||
} else {
|
||||
r.json().then((r) => setError(r.error))
|
||||
}
|
||||
})
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const handleDeleteAlias = (alias: string) => {
|
||||
setError(undefined)
|
||||
setLoading(true)
|
||||
deleteAlias(type, id, alias)
|
||||
.then(r => {
|
||||
if (r.ok) {
|
||||
setDisplayData(displayData.filter((v) => v.alias != alias))
|
||||
} else {
|
||||
r.json().then((r) => setError(r.error))
|
||||
}
|
||||
})
|
||||
setLoading(false)
|
||||
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal maxW={1000} isOpen={open} onClose={() => setOpen(false)}>
|
||||
<h2>Alias Manager</h2>
|
||||
<div className="flex flex-col gap-4">
|
||||
{displayData.map((v) => (
|
||||
<div className="flex gap-2">
|
||||
<div className="bg p-3 rounded-md flex-grow" key={v.alias}>{v.alias} (source: {v.source})</div>
|
||||
<AsyncButton loading={loading} onClick={() => handleSetPrimary(v.alias)} disabled={v.is_primary}>Set Primary</AsyncButton>
|
||||
<AsyncButton loading={loading} onClick={() => handleDeleteAlias(v.alias)} confirm disabled={v.is_primary}><Trash size={16} /></AsyncButton>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex gap-2 w-3/5">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Add a new alias"
|
||||
className="mx-auto fg bg rounded-md p-3 flex-grow"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
/>
|
||||
<AsyncButton loading={loading} onClick={handleNewAlias}>Submit</AsyncButton>
|
||||
</div>
|
||||
{err && <p className="error">{err}</p>}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,60 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Modal } from "./Modal";
|
||||
import { search, type SearchResponse } from "api/api";
|
||||
import SearchResults from "../SearchResults";
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
setOpen: Function
|
||||
}
|
||||
|
||||
export default function SearchModal({ open, setOpen }: Props) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [data, setData] = useState<SearchResponse>();
|
||||
const [debouncedQuery, setDebouncedQuery] = useState(query);
|
||||
|
||||
const closeSearchModal = () => {
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
setData(undefined)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedQuery(query);
|
||||
if (query === '') {
|
||||
setData(undefined)
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedQuery) {
|
||||
search(debouncedQuery).then((r) => {
|
||||
setData(r);
|
||||
});
|
||||
}
|
||||
}, [debouncedQuery]);
|
||||
|
||||
return (
|
||||
<Modal isOpen={open} onClose={closeSearchModal}>
|
||||
<h2>Search</h2>
|
||||
<div className="flex flex-col items-center">
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
placeholder="Search for an artist, album, or track"
|
||||
className="w-full mx-auto fg bg rounded p-2"
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
<div className="h-3/4 w-full">
|
||||
<SearchResults data={data} onSelect={closeSearchModal}/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,41 @@
|
||||
import { Modal } from "./Modal"
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@radix-ui/react-tabs";
|
||||
import AccountPage from "./AccountPage";
|
||||
import { ThemeSwitcher } from "../themeSwitcher/ThemeSwitcher";
|
||||
import ThemeHelper from "../../routes/ThemeHelper";
|
||||
import { useAppContext } from "~/providers/AppProvider";
|
||||
import ApiKeysModal from "./ApiKeysModal";
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
setOpen: Function
|
||||
}
|
||||
|
||||
export default function SettingsModal({ open, setOpen } : Props) {
|
||||
|
||||
const { user } = useAppContext()
|
||||
|
||||
const triggerClasses = "px-4 py-2 w-full hover-bg-secondary rounded-md text-start data-[state=active]:bg-[var(--color-bg-secondary)]"
|
||||
const contentClasses = "w-full px-10 overflow-y-auto"
|
||||
|
||||
return (
|
||||
<Modal h={600} isOpen={open} onClose={() => setOpen(false)} maxW={900}>
|
||||
<Tabs defaultValue="Appearance" orientation="vertical" className="flex justify-between h-full">
|
||||
<TabsList className="w-full flex flex-col gap-1 items-start max-w-1/4 rounded-md bg p-2 grow-0">
|
||||
<TabsTrigger className={triggerClasses} value="Appearance">Appearance</TabsTrigger>
|
||||
<TabsTrigger className={triggerClasses} value="Account">Account</TabsTrigger>
|
||||
{ user && <TabsTrigger className={triggerClasses} value="API Keys">API Keys</TabsTrigger>}
|
||||
</TabsList>
|
||||
<TabsContent value="Account" className={contentClasses}>
|
||||
<AccountPage />
|
||||
</TabsContent>
|
||||
<TabsContent value="Appearance" className={contentClasses}>
|
||||
<ThemeSwitcher />
|
||||
</TabsContent>
|
||||
<TabsContent value="API Keys" className={contentClasses}>
|
||||
<ApiKeysModal />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
import { ExternalLink, Home, Info } from "lucide-react";
|
||||
import SidebarSearch from "./SidebarSearch";
|
||||
import SidebarItem from "./SidebarItem";
|
||||
import SidebarSettings from "./SidebarSettings";
|
||||
|
||||
export default function Sidebar() {
|
||||
|
||||
const iconSize = 20;
|
||||
|
||||
return (
|
||||
<div className="z-50 flex flex-col justify-between h-screen border-r-1 border-(--color-bg-tertiary) p-1 py-10 sticky left-0 top-0 bg-(--color-bg)">
|
||||
<div className="flex flex-col gap-4">
|
||||
<SidebarItem space={10} to="/" name="Home" onClick={() => {}} modal={<></>}><Home size={iconSize} /></SidebarItem>
|
||||
<SidebarSearch size={iconSize} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<SidebarItem icon keyHint={<ExternalLink size={14} />} space={22} externalLink to="https://koito.io" name="About" onClick={() => {}} modal={<></>}><Info size={iconSize} /></SidebarItem>
|
||||
<SidebarSettings size={iconSize} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,48 @@
|
||||
import React, { useState } from "react";
|
||||
import Popup from "../Popup";
|
||||
import { Link } from "react-router";
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
to?: string;
|
||||
onClick: Function;
|
||||
children: React.ReactNode;
|
||||
modal: React.ReactNode;
|
||||
keyHint?: React.ReactNode;
|
||||
space?: number
|
||||
externalLink?: boolean
|
||||
/* true if the keyhint is an icon and not text */
|
||||
icon?: boolean
|
||||
}
|
||||
|
||||
export default function SidebarItem({ externalLink, space, keyHint, name, to, children, modal, onClick, icon }: Props) {
|
||||
const classes = "hover:cursor-pointer hover:bg-(--color-bg-tertiary) transition duration-100 rounded-md p-2 inline-block";
|
||||
|
||||
const popupInner = keyHint ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{name}</span>
|
||||
{icon ?
|
||||
<div>
|
||||
{keyHint}
|
||||
</div>
|
||||
:
|
||||
<kbd className="px-1 text-sm rounded bg-(--color-bg-tertiary) text-(--color-fg) border border-[var(--color-fg)]">
|
||||
{keyHint}
|
||||
</kbd>
|
||||
}
|
||||
</div>
|
||||
) : name;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Popup position="right" space={space ?? 20} inner={popupInner}>
|
||||
{to ? (
|
||||
<Link target={externalLink ? "_blank" : ""} className={classes} to={to}>{children}</Link>
|
||||
) : (
|
||||
<a className={classes} onClick={() => onClick()}>{children}</a>
|
||||
)}
|
||||
</Popup>
|
||||
{modal}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import SidebarItem from "./SidebarItem";
|
||||
import { Search } from "lucide-react";
|
||||
import SearchModal from "../modals/SearchModal";
|
||||
|
||||
interface Props {
|
||||
size: number
|
||||
}
|
||||
|
||||
export default function SidebarSearch({ size } : Props) {
|
||||
const [open, setModalOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === '/' && !open) {
|
||||
e.preventDefault();
|
||||
setModalOpen(true);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<SidebarItem
|
||||
space={26}
|
||||
onClick={() => setModalOpen(true)}
|
||||
name="Search"
|
||||
keyHint="/"
|
||||
children={<Search size={size}/>} modal={<SearchModal open={open} setOpen={setModalOpen} />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
import { Settings2 } from "lucide-react";
|
||||
import SettingsModal from "../modals/SettingsModal";
|
||||
import SidebarItem from "./SidebarItem";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface Props {
|
||||
size: number
|
||||
}
|
||||
|
||||
export default function SidebarSettings({ size }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown= (e: KeyboardEvent) => {
|
||||
if (e.key === '\\' && !open) {
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<SidebarItem space={30} keyHint="\" name="Settings" onClick={() => setOpen(true)} modal={<SettingsModal open={open} setOpen={setOpen} />}>
|
||||
<Settings2 size={size} />
|
||||
</SidebarItem>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
interface Props {
|
||||
numItems: number
|
||||
}
|
||||
|
||||
export default function TopListSkeleton({ numItems }: Props) {
|
||||
|
||||
return (
|
||||
<div className="w-[300px]">
|
||||
{[...Array(numItems)].map(() => (
|
||||
<div className="flex items-center gap-2 mb-[4px]">
|
||||
<div className="w-[40px] h-[40px] bg-(--color-bg-tertiary) rounded"></div>
|
||||
<div>
|
||||
<div className="h-[14px] w-[150px] bg-(--color-bg-tertiary) rounded"></div>
|
||||
<div className="h-[12px] w-[60px] bg-(--color-bg-tertiary) rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
import type { Theme } from "~/providers/ThemeProvider";
|
||||
|
||||
interface Props {
|
||||
theme: Theme
|
||||
setTheme: Function
|
||||
}
|
||||
|
||||
export default function ThemeOption({ theme, setTheme }: Props) {
|
||||
|
||||
const capitalizeFirstLetter = (s: string) => {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1);
|
||||
}
|
||||
|
||||
return (
|
||||
<div onClick={() => setTheme(theme.name)} className="rounded-md p-5 hover:cursor-pointer flex gap-4 items-center border-2" style={{background: theme.bg, color: theme.fg, borderColor: theme.bgSecondary}}>
|
||||
{capitalizeFirstLetter(theme.name)}
|
||||
<div className="w-[50px] h-[30px] rounded-md" style={{background: theme.bgSecondary}}></div>
|
||||
<div className="w-[50px] h-[30px] rounded-md" style={{background: theme.fgSecondary}}></div>
|
||||
<div className="w-[50px] h-[30px] rounded-md" style={{background: theme.primary}}></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
// ThemeSwitcher.tsx
|
||||
import { useEffect } from 'react';
|
||||
import { useTheme } from '../../hooks/useTheme';
|
||||
import { themes } from '~/providers/ThemeProvider';
|
||||
import ThemeOption from './ThemeOption';
|
||||
|
||||
export function ThemeSwitcher() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem('theme');
|
||||
if (saved && saved !== theme) {
|
||||
setTheme(saved);
|
||||
} else if (!saved) {
|
||||
localStorage.setItem('theme', theme)
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (theme) {
|
||||
localStorage.setItem('theme', theme)
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2>Select Theme</h2>
|
||||
<div className="grid grid-cols-2 items-center gap-2">
|
||||
{themes.map((t) => (
|
||||
<ThemeOption setTheme={setTheme} key={t.name} theme={t} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,12 @@
|
||||
import { startTransition, StrictMode } from "react";
|
||||
import { hydrateRoot } from "react-dom/client";
|
||||
import { HydratedRouter } from "react-router/dom";
|
||||
|
||||
startTransition(() => {
|
||||
hydrateRoot(
|
||||
document,
|
||||
<StrictMode>
|
||||
<HydratedRouter />
|
||||
</StrictMode>
|
||||
);
|
||||
});
|
||||
@ -0,0 +1,70 @@
|
||||
import { PassThrough } from "node:stream";
|
||||
|
||||
import type { AppLoadContext, EntryContext } from "react-router";
|
||||
import { createReadableStreamFromReadable } from "@react-router/node";
|
||||
import { ServerRouter } from "react-router";
|
||||
import { isbot } from "isbot";
|
||||
import type { RenderToPipeableStreamOptions } from "react-dom/server";
|
||||
import { renderToPipeableStream } from "react-dom/server";
|
||||
|
||||
export const streamTimeout = 5_000;
|
||||
|
||||
export default function handleRequest(
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
routerContext: EntryContext,
|
||||
loadContext: AppLoadContext
|
||||
// If you have middleware enabled:
|
||||
// loadContext: unstable_RouterContextProvider
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let shellRendered = false;
|
||||
let userAgent = request.headers.get("user-agent");
|
||||
|
||||
// Ensure requests from bots and SPA Mode renders wait for all content to load before responding
|
||||
// https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
|
||||
let readyOption: keyof RenderToPipeableStreamOptions =
|
||||
(userAgent && isbot(userAgent)) || routerContext.isSpaMode
|
||||
? "onAllReady"
|
||||
: "onShellReady";
|
||||
|
||||
const { pipe, abort } = renderToPipeableStream(
|
||||
<ServerRouter context={routerContext} url={request.url} />,
|
||||
{
|
||||
[readyOption]() {
|
||||
shellRendered = true;
|
||||
const body = new PassThrough();
|
||||
const stream = createReadableStreamFromReadable(body);
|
||||
|
||||
responseHeaders.set("Content-Type", "text/html");
|
||||
|
||||
resolve(
|
||||
new Response(stream, {
|
||||
headers: responseHeaders,
|
||||
status: responseStatusCode,
|
||||
})
|
||||
);
|
||||
|
||||
pipe(body);
|
||||
},
|
||||
onShellError(error: unknown) {
|
||||
reject(error);
|
||||
},
|
||||
onError(error: unknown) {
|
||||
responseStatusCode = 500;
|
||||
// Log streaming rendering errors from inside the shell. Don't log
|
||||
// errors encountered during initial shell rendering since they'll
|
||||
// reject and get logged in handleDocumentRequest.
|
||||
if (shellRendered) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// Abort the rendering stream after the `streamTimeout` so it has time to
|
||||
// flush down the rejected boundaries
|
||||
setTimeout(abort, streamTimeout + 1000);
|
||||
});
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
import { useContext } from 'react';
|
||||
import { ThemeContext } from '../providers/ThemeProvider';
|
||||
|
||||
export function useTheme() {
|
||||
const context = useContext(ThemeContext);
|
||||
if (!context) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@ -0,0 +1,61 @@
|
||||
import type { User } from "api/api";
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
interface AppContextType {
|
||||
user: User | null | undefined;
|
||||
configurableHomeActivity: boolean;
|
||||
homeItems: number;
|
||||
setConfigurableHomeActivity: (value: boolean) => void;
|
||||
setHomeItems: (value: number) => void;
|
||||
setUsername: (value: string) => void;
|
||||
}
|
||||
|
||||
const AppContext = createContext<AppContextType | undefined>(undefined);
|
||||
|
||||
export const useAppContext = () => {
|
||||
const context = useContext(AppContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useAppContext must be used within an AppProvider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const AppProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const [user, setUser] = useState<User | null | undefined>(undefined);
|
||||
const [configurableHomeActivity, setConfigurableHomeActivity] = useState<boolean>(false);
|
||||
const [homeItems, setHomeItems] = useState<number>(0);
|
||||
|
||||
const setUsername = (value: string) => {
|
||||
if (!user) {
|
||||
return
|
||||
}
|
||||
setUser({...user, username: value})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/apis/web/v1/user/me")
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
data.error ? setUser(null) : setUser(data);
|
||||
})
|
||||
.catch(() => setUser(null));
|
||||
|
||||
setConfigurableHomeActivity(true);
|
||||
setHomeItems(12);
|
||||
}, []);
|
||||
|
||||
if (user === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contextValue: AppContextType = {
|
||||
user,
|
||||
configurableHomeActivity,
|
||||
homeItems,
|
||||
setConfigurableHomeActivity,
|
||||
setHomeItems,
|
||||
setUsername,
|
||||
};
|
||||
|
||||
return <AppContext.Provider value={contextValue}>{children}</AppContext.Provider>;
|
||||
};
|
||||
@ -0,0 +1,259 @@
|
||||
import { createContext, useEffect, useState, type ReactNode } from 'react';
|
||||
|
||||
// a fair number of colors aren't actually used, but i'm keeping
|
||||
// them so that I don't have to worry about colors when adding new ui elements
|
||||
export type Theme = {
|
||||
name: string,
|
||||
bg: string
|
||||
bgSecondary: string
|
||||
bgTertiary: string
|
||||
fg: string
|
||||
fgSecondary: string
|
||||
fgTertiary: string
|
||||
primary: string
|
||||
primaryDim: string
|
||||
accent: string
|
||||
accentDim: string
|
||||
error: string
|
||||
warning: string
|
||||
info: string
|
||||
success: string
|
||||
}
|
||||
|
||||
export const themes: Theme[] = [
|
||||
{
|
||||
name: "yuu",
|
||||
bg: "#161312",
|
||||
bgSecondary: "#272120",
|
||||
bgTertiary: "#382F2E",
|
||||
fg: "#faf5f4",
|
||||
fgSecondary: "#CCC7C6",
|
||||
fgTertiary: "#B0A3A1",
|
||||
primary: "#ff826d",
|
||||
primaryDim: "#CE6654",
|
||||
accent: "#464DAE",
|
||||
accentDim: "#393D74",
|
||||
error: "#FF6247",
|
||||
warning: "#FFC107",
|
||||
success: "#3ECE5F",
|
||||
info: "#41C4D8",
|
||||
},
|
||||
{
|
||||
name: "varia",
|
||||
bg: "rgb(25, 25, 29)",
|
||||
bgSecondary: "#222222",
|
||||
bgTertiary: "#333333",
|
||||
fg: "#eeeeee",
|
||||
fgSecondary: "#aaaaaa",
|
||||
fgTertiary: "#888888",
|
||||
primary: "rgb(203, 110, 240)",
|
||||
primaryDim: "#c28379",
|
||||
accent: "#f0ad0a",
|
||||
accentDim: "#d08d08",
|
||||
error: "#f44336",
|
||||
warning: "#ff9800",
|
||||
success: "#4caf50",
|
||||
info: "#2196f3",
|
||||
},
|
||||
{
|
||||
name: "midnight",
|
||||
bg: "rgb(8, 15, 24)",
|
||||
bgSecondary: "rgb(15, 27, 46)",
|
||||
bgTertiary: "rgb(15, 41, 70)",
|
||||
fg: "#dbdfe7",
|
||||
fgSecondary: "#9ea3a8",
|
||||
fgTertiary: "#74787c",
|
||||
primary: "#1a97eb",
|
||||
primaryDim: "#2680aa",
|
||||
accent: "#f0ad0a",
|
||||
accentDim: "#d08d08",
|
||||
error: "#f44336",
|
||||
warning: "#ff9800",
|
||||
success: "#4caf50",
|
||||
info: "#2196f3",
|
||||
},
|
||||
{
|
||||
name: "catppuccin",
|
||||
bg: "#1e1e2e",
|
||||
bgSecondary: "#181825",
|
||||
bgTertiary: "#11111b",
|
||||
fg: "#cdd6f4",
|
||||
fgSecondary: "#a6adc8",
|
||||
fgTertiary: "#9399b2",
|
||||
primary: "#89b4fa",
|
||||
primaryDim: "#739df0",
|
||||
accent: "#f38ba8",
|
||||
accentDim: "#d67b94",
|
||||
error: "#f38ba8",
|
||||
warning: "#f9e2af",
|
||||
success: "#a6e3a1",
|
||||
info: "#89dceb",
|
||||
},
|
||||
{
|
||||
name: "autumn",
|
||||
bg: "rgb(44, 25, 18)",
|
||||
bgSecondary: "rgb(70, 40, 18)",
|
||||
bgTertiary: "#4b2f1c",
|
||||
fg: "#fef9f3",
|
||||
fgSecondary: "#dbc6b0",
|
||||
fgTertiary: "#a3917a",
|
||||
primary: "#d97706",
|
||||
primaryDim: "#b45309",
|
||||
accent: "#8c4c28",
|
||||
accentDim: "#6b3b1f",
|
||||
error: "#d1433f",
|
||||
warning: "#e38b29",
|
||||
success: "#6b8e23",
|
||||
info: "#c084fc",
|
||||
},
|
||||
{
|
||||
name: "black",
|
||||
bg: "#000000",
|
||||
bgSecondary: "#1a1a1a",
|
||||
bgTertiary: "#2a2a2a",
|
||||
fg: "#dddddd",
|
||||
fgSecondary: "#aaaaaa",
|
||||
fgTertiary: "#888888",
|
||||
primary: "#08c08c",
|
||||
primaryDim: "#08c08c",
|
||||
accent: "#f0ad0a",
|
||||
accentDim: "#d08d08",
|
||||
error: "#f44336",
|
||||
warning: "#ff9800",
|
||||
success: "#4caf50",
|
||||
info: "#2196f3",
|
||||
},
|
||||
{
|
||||
name: "wine",
|
||||
bg: "#23181E",
|
||||
bgSecondary: "#2C1C25",
|
||||
bgTertiary: "#422A37",
|
||||
fg: "#FCE0B3",
|
||||
fgSecondary: "#C7AC81",
|
||||
fgTertiary: "#A78E64",
|
||||
primary: "#EA8A64",
|
||||
primaryDim: "#BD7255",
|
||||
accent: "#FAE99B",
|
||||
accentDim: "#C6B464",
|
||||
error: "#fca5a5",
|
||||
warning: "#fde68a",
|
||||
success: "#bbf7d0",
|
||||
info: "#bae6fd",
|
||||
},
|
||||
{
|
||||
name: "pearl",
|
||||
bg: "#FFFFFF",
|
||||
bgSecondary: "#EEEEEE",
|
||||
bgTertiary: "#E0E0E0",
|
||||
fg: "#333333",
|
||||
fgSecondary: "#555555",
|
||||
fgTertiary: "#777777",
|
||||
primary: "#007BFF",
|
||||
primaryDim: "#0056B3",
|
||||
accent: "#28A745",
|
||||
accentDim: "#1E7E34",
|
||||
error: "#DC3545",
|
||||
warning: "#FFC107",
|
||||
success: "#28A745",
|
||||
info: "#17A2B8",
|
||||
},
|
||||
{
|
||||
name: "asuka",
|
||||
bg: "#3B1212",
|
||||
bgSecondary: "#471B1B",
|
||||
bgTertiary: "#020202",
|
||||
fg: "#F1E9E6",
|
||||
fgSecondary: "#CCB6AE",
|
||||
fgTertiary: "#9F8176",
|
||||
primary: "#F1E9E6",
|
||||
primaryDim: "#CCB6AE",
|
||||
accent: "#41CE41",
|
||||
accentDim: "#3BA03B",
|
||||
error: "#DC143C",
|
||||
warning: "#FFD700",
|
||||
success: "#32CD32",
|
||||
info: "#1E90FF",
|
||||
},
|
||||
{
|
||||
name: "urim",
|
||||
bg: "#101713",
|
||||
bgSecondary: "#1B2921",
|
||||
bgTertiary: "#273B30",
|
||||
fg: "#D2E79E",
|
||||
fgSecondary: "#B4DA55",
|
||||
fgTertiary: "#7E9F2A",
|
||||
primary: "#ead500",
|
||||
primaryDim: "#C1B210",
|
||||
accent: "#28A745",
|
||||
accentDim: "#1E7E34",
|
||||
error: "#EE5237",
|
||||
warning: "#FFC107",
|
||||
success: "#28A745",
|
||||
info: "#17A2B8",
|
||||
},
|
||||
{
|
||||
name: "match",
|
||||
bg: "#071014",
|
||||
bgSecondary: "#0A181E",
|
||||
bgTertiary: "#112A34",
|
||||
fg: "#ebeaeb",
|
||||
fgSecondary: "#BDBDBD",
|
||||
fgTertiary: "#A2A2A2",
|
||||
primary: "#fda827",
|
||||
primaryDim: "#C78420",
|
||||
accent: "#277CFD",
|
||||
accentDim: "#1F60C1",
|
||||
error: "#F14426",
|
||||
warning: "#FFC107",
|
||||
success: "#28A745",
|
||||
info: "#17A2B8",
|
||||
},
|
||||
{
|
||||
name: "lemon",
|
||||
bg: "#1a171a",
|
||||
bgSecondary: "#2E272E",
|
||||
bgTertiary: "#443844",
|
||||
fg: "#E6E2DC",
|
||||
fgSecondary: "#B2ACA1",
|
||||
fgTertiary: "#968F82",
|
||||
primary: "#f5c737",
|
||||
primaryDim: "#C29D2F",
|
||||
accent: "#277CFD",
|
||||
accentDim: "#1F60C1",
|
||||
error: "#F14426",
|
||||
warning: "#FFC107",
|
||||
success: "#28A745",
|
||||
info: "#17A2B8",
|
||||
},
|
||||
];
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: string;
|
||||
setTheme: (theme: string) => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
||||
|
||||
export function ThemeProvider({
|
||||
theme: initialTheme,
|
||||
children,
|
||||
}: {
|
||||
theme: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [theme, setTheme] = useState(initialTheme);
|
||||
|
||||
useEffect(() => {
|
||||
if (theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, setTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export { ThemeContext }
|
||||
@ -0,0 +1,138 @@
|
||||
import {
|
||||
isRouteErrorResponse,
|
||||
Links,
|
||||
Meta,
|
||||
Outlet,
|
||||
Scripts,
|
||||
ScrollRestoration,
|
||||
useRouteError,
|
||||
} from "react-router";
|
||||
|
||||
import type { Route } from "./+types/root";
|
||||
import './themes.css'
|
||||
import "./app.css";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ThemeProvider } from './providers/ThemeProvider';
|
||||
import Sidebar from "./components/sidebar/Sidebar";
|
||||
import Footer from "./components/Footer";
|
||||
import { AppProvider } from "./providers/AppProvider";
|
||||
|
||||
// Create a client
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
export const links: Route.LinksFunction = () => [
|
||||
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
|
||||
{
|
||||
rel: "preconnect",
|
||||
href: "https://fonts.gstatic.com",
|
||||
crossOrigin: "anonymous",
|
||||
},
|
||||
{
|
||||
rel: "stylesheet",
|
||||
href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
|
||||
},
|
||||
];
|
||||
|
||||
export function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en" style={{backgroundColor: 'black'}}>
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<meta name="apple-mobile-web-app-title" content="Koito" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<Meta />
|
||||
<Links />
|
||||
</head>
|
||||
<body className="min-h-screen">
|
||||
{children}
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
let theme = localStorage.getItem('theme') ?? 'midnight'
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppProvider>
|
||||
<ThemeProvider theme={theme}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<div className="flex">
|
||||
<Sidebar />
|
||||
<div className="flex flex-col items-center mx-auto w-full">
|
||||
<Outlet />
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
</AppProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function HydrateFallback() {
|
||||
return null
|
||||
}
|
||||
|
||||
export function ErrorBoundary() {
|
||||
const error = useRouteError();
|
||||
let message = "Oops!";
|
||||
let details = "An unexpected error occurred.";
|
||||
let stack: string | undefined;
|
||||
|
||||
if (isRouteErrorResponse(error)) {
|
||||
message = error.status === 404 ? "404" : "Error";
|
||||
details = error.status === 404
|
||||
? "The requested page could not be found."
|
||||
: error.statusText || details;
|
||||
} else if (import.meta.env.DEV && error instanceof Error) {
|
||||
details = error.message;
|
||||
stack = error.stack;
|
||||
}
|
||||
|
||||
let theme = 'midnight'
|
||||
try {
|
||||
theme = localStorage.getItem('theme') ?? theme
|
||||
} catch(err) {
|
||||
console.log(err)
|
||||
}
|
||||
|
||||
const title = `${message} - Koito`
|
||||
|
||||
return (
|
||||
<AppProvider>
|
||||
<ThemeProvider theme={theme}>
|
||||
<title>{title}</title>
|
||||
<div className="flex">
|
||||
<Sidebar />
|
||||
<div className="w-full flex flex-col">
|
||||
<main className="pt-16 p-4 container mx-auto flex-grow">
|
||||
<div className="flex gap-4 items-end">
|
||||
<img className="w-[200px] rounded" src="../public/yuu.jpg" />
|
||||
<div>
|
||||
<h1>{message}</h1>
|
||||
<p>{details}</p>
|
||||
</div>
|
||||
</div>
|
||||
{stack && (
|
||||
<pre className="w-full p-4 overflow-x-auto">
|
||||
<code>{stack}</code>
|
||||
</pre>
|
||||
)}
|
||||
</main>
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
import { type RouteConfig, index, route } from "@react-router/dev/routes";
|
||||
|
||||
export default [
|
||||
index("routes/Home.tsx"),
|
||||
route("/artist/:id", "routes/MediaItems/Artist.tsx"),
|
||||
route("/album/:id", "routes/MediaItems/Album.tsx"),
|
||||
route("/track/:id", "routes/MediaItems/Track.tsx"),
|
||||
route("/chart/top-albums", "routes/Charts/AlbumChart.tsx"),
|
||||
route("/chart/top-artists", "routes/Charts/ArtistChart.tsx"),
|
||||
route("/chart/top-tracks", "routes/Charts/TrackChart.tsx"),
|
||||
route("/listens", "routes/Charts/Listens.tsx"),
|
||||
route("/theme-helper", "routes/ThemeHelper.tsx"),
|
||||
] satisfies RouteConfig;
|
||||
@ -0,0 +1,58 @@
|
||||
import TopItemList from "~/components/TopItemList";
|
||||
import ChartLayout from "./ChartLayout";
|
||||
import { useLoaderData, type LoaderFunctionArgs } from "react-router";
|
||||
import { type Album, type PaginatedResponse } from "api/api";
|
||||
|
||||
export async function clientLoader({ request }: LoaderFunctionArgs) {
|
||||
const url = new URL(request.url);
|
||||
const page = url.searchParams.get("page") || "0";
|
||||
url.searchParams.set('page', page)
|
||||
|
||||
const res = await fetch(
|
||||
`/apis/web/v1/top-albums?${url.searchParams.toString()}`
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Response("Failed to load top albums", { status: 500 });
|
||||
}
|
||||
|
||||
const top_albums: PaginatedResponse<Album> = await res.json();
|
||||
return { top_albums };
|
||||
}
|
||||
|
||||
export default function AlbumChart() {
|
||||
const { top_albums: initialData } = useLoaderData<{ top_albums: PaginatedResponse<Album> }>();
|
||||
|
||||
return (
|
||||
<ChartLayout
|
||||
title="Top Albums"
|
||||
initialData={initialData}
|
||||
endpoint="chart/top-albums"
|
||||
render={({ data, page, onNext, onPrev }) => (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex gap-15 mx-auto">
|
||||
<button className="default" onClick={onPrev} disabled={page <= 1}>
|
||||
Prev
|
||||
</button>
|
||||
<button className="default" onClick={onNext} disabled={!data.has_next_page}>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
<TopItemList
|
||||
separators
|
||||
data={data}
|
||||
width={600}
|
||||
type="album"
|
||||
/>
|
||||
<div className="flex gap-15 mx-auto">
|
||||
<button className="default" onClick={onPrev} disabled={page === 0}>
|
||||
Prev
|
||||
</button>
|
||||
<button className="default" onClick={onNext} disabled={!data.has_next_page}>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,58 @@
|
||||
import TopItemList from "~/components/TopItemList";
|
||||
import ChartLayout from "./ChartLayout";
|
||||
import { useLoaderData, type LoaderFunctionArgs } from "react-router";
|
||||
import { type Album, type PaginatedResponse } from "api/api";
|
||||
|
||||
export async function clientLoader({ request }: LoaderFunctionArgs) {
|
||||
const url = new URL(request.url);
|
||||
const page = url.searchParams.get("page") || "0";
|
||||
url.searchParams.set('page', page)
|
||||
|
||||
const res = await fetch(
|
||||
`/apis/web/v1/top-artists?${url.searchParams.toString()}`
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Response("Failed to load top artists", { status: 500 });
|
||||
}
|
||||
|
||||
const top_artists: PaginatedResponse<Album> = await res.json();
|
||||
return { top_artists };
|
||||
}
|
||||
|
||||
export default function Artist() {
|
||||
const { top_artists: initialData } = useLoaderData<{ top_artists: PaginatedResponse<Album> }>();
|
||||
|
||||
return (
|
||||
<ChartLayout
|
||||
title="Top Artists"
|
||||
initialData={initialData}
|
||||
endpoint="chart/top-artists"
|
||||
render={({ data, page, onNext, onPrev }) => (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex gap-15 mx-auto">
|
||||
<button className="default" onClick={onPrev} disabled={page <= 1}>
|
||||
Prev
|
||||
</button>
|
||||
<button className="default" onClick={onNext} disabled={!data.has_next_page}>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
<TopItemList
|
||||
separators
|
||||
data={data}
|
||||
width={600}
|
||||
type="artist"
|
||||
/>
|
||||
<div className="flex gap-15 mx-auto">
|
||||
<button className="default" onClick={onPrev} disabled={page <= 1}>
|
||||
Prev
|
||||
</button>
|
||||
<button className="default" onClick={onNext} disabled={!data.has_next_page}>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,262 @@
|
||||
import {
|
||||
useFetcher,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
} from "react-router"
|
||||
import { useEffect, useState } from "react"
|
||||
import { average } from "color.js"
|
||||
import { imageUrl, type PaginatedResponse } from "api/api"
|
||||
import PeriodSelector from "~/components/PeriodSelector"
|
||||
|
||||
interface ChartLayoutProps<T> {
|
||||
title: "Top Albums" | "Top Tracks" | "Top Artists" | "Last Played"
|
||||
initialData: PaginatedResponse<T>
|
||||
endpoint: string
|
||||
render: (opts: {
|
||||
data: PaginatedResponse<T>
|
||||
page: number
|
||||
onNext: () => void
|
||||
onPrev: () => void
|
||||
}) => React.ReactNode
|
||||
}
|
||||
|
||||
export default function ChartLayout<T>({
|
||||
title,
|
||||
initialData,
|
||||
endpoint,
|
||||
render,
|
||||
}: ChartLayoutProps<T>) {
|
||||
const pgTitle = `${title} - Koito`
|
||||
|
||||
const fetcher = useFetcher()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const currentParams = new URLSearchParams(location.search)
|
||||
const currentPage = parseInt(currentParams.get("page") || "1", 10)
|
||||
|
||||
const data: PaginatedResponse<T> = fetcher.data?.[endpoint]
|
||||
? fetcher.data[endpoint]
|
||||
: initialData
|
||||
|
||||
const [bgColor, setBgColor] = useState<string>("(--color-bg)")
|
||||
|
||||
useEffect(() => {
|
||||
if ((data?.items?.length ?? 0) === 0) return
|
||||
|
||||
const img = (data.items[0] as any)?.image
|
||||
if (!img) return
|
||||
|
||||
average(imageUrl(img, "small"), { amount: 1 }).then((color) => {
|
||||
setBgColor(`rgba(${color[0]},${color[1]},${color[2]},0.4)`)
|
||||
})
|
||||
}, [data])
|
||||
|
||||
const period = currentParams.get("period") ?? "day"
|
||||
const year = currentParams.get("year")
|
||||
const month = currentParams.get("month")
|
||||
const week = currentParams.get("week")
|
||||
|
||||
const updateParams = (params: Record<string, string | null>) => {
|
||||
const nextParams = new URLSearchParams(location.search)
|
||||
|
||||
for (const key in params) {
|
||||
const val = params[key]
|
||||
if (val !== null) {
|
||||
nextParams.set(key, val)
|
||||
} else {
|
||||
nextParams.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
const url = `/${endpoint}?${nextParams.toString()}`
|
||||
navigate(url, { replace: false })
|
||||
}
|
||||
|
||||
const handleSetPeriod = (p: string) => {
|
||||
updateParams({
|
||||
period: p,
|
||||
page: "1",
|
||||
year: null,
|
||||
month: null,
|
||||
week: null,
|
||||
})
|
||||
}
|
||||
const handleSetYear = (val: string) => {
|
||||
if (val == "") {
|
||||
updateParams({
|
||||
period: period,
|
||||
page: "1",
|
||||
year: null,
|
||||
month: null,
|
||||
week: null
|
||||
})
|
||||
return
|
||||
}
|
||||
updateParams({
|
||||
period: null,
|
||||
page: "1",
|
||||
year: val,
|
||||
})
|
||||
}
|
||||
const handleSetMonth = (val: string) => {
|
||||
updateParams({
|
||||
period: null,
|
||||
page: "1",
|
||||
year: year ?? new Date().getFullYear().toString(),
|
||||
month: val,
|
||||
})
|
||||
}
|
||||
const handleSetWeek = (val: string) => {
|
||||
updateParams({
|
||||
period: null,
|
||||
page: "1",
|
||||
year: year ?? new Date().getFullYear().toString(),
|
||||
month: null,
|
||||
week: val,
|
||||
})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetcher.load(`/${endpoint}?${currentParams.toString()}`)
|
||||
}, [location.search])
|
||||
|
||||
const setPage = (nextPage: number) => {
|
||||
const nextParams = new URLSearchParams(location.search)
|
||||
nextParams.set("page", String(nextPage))
|
||||
const url = `/${endpoint}?${nextParams.toString()}`
|
||||
fetcher.load(url)
|
||||
navigate(url, { replace: false })
|
||||
}
|
||||
|
||||
const handleNextPage = () => setPage(currentPage + 1)
|
||||
const handlePrevPage = () => setPage(currentPage - 1)
|
||||
|
||||
const yearOptions = Array.from({ length: 10 }, (_, i) => `${new Date().getFullYear() - i}`)
|
||||
const monthOptions = Array.from({ length: 12 }, (_, i) => `${i + 1}`)
|
||||
const weekOptions = Array.from({ length: 53 }, (_, i) => `${i + 1}`)
|
||||
|
||||
const getDateRange = (): string => {
|
||||
let from: Date
|
||||
let to: Date
|
||||
|
||||
const now = new Date()
|
||||
const currentYear = now.getFullYear()
|
||||
const currentMonth = now.getMonth() // 0-indexed
|
||||
const currentDate = now.getDate()
|
||||
|
||||
if (year && month) {
|
||||
from = new Date(parseInt(year), parseInt(month) - 1, 1)
|
||||
to = new Date(from)
|
||||
to.setMonth(from.getMonth() + 1)
|
||||
to.setDate(0)
|
||||
} else if (year && week) {
|
||||
const base = new Date(parseInt(year), 0, 1) // Jan 1 of the year
|
||||
const weekNumber = parseInt(week)
|
||||
from = new Date(base)
|
||||
from.setDate(base.getDate() + (weekNumber - 1) * 7)
|
||||
to = new Date(from)
|
||||
to.setDate(from.getDate() + 6)
|
||||
} else if (year) {
|
||||
from = new Date(parseInt(year), 0, 1)
|
||||
to = new Date(parseInt(year), 11, 31)
|
||||
} else {
|
||||
switch (period) {
|
||||
case "day":
|
||||
from = new Date(now)
|
||||
to = new Date(now)
|
||||
break
|
||||
case "week":
|
||||
to = new Date(now)
|
||||
from = new Date(now)
|
||||
from.setDate(to.getDate() - 6)
|
||||
break
|
||||
case "month":
|
||||
to = new Date(now)
|
||||
from = new Date(now)
|
||||
if (currentMonth === 0) {
|
||||
from = new Date(currentYear - 1, 11, currentDate)
|
||||
} else {
|
||||
from = new Date(currentYear, currentMonth - 1, currentDate)
|
||||
}
|
||||
break
|
||||
case "year":
|
||||
to = new Date(now)
|
||||
from = new Date(currentYear - 1, currentMonth, currentDate)
|
||||
break
|
||||
case "all_time":
|
||||
return "All Time"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
const formatter = new Intl.DateTimeFormat(undefined, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})
|
||||
|
||||
return `${formatter.format(from)} - ${formatter.format(to)}`
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-full min-h-screen"
|
||||
style={{
|
||||
background: `linear-gradient(to bottom, ${bgColor}, var(--color-bg) 500px)`,
|
||||
transition: "1000",
|
||||
}}
|
||||
>
|
||||
<title>{pgTitle}</title>
|
||||
<meta property="og:title" content={pgTitle} />
|
||||
<meta name="description" content={pgTitle} />
|
||||
<div className="w-17/20 mx-auto pt-12">
|
||||
<h1>{title}</h1>
|
||||
<div className="flex items-center gap-4">
|
||||
<PeriodSelector current={period} setter={handleSetPeriod} disableCache />
|
||||
<select
|
||||
value={year ?? ""}
|
||||
onChange={(e) => handleSetYear(e.target.value)}
|
||||
className="px-2 py-1 rounded border border-gray-400"
|
||||
>
|
||||
<option value="">Year</option>
|
||||
{yearOptions.map((y) => (
|
||||
<option key={y} value={y}>{y}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={month ?? ""}
|
||||
onChange={(e) => handleSetMonth(e.target.value)}
|
||||
className="px-2 py-1 rounded border border-gray-400"
|
||||
>
|
||||
<option value="">Month</option>
|
||||
{monthOptions.map((m) => (
|
||||
<option key={m} value={m}>{m}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={week ?? ""}
|
||||
onChange={(e) => handleSetWeek(e.target.value)}
|
||||
className="px-2 py-1 rounded border border-gray-400"
|
||||
>
|
||||
<option value="">Week</option>
|
||||
{weekOptions.map((w) => (
|
||||
<option key={w} value={w}>{w}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-color-fg-secondary">{getDateRange()}</p>
|
||||
<div className="mt-20 flex mx-auto justify-between">
|
||||
{render({
|
||||
data,
|
||||
page: currentPage,
|
||||
onNext: handleNextPage,
|
||||
onPrev: handlePrevPage,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,66 @@
|
||||
import ChartLayout from "./ChartLayout";
|
||||
import { Link, useLoaderData, type LoaderFunctionArgs } from "react-router";
|
||||
import { type Album, type Listen, type PaginatedResponse } from "api/api";
|
||||
import { timeSince } from "~/utils/utils";
|
||||
import ArtistLinks from "~/components/ArtistLinks";
|
||||
|
||||
export async function clientLoader({ request }: LoaderFunctionArgs) {
|
||||
const url = new URL(request.url);
|
||||
const page = url.searchParams.get("page") || "0";
|
||||
url.searchParams.set('page', page)
|
||||
|
||||
const res = await fetch(
|
||||
`/apis/web/v1/listens?${url.searchParams.toString()}`
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Response("Failed to load top tracks", { status: 500 });
|
||||
}
|
||||
|
||||
const listens: PaginatedResponse<Album> = await res.json();
|
||||
return { listens };
|
||||
}
|
||||
|
||||
export default function Listens() {
|
||||
const { listens: initialData } = useLoaderData<{ listens: PaginatedResponse<Listen> }>();
|
||||
|
||||
return (
|
||||
<ChartLayout
|
||||
title="Last Played"
|
||||
initialData={initialData}
|
||||
endpoint="listens"
|
||||
render={({ data, page, onNext, onPrev }) => (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex gap-15 mx-auto">
|
||||
<button className="default" onClick={onPrev} disabled={page <= 1}>
|
||||
Prev
|
||||
</button>
|
||||
<button className="default" onClick={onNext} disabled={!data.has_next_page}>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
<table>
|
||||
<tbody>
|
||||
{data.items.map((item) => (
|
||||
<tr key={`last_listen_${item.time}`}>
|
||||
<td className="color-fg-tertiary pr-4 text-sm" title={new Date(item.time).toString()}>{timeSince(new Date(item.time))}</td>
|
||||
<td className="text-ellipsis overflow-hidden w-[700px]">
|
||||
<ArtistLinks artists={item.track.artists} />{' - '}
|
||||
<Link className="hover:text-(--color-fg-secondary)" to={`/track/${item.track.id}`}>{item.track.title}</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="flex gap-15 mx-auto">
|
||||
<button className="default" onClick={onPrev} disabled={page === 0}>
|
||||
Prev
|
||||
</button>
|
||||
<button className="default" onClick={onNext} disabled={!data.has_next_page}>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,58 @@
|
||||
import TopItemList from "~/components/TopItemList";
|
||||
import ChartLayout from "./ChartLayout";
|
||||
import { useLoaderData, type LoaderFunctionArgs } from "react-router";
|
||||
import { type Album, type PaginatedResponse } from "api/api";
|
||||
|
||||
export async function clientLoader({ request }: LoaderFunctionArgs) {
|
||||
const url = new URL(request.url);
|
||||
const page = url.searchParams.get("page") || "0";
|
||||
url.searchParams.set('page', page)
|
||||
|
||||
const res = await fetch(
|
||||
`/apis/web/v1/top-tracks?${url.searchParams.toString()}`
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Response("Failed to load top tracks", { status: 500 });
|
||||
}
|
||||
|
||||
const top_tracks: PaginatedResponse<Album> = await res.json();
|
||||
return { top_tracks };
|
||||
}
|
||||
|
||||
export default function TrackChart() {
|
||||
const { top_tracks: initialData } = useLoaderData<{ top_tracks: PaginatedResponse<Album> }>();
|
||||
|
||||
return (
|
||||
<ChartLayout
|
||||
title="Top Tracks"
|
||||
initialData={initialData}
|
||||
endpoint="chart/top-tracks"
|
||||
render={({ data, page, onNext, onPrev }) => (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div className="flex gap-15 mx-auto">
|
||||
<button className="default" onClick={onPrev} disabled={page <= 1}>
|
||||
Prev
|
||||
</button>
|
||||
<button className="default" onClick={onNext} disabled={!data.has_next_page}>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
<TopItemList
|
||||
separators
|
||||
data={data}
|
||||
width={600}
|
||||
type="track"
|
||||
/>
|
||||
<div className="flex gap-15 mx-auto">
|
||||
<button className="default" onClick={onPrev} disabled={page === 0}>
|
||||
Prev
|
||||
</button>
|
||||
<button className="default" onClick={onNext} disabled={!data.has_next_page}>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,41 @@
|
||||
import type { Route } from "./+types/Home";
|
||||
import TopTracks from "~/components/TopTracks";
|
||||
import LastPlays from "~/components/LastPlays";
|
||||
import ActivityGrid from "~/components/ActivityGrid";
|
||||
import TopAlbums from "~/components/TopAlbums";
|
||||
import TopArtists from "~/components/TopArtists";
|
||||
import AllTimeStats from "~/components/AllTimeStats";
|
||||
import { useState } from "react";
|
||||
import PeriodSelector from "~/components/PeriodSelector";
|
||||
import { useAppContext } from "~/providers/AppProvider";
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Koito" },
|
||||
{ name: "description", content: "Koito" },
|
||||
];
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [period, setPeriod] = useState('week')
|
||||
|
||||
const { homeItems } = useAppContext();
|
||||
|
||||
return (
|
||||
<main className="flex flex-grow justify-center pb-4">
|
||||
<div className="flex-1 flex flex-col items-center gap-16 min-h-0 mt-20">
|
||||
<div className="flex gap-20">
|
||||
<AllTimeStats />
|
||||
<ActivityGrid />
|
||||
</div>
|
||||
<PeriodSelector setter={setPeriod} current={period} />
|
||||
<div className="flex flex-wrap 2xl:gap-20 xl:gap-10 justify-around gap-5">
|
||||
<TopArtists period={period} limit={homeItems} />
|
||||
<TopAlbums period={period} limit={homeItems} />
|
||||
<TopTracks period={period} limit={homeItems} />
|
||||
<LastPlays limit={Math.floor(homeItems * 2.5)} />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,57 @@
|
||||
import { useState } from "react";
|
||||
import { useLoaderData, type LoaderFunctionArgs } from "react-router";
|
||||
import TopTracks from "~/components/TopTracks";
|
||||
import { mergeAlbums, type Album } from "api/api";
|
||||
import LastPlays from "~/components/LastPlays";
|
||||
import PeriodSelector from "~/components/PeriodSelector";
|
||||
import MediaLayout from "./MediaLayout";
|
||||
import ActivityGrid from "~/components/ActivityGrid";
|
||||
|
||||
export async function clientLoader({ params }: LoaderFunctionArgs) {
|
||||
const res = await fetch(`/apis/web/v1/album?id=${params.id}`);
|
||||
if (!res.ok) {
|
||||
throw new Response("Failed to load album", { status: 500 });
|
||||
}
|
||||
const album: Album = await res.json();
|
||||
return album;
|
||||
}
|
||||
|
||||
export default function Album() {
|
||||
const album = useLoaderData() as Album;
|
||||
const [period, setPeriod] = useState('week')
|
||||
|
||||
console.log(album)
|
||||
|
||||
return (
|
||||
<MediaLayout type="Album"
|
||||
title={album.title}
|
||||
img={album.image}
|
||||
id={album.id}
|
||||
musicbrainzId={album.musicbrainz_id}
|
||||
imgItemId={album.id}
|
||||
mergeFunc={mergeAlbums}
|
||||
mergeCleanerFunc={(r, id) => {
|
||||
r.artists = []
|
||||
r.tracks = []
|
||||
for (let i = 0; i < r.albums.length; i ++) {
|
||||
if (r.albums[i].id === id) {
|
||||
delete r.albums[i]
|
||||
}
|
||||
}
|
||||
return r
|
||||
}}
|
||||
subContent={<>
|
||||
{album.listen_count && <p>{album.listen_count} play{ album.listen_count > 1 ? 's' : ''}</p>}
|
||||
</>}
|
||||
>
|
||||
<div className="mt-10">
|
||||
<PeriodSelector setter={setPeriod} current={period} />
|
||||
</div>
|
||||
<div className="flex gap-20 mt-10">
|
||||
<LastPlays limit={30} albumId={album.id} />
|
||||
<TopTracks limit={12} period={period} albumId={album.id} />
|
||||
<ActivityGrid autoAdjust configurable albumId={album.id} />
|
||||
</div>
|
||||
</MediaLayout>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,66 @@
|
||||
import { useState } from "react";
|
||||
import { useLoaderData, type LoaderFunctionArgs } from "react-router";
|
||||
import TopTracks from "~/components/TopTracks";
|
||||
import { mergeArtists, type Artist } from "api/api";
|
||||
import LastPlays from "~/components/LastPlays";
|
||||
import PeriodSelector from "~/components/PeriodSelector";
|
||||
import MediaLayout from "./MediaLayout";
|
||||
import ArtistAlbums from "~/components/ArtistAlbums";
|
||||
import ActivityGrid from "~/components/ActivityGrid";
|
||||
|
||||
export async function clientLoader({ params }: LoaderFunctionArgs) {
|
||||
const res = await fetch(`/apis/web/v1/artist?id=${params.id}`);
|
||||
if (!res.ok) {
|
||||
throw new Response("Failed to load artist", { status: 500 });
|
||||
}
|
||||
const artist: Artist = await res.json();
|
||||
return artist;
|
||||
}
|
||||
|
||||
export default function Artist() {
|
||||
const artist = useLoaderData() as Artist;
|
||||
const [period, setPeriod] = useState('week')
|
||||
|
||||
// remove canonical name from alias list
|
||||
console.log(artist.aliases)
|
||||
let index = artist.aliases.indexOf(artist.name);
|
||||
if (index !== -1) {
|
||||
artist.aliases.splice(index, 1);
|
||||
}
|
||||
|
||||
return (
|
||||
<MediaLayout type="Artist"
|
||||
title={artist.name}
|
||||
img={artist.image}
|
||||
id={artist.id}
|
||||
musicbrainzId={artist.musicbrainz_id}
|
||||
imgItemId={artist.id}
|
||||
mergeFunc={mergeArtists}
|
||||
mergeCleanerFunc={(r, id) => {
|
||||
r.albums = []
|
||||
r.tracks = []
|
||||
for (let i = 0; i < r.artists.length; i ++) {
|
||||
if (r.artists[i].id === id) {
|
||||
delete r.artists[i]
|
||||
}
|
||||
}
|
||||
return r
|
||||
}}
|
||||
subContent={<>
|
||||
{artist.listen_count && <p>{artist.listen_count} play{ artist.listen_count > 1 ? 's' : ''}</p>}
|
||||
</>}
|
||||
>
|
||||
<div className="mt-10">
|
||||
<PeriodSelector setter={setPeriod} current={period} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-20">
|
||||
<div className="flex gap-15 mt-10 flex-wrap">
|
||||
<LastPlays limit={20} artistId={artist.id} />
|
||||
<TopTracks limit={8} period={period} artistId={artist.id} />
|
||||
<ActivityGrid configurable autoAdjust artistId={artist.id} />
|
||||
</div>
|
||||
<ArtistAlbums period={period} artistId={artist.id} name={artist.name} />
|
||||
</div>
|
||||
</MediaLayout>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,88 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { average } from "color.js";
|
||||
import { imageUrl, type SearchResponse } from "api/api";
|
||||
import ImageDropHandler from "~/components/ImageDropHandler";
|
||||
import { Edit, ImageIcon, Merge, Trash } from "lucide-react";
|
||||
import { useAppContext } from "~/providers/AppProvider";
|
||||
import MergeModal from "~/components/modals/MergeModal";
|
||||
import ImageReplaceModal from "~/components/modals/ImageReplaceModal";
|
||||
import DeleteModal from "~/components/modals/DeleteModal";
|
||||
import RenameModal from "~/components/modals/RenameModal";
|
||||
|
||||
export type MergeFunc = (from: number, to: number) => Promise<Response>
|
||||
export type MergeSearchCleanerFunc = (r: SearchResponse, id: number) => SearchResponse
|
||||
|
||||
interface Props {
|
||||
type: "Track" | "Album" | "Artist"
|
||||
title: string
|
||||
img: string
|
||||
id: number
|
||||
musicbrainzId: string
|
||||
imgItemId: number
|
||||
mergeFunc: MergeFunc
|
||||
mergeCleanerFunc: MergeSearchCleanerFunc
|
||||
children: React.ReactNode
|
||||
subContent: React.ReactNode
|
||||
}
|
||||
|
||||
export default function MediaLayout(props: Props) {
|
||||
const [bgColor, setBgColor] = useState<string>("(--color-bg)");
|
||||
const [mergeModalOpen, setMergeModalOpen] = useState(false);
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||
const [imageModalOpen, setImageModalOpen] = useState(false);
|
||||
const [renameModalOpen, setRenameModalOpen] = useState(false);
|
||||
const { user } = useAppContext();
|
||||
|
||||
useEffect(() => {
|
||||
average(imageUrl(props.img, 'small'), { amount: 1 }).then((color) => {
|
||||
setBgColor(`rgba(${color[0]},${color[1]},${color[2]},0.4)`);
|
||||
});
|
||||
}, [props.img]);
|
||||
|
||||
const replaceImageCallback = () => {
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
const title = `${props.title} - Koito`
|
||||
|
||||
return (
|
||||
<main
|
||||
className="w-full flex flex-col flex-grow"
|
||||
style={{
|
||||
background: `linear-gradient(to bottom, ${bgColor}, var(--color-bg) 50%)`,
|
||||
transition: '1000',
|
||||
}}
|
||||
>
|
||||
<ImageDropHandler itemType={props.type.toLowerCase() === 'artist' ? 'artist' : 'album'} id={props.imgItemId} onComplete={replaceImageCallback} />
|
||||
<title>{title}</title>
|
||||
<meta property="og:title" content={title} />
|
||||
<meta
|
||||
name="description"
|
||||
content={title}
|
||||
/>
|
||||
<div className="w-19/20 mx-auto pt-12">
|
||||
<div className="flex gap-8 relative">
|
||||
<img style={{zIndex: 5}} src={imageUrl(props.img, "large")} alt={props.title} className="w-sm shadow-(--color-shadow) shadow-lg" />
|
||||
<div className="flex flex-col items-start">
|
||||
<h3>{props.type}</h3>
|
||||
<h1>{props.title}</h1>
|
||||
{props.subContent}
|
||||
</div>
|
||||
{ user &&
|
||||
<div className="absolute right-1 flex gap-3 items-center">
|
||||
<button title="Rename Item" className="hover:cursor-pointer" onClick={() => setRenameModalOpen(true)}><Edit size={30} /></button>
|
||||
<button title="Replace Image" className="hover:cursor-pointer" onClick={() => setImageModalOpen(true)}><ImageIcon size={30} /></button>
|
||||
<button title="Merge Items" className="hover:cursor-pointer" onClick={() => setMergeModalOpen(true)}><Merge size={30} /></button>
|
||||
<button title="Delete Item" className="hover:cursor-pointer" onClick={() => setDeleteModalOpen(true)}><Trash size={30} /></button>
|
||||
<RenameModal open={renameModalOpen} setOpen={setRenameModalOpen} type={props.type.toLowerCase()} id={props.id}/>
|
||||
<ImageReplaceModal open={imageModalOpen} setOpen={setImageModalOpen} id={props.imgItemId} musicbrainzId={props.musicbrainzId} type={props.type === "Track" ? "Album" : props.type} />
|
||||
<MergeModal currentTitle={props.title} mergeFunc={props.mergeFunc} mergeCleanerFunc={props.mergeCleanerFunc} type={props.type} currentId={props.id} open={mergeModalOpen} setOpen={setMergeModalOpen} />
|
||||
<DeleteModal open={deleteModalOpen} setOpen={setDeleteModalOpen} title={props.title} id={props.id} type={props.type} />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
{props.children}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,59 @@
|
||||
import { useState } from "react";
|
||||
import { Link, useLoaderData, type LoaderFunctionArgs } from "react-router";
|
||||
import { mergeTracks, type Album, type Track } from "api/api";
|
||||
import LastPlays from "~/components/LastPlays";
|
||||
import PeriodSelector from "~/components/PeriodSelector";
|
||||
import MediaLayout from "./MediaLayout";
|
||||
import ActivityGrid from "~/components/ActivityGrid";
|
||||
|
||||
export async function clientLoader({ params }: LoaderFunctionArgs) {
|
||||
let res = await fetch(`/apis/web/v1/track?id=${params.id}`);
|
||||
if (!res.ok) {
|
||||
throw new Response("Failed to load track", { status: res.status });
|
||||
}
|
||||
const track: Track = await res.json();
|
||||
res = await fetch(`/apis/web/v1/album?id=${track.album_id}`)
|
||||
if (!res.ok) {
|
||||
throw new Response("Failed to load album for track", { status: res.status })
|
||||
}
|
||||
const album: Album = await res.json()
|
||||
return {track: track, album: album};
|
||||
}
|
||||
|
||||
export default function Track() {
|
||||
const { track, album } = useLoaderData();
|
||||
const [period, setPeriod] = useState('week')
|
||||
|
||||
return (
|
||||
<MediaLayout type="Track"
|
||||
title={track.title}
|
||||
img={track.image}
|
||||
id={track.id}
|
||||
musicbrainzId={album.musicbrainz_id}
|
||||
imgItemId={track.album_id}
|
||||
mergeFunc={mergeTracks}
|
||||
mergeCleanerFunc={(r, id) => {
|
||||
r.albums = []
|
||||
r.artists = []
|
||||
for (let i = 0; i < r.tracks.length; i ++) {
|
||||
if (r.tracks[i].id === id) {
|
||||
delete r.tracks[i]
|
||||
}
|
||||
}
|
||||
return r
|
||||
}}
|
||||
subContent={<div className="flex flex-col gap-4 items-start">
|
||||
<Link to={`/album/${track.album_id}`}>appears on {album.title}</Link>
|
||||
{track.listen_count && <p>{track.listen_count} play{ track.listen_count > 1 ? 's' : ''}</p>}
|
||||
</div>}
|
||||
>
|
||||
<div className="mt-10">
|
||||
<PeriodSelector setter={setPeriod} current={period} />
|
||||
</div>
|
||||
<div className="flex gap-20 mt-10">
|
||||
<LastPlays limit={20} trackId={track.id}/>
|
||||
<ActivityGrid trackId={track.id} configurable autoAdjust />
|
||||
</div>
|
||||
</MediaLayout>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
import { isRouteErrorResponse, Outlet } from "react-router";
|
||||
import Footer from "~/components/Footer";
|
||||
import type { Route } from "../+types/root";
|
||||
|
||||
export default function Root() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center mx-auto w-full">
|
||||
<Outlet />
|
||||
<Footer />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||
let message = "Oops!";
|
||||
let details = "An unexpected error occurred.";
|
||||
let stack: string | undefined;
|
||||
|
||||
if (isRouteErrorResponse(error)) {
|
||||
message = error.status === 404 ? "404" : "Error";
|
||||
details =
|
||||
error.status === 404
|
||||
? "The requested page could not be found."
|
||||
: error.statusText || details;
|
||||
} else if (import.meta.env.DEV && error && error instanceof Error) {
|
||||
details = error.message;
|
||||
stack = error.stack;
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="pt-16 p-4 container mx-auto scroll-smooth">
|
||||
<h1>{message}</h1>
|
||||
<p>{details}</p>
|
||||
{stack && (
|
||||
<pre className="w-full p-4 overflow-x-auto">
|
||||
<code>{stack}</code>
|
||||
</pre>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@ -0,0 +1,67 @@
|
||||
import { useState } from "react"
|
||||
import { useAppContext } from "~/providers/AppProvider"
|
||||
import { AsyncButton } from "../components/AsyncButton"
|
||||
import AllTimeStats from "~/components/AllTimeStats"
|
||||
import ActivityGrid from "~/components/ActivityGrid"
|
||||
import LastPlays from "~/components/LastPlays"
|
||||
import TopAlbums from "~/components/TopAlbums"
|
||||
import TopArtists from "~/components/TopArtists"
|
||||
import TopTracks from "~/components/TopTracks"
|
||||
|
||||
export default function ThemeHelper() {
|
||||
|
||||
const homeItems = 3
|
||||
|
||||
return (
|
||||
<div className="mt-10 flex flex-col gap-10 items-center">
|
||||
<div className="flex gap-5">
|
||||
<AllTimeStats />
|
||||
<ActivityGrid />
|
||||
</div>
|
||||
<div className="flex flex-wrap 2xl:gap-20 xl:gap-10 justify-around gap-5">
|
||||
<TopArtists period="all_time" limit={homeItems} />
|
||||
<TopAlbums period="all_time" limit={homeItems} />
|
||||
<TopTracks period="all_time" limit={homeItems} />
|
||||
<LastPlays limit={Math.floor(homeItems * 2.5)} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-6 bg-secondary p-10 rounded-lg">
|
||||
<div className="flex flex-col gap-4 items-center">
|
||||
<p>You're logged in as <strong>Example User</strong></p>
|
||||
<AsyncButton loading={false} onClick={() => {}}>Logout</AsyncButton>
|
||||
</div>
|
||||
<div className="flex flex gap-4">
|
||||
<input
|
||||
name="koito-update-username"
|
||||
type="text"
|
||||
placeholder="Update username"
|
||||
className="w-full mx-auto fg bg rounded p-2"
|
||||
/>
|
||||
<AsyncButton loading={false} onClick={() => {}}>Submit</AsyncButton>
|
||||
</div>
|
||||
<div className="flex flex gap-4">
|
||||
<input
|
||||
name="koito-update-password"
|
||||
type="password"
|
||||
placeholder="Update password"
|
||||
className="w-full mx-auto fg bg rounded p-2"
|
||||
/>
|
||||
<input
|
||||
name="koito-confirm-password"
|
||||
type="password"
|
||||
placeholder="Confirm password"
|
||||
className="w-full mx-auto fg bg rounded p-2"
|
||||
/>
|
||||
<AsyncButton loading={false} onClick={() => {}}>Submit</AsyncButton>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-3">
|
||||
<input type="checkbox" name="reverse-merge-order" onChange={() => {}} />
|
||||
<label htmlFor="reverse-merge-order">Example checkbox</label>
|
||||
</div>
|
||||
<p className="success">successfully displayed example text</p>
|
||||
<p className="error">this is an example of error text</p>
|
||||
<p className="info">here is an informational example</p>
|
||||
<p className="warning">heed this warning, traveller</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,432 @@
|
||||
/* Theme Definitions */
|
||||
|
||||
[data-theme="varia"]{
|
||||
/* Backgrounds */
|
||||
--color-bg:rgb(25, 25, 29);
|
||||
--color-bg-secondary: #222222;
|
||||
--color-bg-tertiary: #333333;
|
||||
|
||||
/* Foregrounds */
|
||||
--color-fg: #eeeeee;
|
||||
--color-fg-secondary: #aaaaaa;
|
||||
--color-fg-tertiary: #888888;
|
||||
|
||||
/* Accents */
|
||||
--color-primary:rgb(203, 110, 240);
|
||||
--color-primary-dim: #c28379;
|
||||
--color-accent: #f0ad0a;
|
||||
--color-accent-dim: #d08d08;
|
||||
|
||||
/* Status Colors */
|
||||
--color-error: #f44336;
|
||||
--color-warning: #ff9800;
|
||||
--color-success: #4caf50;
|
||||
--color-info: #2196f3;
|
||||
|
||||
/* Borders and Shadows */
|
||||
--color-border: var(--color-bg-tertiary);
|
||||
--color-shadow: rgba(0, 0, 0, 0.5);
|
||||
|
||||
/* Interactive Elements */
|
||||
--color-link: var(--color-primary);
|
||||
--color-link-hover: var(--color-primary-dim);
|
||||
}
|
||||
|
||||
[data-theme="wine"] {
|
||||
/* Backgrounds */
|
||||
--color-bg: #23181E;
|
||||
--color-bg-secondary: #2C1C25;
|
||||
--color-bg-tertiary: #422A37;
|
||||
|
||||
/* Foregrounds */
|
||||
--color-fg: #FCE0B3;
|
||||
--color-fg-secondary:#C7AC81;
|
||||
--color-fg-tertiary:#A78E64;
|
||||
|
||||
/* Accents */
|
||||
--color-primary: #EA8A64;
|
||||
--color-primary-dim: #BD7255;
|
||||
--color-accent: #FAE99B;
|
||||
--color-accent-dim: #C6B464;
|
||||
|
||||
/* Status Colors */
|
||||
--color-error: #fca5a5;
|
||||
--color-warning: #fde68a;
|
||||
--color-success: #bbf7d0;
|
||||
--color-info: #bae6fd;
|
||||
|
||||
/* Borders and Shadows */
|
||||
--color-border: var(--color-bg-tertiary);
|
||||
--color-shadow: rgba(0, 0, 0, 0.05);
|
||||
|
||||
/* Interactive Elements */
|
||||
--color-link: var(--color-primary);
|
||||
--color-link-hover: var(--color-primary-dim);
|
||||
}
|
||||
|
||||
[data-theme="asuka"] {
|
||||
/* Backgrounds */
|
||||
--color-bg: #3B1212;
|
||||
--color-bg-secondary: #471B1B;
|
||||
--color-bg-tertiary: #020202;
|
||||
|
||||
/* Foregrounds */
|
||||
--color-fg: #F1E9E6;
|
||||
--color-fg-secondary: #CCB6AE;
|
||||
--color-fg-tertiary: #9F8176;
|
||||
|
||||
/* Accents */
|
||||
--color-primary: #F1E9E6;
|
||||
--color-primary-dim: #CCB6AE;
|
||||
--color-accent: #41CE41;
|
||||
--color-accent-dim: #3BA03B;
|
||||
|
||||
/* Status Colors */
|
||||
--color-error: #EB97A8;
|
||||
--color-warning: #FFD700;
|
||||
--color-success: #32CD32;
|
||||
--color-info: #1E90FF;
|
||||
|
||||
/* Borders and Shadows (derived from existing colors for consistency) */
|
||||
--color-border: var(--color-bg-tertiary);
|
||||
--color-shadow: rgba(0, 0, 0, 0.1); /* Slightly more prominent shadow for contrast */
|
||||
|
||||
/* Interactive Elements */
|
||||
--color-link: var(--color-primary);
|
||||
--color-link-hover: var(--color-primary-dim);
|
||||
}
|
||||
|
||||
[data-theme="midnight"] {
|
||||
/* Backgrounds */
|
||||
--color-bg:rgb(8, 15, 24);
|
||||
--color-bg-secondary:rgb(15, 27, 46);
|
||||
--color-bg-tertiary:rgb(15, 41, 70);
|
||||
|
||||
/* Foregrounds */
|
||||
--color-fg: #dbdfe7;
|
||||
--color-fg-secondary: #9ea3a8;
|
||||
--color-fg-tertiary: #74787c;
|
||||
|
||||
/* Accents */
|
||||
--color-primary: #1a97eb;
|
||||
--color-primary-dim: #2680aa;
|
||||
--color-accent: #f0ad0a;
|
||||
--color-accent-dim: #d08d08;
|
||||
|
||||
/* Status Colors */
|
||||
--color-error: #f44336;
|
||||
--color-warning: #ff9800;
|
||||
--color-success: #4caf50;
|
||||
--color-info: #2196f3;
|
||||
|
||||
/* Borders and Shadows */
|
||||
--color-border: var(--color-bg-tertiary);
|
||||
--color-shadow: rgba(0, 0, 0, 0.5);
|
||||
|
||||
/* Interactive Elements */
|
||||
--color-link: var(--color-primary);
|
||||
--color-link-hover: var(--color-primary-dim);
|
||||
}
|
||||
|
||||
/* TODO: Adjust */
|
||||
[data-theme="catppuccin"] {
|
||||
/* Backgrounds */
|
||||
--color-bg: #1e1e2e;
|
||||
--color-bg-secondary: #181825;
|
||||
--color-bg-tertiary: #11111b;
|
||||
|
||||
/* Foregrounds */
|
||||
--color-fg: #cdd6f4;
|
||||
--color-fg-secondary: #a6adc8;
|
||||
--color-fg-tertiary: #9399b2;
|
||||
|
||||
/* Accents */
|
||||
--color-primary: #cba6f7;
|
||||
--color-primary-dim: #739df0;
|
||||
--color-accent: #f38ba8;
|
||||
--color-accent-dim: #d67b94;
|
||||
|
||||
/* Status Colors */
|
||||
--color-error: #f38ba8;
|
||||
--color-warning: #f9e2af;
|
||||
--color-success: #a6e3a1;
|
||||
--color-info: #89dceb;
|
||||
|
||||
/* Borders and Shadows */
|
||||
--color-border: var(--color-bg-tertiary);
|
||||
--color-shadow: rgba(0, 0, 0, 0.5);
|
||||
|
||||
/* Interactive Elements */
|
||||
--color-link: var(--color-primary);
|
||||
--color-link-hover: var(--color-primary-dim);
|
||||
}
|
||||
|
||||
[data-theme="pearl"] {
|
||||
/* Backgrounds */
|
||||
--color-bg: #FFFFFF;
|
||||
--color-bg-secondary: #EEEEEE;
|
||||
--color-bg-tertiary: #E0E0E0;
|
||||
|
||||
/* Foregrounds */
|
||||
--color-fg: #333333;
|
||||
--color-fg-secondary: #555555;
|
||||
--color-fg-tertiary: #777777;
|
||||
|
||||
/* Accents */
|
||||
--color-primary: #007BFF;
|
||||
--color-primary-dim: #0056B3;
|
||||
--color-accent: #28A745;
|
||||
--color-accent-dim: #1E7E34;
|
||||
|
||||
/* Status Colors */
|
||||
--color-error: #DC3545;
|
||||
--color-warning: #CE9B00;
|
||||
--color-success: #099B2B;
|
||||
--color-info: #02B3CE;
|
||||
|
||||
/* Borders and Shadows */
|
||||
--color-border: var(--color-bg-tertiary);
|
||||
--color-shadow: rgba(0, 0, 0, 0.1);
|
||||
|
||||
/* Interactive Elements */
|
||||
--color-link: var(--color-primary);
|
||||
--color-link-hover: var(--color-primary-dim);
|
||||
}
|
||||
|
||||
[data-theme="urim"] {
|
||||
/* Backgrounds */
|
||||
--color-bg: #101713;
|
||||
--color-bg-secondary: #1B2921;
|
||||
--color-bg-tertiary: #273B30;
|
||||
|
||||
/* Foregrounds */
|
||||
--color-fg: #D2E79E;
|
||||
--color-fg-secondary: #B4DA55;
|
||||
--color-fg-tertiary: #7E9F2A;
|
||||
|
||||
/* Accents */
|
||||
--color-primary: #ead500;
|
||||
--color-primary-dim: #C1B210;
|
||||
--color-accent: #28A745;
|
||||
--color-accent-dim: #1E7E34;
|
||||
|
||||
/* Status Colors */
|
||||
--color-error: #EE5237;
|
||||
--color-warning: #FFC107;
|
||||
--color-success: #28A745;
|
||||
--color-info: #17A2B8;
|
||||
|
||||
/* Borders and Shadows */
|
||||
--color-border: var(--color-bg-tertiary);
|
||||
--color-shadow: rgba(0, 0, 0, 0.1);
|
||||
|
||||
/* Interactive Elements */
|
||||
--color-link: var(--color-primary);
|
||||
--color-link-hover: var(--color-primary-dim);
|
||||
}
|
||||
|
||||
[data-theme="yuu"] {
|
||||
/* Backgrounds */
|
||||
--color-bg: #161312;
|
||||
--color-bg-secondary: #272120;
|
||||
--color-bg-tertiary: #382F2E;
|
||||
|
||||
/* Foregrounds */
|
||||
--color-fg: #faf5f4;
|
||||
--color-fg-secondary: #CCC7C6;
|
||||
--color-fg-tertiary: #B0A3A1;
|
||||
|
||||
/* Accents */
|
||||
--color-primary: #ff826d;
|
||||
--color-primary-dim: #CE6654;
|
||||
--color-accent: #464DAE;
|
||||
--color-accent-dim: #393D74;
|
||||
|
||||
/* Status Colors */
|
||||
--color-error: #FF6247;
|
||||
--color-warning: #FFC107;
|
||||
--color-success: #3ECE5F;
|
||||
--color-info: #41C4D8;
|
||||
|
||||
/* Borders and Shadows */
|
||||
--color-border: var(--color-bg-tertiary);
|
||||
--color-shadow: rgba(0, 0, 0, 0.1);
|
||||
|
||||
/* Interactive Elements */
|
||||
--color-link: var(--color-primary);
|
||||
--color-link-hover: var(--color-primary-dim);
|
||||
}
|
||||
|
||||
[data-theme="match"] {
|
||||
/* Backgrounds */
|
||||
--color-bg: #071014;
|
||||
--color-bg-secondary: #0A181E;
|
||||
--color-bg-tertiary: #112A34;
|
||||
|
||||
/* Foregrounds */
|
||||
--color-fg: #ebeaeb;
|
||||
--color-fg-secondary: #BDBDBD;
|
||||
--color-fg-tertiary: #A2A2A2;
|
||||
|
||||
/* Accents */
|
||||
--color-primary: #fda827;
|
||||
--color-primary-dim: #C78420;
|
||||
--color-accent: #277CFD;
|
||||
--color-accent-dim: #1F60C1;
|
||||
|
||||
/* Status Colors */
|
||||
--color-error: #F14426;
|
||||
--color-warning: #FFC107;
|
||||
--color-success: #28A745;
|
||||
--color-info: #17A2B8;
|
||||
|
||||
/* Borders and Shadows */
|
||||
--color-border: var(--color-bg-tertiary);
|
||||
--color-shadow: rgba(0, 0, 0, 0.1);
|
||||
|
||||
/* Interactive Elements */
|
||||
--color-link: var(--color-primary);
|
||||
--color-link-hover: var(--color-primary-dim);
|
||||
}
|
||||
|
||||
[data-theme="lemon"] {
|
||||
/* Backgrounds */
|
||||
--color-bg: #1a171a;
|
||||
--color-bg-secondary: #2E272E;
|
||||
--color-bg-tertiary: #443844;
|
||||
|
||||
/* Foregrounds */
|
||||
--color-fg: #E6E2DC;
|
||||
--color-fg-secondary: #B2ACA1;
|
||||
--color-fg-tertiary: #968F82;
|
||||
|
||||
/* Accents */
|
||||
--color-primary: #f5c737;
|
||||
--color-primary-dim: #C29D2F;
|
||||
--color-accent: #277CFD;
|
||||
--color-accent-dim: #1F60C1;
|
||||
|
||||
/* Status Colors */
|
||||
--color-error: #F14426;
|
||||
--color-warning: #FFC107;
|
||||
--color-success: #28A745;
|
||||
--color-info: #17A2B8;
|
||||
|
||||
/* Borders and Shadows */
|
||||
--color-border: var(--color-bg-tertiary);
|
||||
--color-shadow: rgba(0, 0, 0, 0.1);
|
||||
|
||||
/* Interactive Elements */
|
||||
--color-link: var(--color-primary);
|
||||
--color-link-hover: var(--color-primary-dim);
|
||||
}
|
||||
|
||||
[data-theme="autumn"] {
|
||||
/* Backgrounds */
|
||||
--color-bg:rgb(44, 25, 18);
|
||||
--color-bg-secondary:rgb(70, 40, 18);
|
||||
--color-bg-tertiary: #4b2f1c;
|
||||
|
||||
/* Foregrounds */
|
||||
--color-fg: #fef9f3;
|
||||
--color-fg-secondary: #dbc6b0;
|
||||
--color-fg-tertiary: #a3917a;
|
||||
|
||||
/* Accents */
|
||||
--color-primary: #d97706;
|
||||
--color-primary-dim: #b45309;
|
||||
--color-accent: #8c4c28;
|
||||
--color-accent-dim: #6b3b1f;
|
||||
|
||||
/* Status Colors */
|
||||
--color-error: #d1433f;
|
||||
--color-warning: #e38b29;
|
||||
--color-success: #6b8e23;
|
||||
--color-info: #c084fc;
|
||||
|
||||
/* Borders and Shadows */
|
||||
--color-border: var(--color-bg-tertiary);
|
||||
--color-shadow: rgba(0, 0, 0, 0.4);
|
||||
|
||||
/* Interactive Elements */
|
||||
--color-link: var(--color-primary);
|
||||
--color-link-hover: var(--color-primary-dim);
|
||||
}
|
||||
|
||||
[data-theme="black"] {
|
||||
/* Backgrounds */
|
||||
--color-bg: #000000;
|
||||
--color-bg-secondary: #1a1a1a;
|
||||
--color-bg-tertiary: #2a2a2a;
|
||||
|
||||
/* Foregrounds */
|
||||
--color-fg: #dddddd;
|
||||
--color-fg-secondary: #aaaaaa;
|
||||
--color-fg-tertiary: #888888;
|
||||
|
||||
/* Accents */
|
||||
--color-primary: #08c08c;
|
||||
--color-primary-dim: #08c08c;
|
||||
--color-accent: #f0ad0a;
|
||||
--color-accent-dim: #d08d08;
|
||||
|
||||
/* Status Colors */
|
||||
--color-error: #f44336;
|
||||
--color-warning: #ff9800;
|
||||
--color-success: #4caf50;
|
||||
--color-info: #2196f3;
|
||||
|
||||
/* Borders and Shadows */
|
||||
--color-border: var(--color-bg-tertiary);
|
||||
--color-shadow: rgba(0, 0, 0, 0.5);
|
||||
|
||||
/* Interactive Elements */
|
||||
--color-link: #0af0af;
|
||||
--color-link-hover: #08c08c;
|
||||
}
|
||||
|
||||
|
||||
/* Theme Helper Classes */
|
||||
|
||||
/* Foreground Text */
|
||||
.color-fg { color: var(--color-fg); }
|
||||
.color-fg-secondary { color: var(--color-fg-secondary); }
|
||||
.color-fg-tertiary { color: var(--color-fg-tertiary); }
|
||||
.hover-color-fg:hover { color: var(--color-fg); }
|
||||
.hover-color-fg-secondary:hover { color: var(--color-fg-secondary); }
|
||||
.hover-color-fg-tertiary:hover { color: var(--color-fg-tertiary); }
|
||||
|
||||
/* Backgrounds */
|
||||
.bg { background-color: var(--color-bg); }
|
||||
.bg-secondary { background-color: var(--color-bg-secondary); }
|
||||
.bg-tertiary { background-color: var(--color-bg-tertiary); }
|
||||
.hover-bg:hover { background-color: var(--color-bg); }
|
||||
.hover-bg-secondary:hover { background-color: var(--color-bg-secondary); }
|
||||
.hover-bg-tertiary:hover { background-color: var(--color-bg-tertiary); }
|
||||
|
||||
/* Borders */
|
||||
.border { border: 1px solid var(--color-border); }
|
||||
|
||||
/* Accent Colors */
|
||||
.color-primary { color: var(--color-primary); }
|
||||
.bg-primary { background-color: var(--color-primary); }
|
||||
.color-accent { color: var(--color-accent); }
|
||||
.bg-secondary-accent { background-color: var(--color-accent); }
|
||||
|
||||
/* Status Colors */
|
||||
.error { color: var(--color-error); }
|
||||
.bg-error { background-color: var(--color-error); }
|
||||
|
||||
.warning { color: var(--color-warning); }
|
||||
.bg-warning { background-color: var(--color-warning); }
|
||||
|
||||
.success { color: var(--color-success); }
|
||||
.bg-success { background-color: var(--color-success); }
|
||||
|
||||
|
||||
.info { color: var(--color-info); }
|
||||
.bg-info { background-color: var(--color-info); }
|
||||
|
||||
/* Links */
|
||||
.link { color: var(--color-link); transition: color var(--transition-speed); }
|
||||
.link:hover { color: var(--color-link-hover); }
|
||||
@ -0,0 +1,9 @@
|
||||
enum Timeframe {
|
||||
Day = 1,
|
||||
Week,
|
||||
Month,
|
||||
Year,
|
||||
AllTime,
|
||||
}
|
||||
|
||||
export default Timeframe
|
||||
@ -0,0 +1,90 @@
|
||||
import Timeframe from "~/types/timeframe"
|
||||
|
||||
const timeframeToInterval = (timeframe: Timeframe): string => {
|
||||
switch (timeframe) {
|
||||
case Timeframe.Day:
|
||||
return "1 day"
|
||||
case Timeframe.Week:
|
||||
return "1 week"
|
||||
case Timeframe.Month:
|
||||
return "1 month"
|
||||
case Timeframe.Year:
|
||||
return "1 year"
|
||||
case Timeframe.AllTime:
|
||||
return "99 years"
|
||||
}
|
||||
}
|
||||
|
||||
function timeSince(date: Date) {
|
||||
const now = new Date();
|
||||
const seconds = Math.floor((now.getTime() - date.getTime()) / 1000);
|
||||
|
||||
const intervals = [
|
||||
{ label: 'year', seconds: 31536000 },
|
||||
{ label: 'month', seconds: 2592000 },
|
||||
{ label: 'week', seconds: 604800 },
|
||||
{ label: 'day', seconds: 86400 },
|
||||
{ label: 'hour', seconds: 3600 },
|
||||
{ label: 'minute', seconds: 60 },
|
||||
{ label: 'second', seconds: 1 },
|
||||
];
|
||||
|
||||
for (const interval of intervals) {
|
||||
const count = Math.floor(seconds / interval.seconds);
|
||||
if (count >= 1) {
|
||||
return `${count} ${interval.label}${count !== 1 ? 's' : ''} ago`;
|
||||
}
|
||||
}
|
||||
|
||||
return 'just now';
|
||||
}
|
||||
|
||||
export { timeSince }
|
||||
|
||||
type hsl = {
|
||||
h: number,
|
||||
s: number,
|
||||
l: number,
|
||||
}
|
||||
|
||||
const hexToHSL = (hex: string): hsl => {
|
||||
let r = 0, g = 0, b = 0;
|
||||
hex = hex.replace('#', '');
|
||||
|
||||
if (hex.length === 3) {
|
||||
r = parseInt(hex[0] + hex[0], 16);
|
||||
g = parseInt(hex[1] + hex[1], 16);
|
||||
b = parseInt(hex[2] + hex[2], 16);
|
||||
} else if (hex.length === 6) {
|
||||
r = parseInt(hex.substring(0, 2), 16);
|
||||
g = parseInt(hex.substring(2, 4), 16);
|
||||
b = parseInt(hex.substring(4, 6), 16);
|
||||
}
|
||||
|
||||
r /= 255;
|
||||
g /= 255;
|
||||
b /= 255;
|
||||
|
||||
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
||||
let h = 0, s = 0, l = (max + min) / 2;
|
||||
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
switch (max) {
|
||||
case r: h = ((g - b) / d + (g < b ? 6 : 0)); break;
|
||||
case g: h = ((b - r) / d + 2); break;
|
||||
case b: h = ((r - g) / d + 4); break;
|
||||
}
|
||||
h /= 6;
|
||||
}
|
||||
|
||||
return {
|
||||
h: Math.round(h * 360),
|
||||
s: Math.round(s * 100),
|
||||
l: Math.round(l * 100)
|
||||
};
|
||||
};
|
||||
|
||||
export {hexToHSL}
|
||||
export type {hsl}
|
||||
@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "koito",
|
||||
"version": "v0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "react-router build",
|
||||
"dev": "react-router dev",
|
||||
"start": "react-router-serve ./build/server/index.js",
|
||||
"typecheck": "react-router typegen && tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@react-router/node": "^7.5.3",
|
||||
"@react-router/serve": "^7.5.3",
|
||||
"@tanstack/react-query": "^5.80.6",
|
||||
"color.js": "^1.2.0",
|
||||
"isbot": "^5.1.27",
|
||||
"lucide-react": "^0.513.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router": "^7.5.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-router/dev": "^7.5.3",
|
||||
"@tailwindcss/vite": "^4.1.4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"tailwindcss": "^4.1.4",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.3.3",
|
||||
"vite-tsconfig-paths": "^5.1.4"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 479 B |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 901 B |
|
After Width: | Height: | Size: 491 B |
@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "Koito",
|
||||
"short_name": "Koito",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "/web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
],
|
||||
"theme_color": "#161312",
|
||||
"background_color": "#161312",
|
||||
"display": "standalone"
|
||||
}
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 11 KiB |
@ -0,0 +1,7 @@
|
||||
import type { Config } from "@react-router/dev/config";
|
||||
|
||||
export default {
|
||||
// Config options...
|
||||
// Server-side render by default, to enable SPA mode set this to `false`
|
||||
ssr: false,
|
||||
} satisfies Config;
|
||||
@ -0,0 +1,27 @@
|
||||
{
|
||||
"include": [
|
||||
"**/*",
|
||||
"**/.server/**/*",
|
||||
"**/.client/**/*",
|
||||
".react-router/types/**/*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||
"types": ["node", "vite/client"],
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"rootDirs": [".", "./.react-router/types"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"~/*": ["./app/*"]
|
||||
},
|
||||
"esModuleInterop": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"noEmit": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
import { reactRouter } from "@react-router/dev/vite";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { defineConfig } from "vite";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
|
||||
const isDocker = process.env.BUILD_TARGET === 'docker';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), reactRouter(), tsconfigPaths()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/apis': {
|
||||
target: 'http://localhost:4110',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/images': {
|
||||
target: 'http://192.168.0.153:4110',
|
||||
changeOrigin: true,
|
||||
}
|
||||
}
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
...(isDocker
|
||||
? { 'react-dom/server': 'react-dom/server.node' }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -0,0 +1,18 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/gabehf/koito/engine"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := engine.Run(
|
||||
os.Getenv,
|
||||
os.Stdout,
|
||||
); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||