mirror of
https://github.com/gabehf/Koito.git
synced 2026-03-07 13:38:15 -08:00
feat: v0.0.10 (#23)
* feat: single SOT for themes + basic custom support * fix: adjust colors for yuu theme * feat: Allow loading of environment variables from file (#20) * feat: allow loading of environment variables from file * Panic if a file for an environment variable cannot be read * Use log.Fatalf + os.Exit instead of panic * fix: remove supurfluous call to os.Exit() --------- Co-authored-by: adaexec <nixos-git.s1pht@simplelogin.com> Co-authored-by: Gabe Farrell <90876006+gabehf@users.noreply.github.com> * chore: add pr test workflow * chore: changelog * feat: make all activity grids configurable * fix: adjust activity grid style * fix: make background gradient consistent size * revert: remove year from activity grid opts * style: adjust top item list min size to 200px * feat: add support for custom themes * fix: stabilized the order of top items * chore: update changelog * feat: native import & export * fix: use correct request body for alias requests * fix: clear input when closing edit modal * chore: changelog * docs: make endpoint clearer for some apps * feat: add ui and handler for export * fix: fix pr test workflow --------- Co-authored-by: adaexec <78047743+adaexec@users.noreply.github.com> Co-authored-by: adaexec <nixos-git.s1pht@simplelogin.com>
This commit is contained in:
parent
486f5d0269
commit
c16b557c21
51 changed files with 1754 additions and 866 deletions
32
.github/workflows/test.yml
vendored
Normal file
32
.github/workflows/test.yml
vendored
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
name: Test
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Go Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install libvips
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libvips-dev
|
||||
|
||||
- name: Verify libvips install
|
||||
run: vips --version
|
||||
|
||||
- name: Build
|
||||
run: go build -v ./...
|
||||
|
||||
- name: Test
|
||||
uses: robherley/go-test-action@v0
|
||||
20
CHANGELOG.md
20
CHANGELOG.md
|
|
@ -1,4 +1,22 @@
|
|||
# v0.0.9
|
||||
# v0.0.10
|
||||
|
||||
## Features
|
||||
- Support for custom themes added! You can find the custom theme input in the Appearance menu.
|
||||
- Allow loading environment variables from files using the _FILE suffix (#20)
|
||||
- All activity grids (calendar heatmaps) are now configurable
|
||||
- Native import and export
|
||||
|
||||
## Enhancements
|
||||
- The activity grid on the home page is now configurable
|
||||
|
||||
## Fixes
|
||||
- Sub-second precision is stripped from incoming listens to ensure they can be deleted reliably
|
||||
- Top items are now sorted by id for stability
|
||||
- Clear input when closing edit modal
|
||||
- Use correct request body for create and delete alias requests
|
||||
|
||||
## Updates
|
||||
- Adjusted colors for the "Yuu" theme
|
||||
- Themes now have a single source of truth in themes.css.ts
|
||||
- Configurable activity grids now have a re-styled, collapsible menu
|
||||
- The year option for activity grids has been removed
|
||||
|
|
@ -53,6 +53,7 @@ function getStats(period: string): Promise<Stats> {
|
|||
}
|
||||
|
||||
function search(q: string): Promise<SearchResponse> {
|
||||
q = encodeURIComponent(q)
|
||||
return fetch(`/apis/web/v1/search?q=${q}`).then(r => r.json() as Promise<SearchResponse>)
|
||||
}
|
||||
|
||||
|
|
@ -131,8 +132,12 @@ function deleteApiKey(id: number): Promise<Response> {
|
|||
})
|
||||
}
|
||||
function updateApiKeyLabel(id: number, label: string): Promise<Response> {
|
||||
return fetch(`/apis/web/v1/user/apikeys?id=${id}&label=${label}`, {
|
||||
method: "PATCH"
|
||||
const form = new URLSearchParams
|
||||
form.append('id', String(id))
|
||||
form.append('label', label)
|
||||
return fetch(`/apis/web/v1/user/apikeys`, {
|
||||
method: "PATCH",
|
||||
body: form,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -154,18 +159,30 @@ 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'
|
||||
const form = new URLSearchParams
|
||||
form.append(`${type}_id`, String(id))
|
||||
form.append('alias', alias)
|
||||
return fetch(`/apis/web/v1/aliases`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
})
|
||||
}
|
||||
function deleteAlias(type: string, id: number, alias: string): Promise<Response> {
|
||||
return fetch(`/apis/web/v1/aliases?${type}_id=${id}&alias=${alias}`, {
|
||||
method: "DELETE"
|
||||
const form = new URLSearchParams
|
||||
form.append(`${type}_id`, String(id))
|
||||
form.append('alias', alias)
|
||||
return fetch(`/apis/web/v1/aliases/delete`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
})
|
||||
}
|
||||
function setPrimaryAlias(type: string, id: number, alias: string): Promise<Response> {
|
||||
return fetch(`/apis/web/v1/aliases/primary?${type}_id=${id}&alias=${alias}`, {
|
||||
method: "POST"
|
||||
const form = new URLSearchParams
|
||||
form.append(`${type}_id`, String(id))
|
||||
form.append('alias', alias)
|
||||
return fetch(`/apis/web/v1/aliases/primary`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
})
|
||||
}
|
||||
function getAlbum(id: number): Promise<Album> {
|
||||
|
|
@ -179,6 +196,8 @@ function deleteListen(listen: Listen): Promise<Response> {
|
|||
method: "DELETE"
|
||||
})
|
||||
}
|
||||
function getExport() {
|
||||
}
|
||||
|
||||
export {
|
||||
getLastListens,
|
||||
|
|
@ -207,6 +226,7 @@ export {
|
|||
updateApiKeyLabel,
|
||||
deleteListen,
|
||||
getAlbum,
|
||||
getExport,
|
||||
}
|
||||
type Track = {
|
||||
id: number
|
||||
|
|
|
|||
|
|
@ -139,6 +139,13 @@ input[type="text"]:focus {
|
|||
outline: none;
|
||||
border: 1px solid var(--color-fg-tertiary);
|
||||
}
|
||||
textarea {
|
||||
border: 1px solid var(--color-bg);
|
||||
}
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border: 1px solid var(--color-fg-tertiary);
|
||||
}
|
||||
input[type="password"] {
|
||||
border: 1px solid var(--color-bg);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,6 @@ export default function ActivityGrid({
|
|||
albumId = 0,
|
||||
trackId = 0,
|
||||
configurable = false,
|
||||
autoAdjust = false,
|
||||
}: Props) {
|
||||
|
||||
const [color, setColor] = useState(getPrimaryColor())
|
||||
|
|
@ -111,25 +110,27 @@ export default function ActivityGrid({
|
|||
|
||||
const getDarkenAmount = (v: number, t: number): number => {
|
||||
|
||||
if (autoAdjust) {
|
||||
// really ugly way to just check if this is for all items and not a specific item.
|
||||
// is it jsut better to just pass the target in as a var? probably.
|
||||
const adjustment = artistId == albumId && albumId == trackId && trackId == 0 ? 10 : 1
|
||||
|
||||
// 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
|
||||
t = 10 * adjustment
|
||||
break;
|
||||
case 'week':
|
||||
t = 20
|
||||
t = 20 * adjustment
|
||||
break;
|
||||
case 'month':
|
||||
t = 50
|
||||
t = 50 * adjustment
|
||||
break;
|
||||
case 'year':
|
||||
t = 100
|
||||
t = 100 * adjustment
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
v = Math.min(v, t)
|
||||
if (theme === "pearl") {
|
||||
|
|
@ -142,7 +143,15 @@ export default function ActivityGrid({
|
|||
}
|
||||
}
|
||||
|
||||
return (<div className="flex flex-col items-start">
|
||||
const CHUNK_SIZE = 26 * 7;
|
||||
const chunks = [];
|
||||
|
||||
for (let i = 0; i < data.length; i += CHUNK_SIZE) {
|
||||
chunks.push(data.slice(i, i + CHUNK_SIZE));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start">
|
||||
<h2>Activity</h2>
|
||||
{configurable ? (
|
||||
<ActivityOptsSelector
|
||||
|
|
@ -151,11 +160,14 @@ export default function ActivityGrid({
|
|||
stepSetter={setStep}
|
||||
currentStep={stepState}
|
||||
/>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
<div className="w-auto grid grid-flow-col grid-rows-7 gap-[3px] md:gap-[5px]">
|
||||
{data.map((item) => (
|
||||
) : null}
|
||||
|
||||
{chunks.map((chunk, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="w-auto grid grid-flow-col grid-rows-7 gap-[3px] md:gap-[5px] mb-4"
|
||||
>
|
||||
{chunk.map((item) => (
|
||||
<div
|
||||
key={new Date(item.start_time).toString()}
|
||||
className="w-[10px] sm:w-[12px] h-[10px] sm:h-[12px]"
|
||||
|
|
@ -174,13 +186,15 @@ export default function ActivityGrid({
|
|||
? LightenDarkenColor(color, getDarkenAmount(item.listens, 100))
|
||||
: 'var(--color-bg-secondary)',
|
||||
}}
|
||||
className={`w-[10px] sm:w-[12px] h-[10px] sm:h-[12px] rounded-[2px] md:rounded-[3px] ${item.listens > 0 ? '' : 'border-[0.5px] border-(--color-bg-tertiary)'}`}
|
||||
className={`w-[10px] sm:w-[12px] h-[10px] sm:h-[12px] rounded-[2px] md:rounded-[3px] ${
|
||||
item.listens > 0 ? '' : 'border-[0.5px] border-(--color-bg-tertiary)'
|
||||
}`}
|
||||
></div>
|
||||
</Popup>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useEffect } from "react";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface Props {
|
||||
stepSetter: (value: string) => void;
|
||||
|
|
@ -15,18 +16,15 @@ export default function ActivityOptsSelector({
|
|||
currentRange,
|
||||
disableCache = false,
|
||||
}: Props) {
|
||||
const stepPeriods = ['day', 'week', 'month', 'year'];
|
||||
const rangePeriods = [105, 182, 365];
|
||||
const stepPeriods = ['day', 'week', 'month'];
|
||||
const rangePeriods = [105, 182, 364];
|
||||
const [collapsed, setCollapsed] = useState(true);
|
||||
|
||||
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 setMenuOpen = (val: boolean) => {
|
||||
setCollapsed(val)
|
||||
if (!disableCache) {
|
||||
localStorage.setItem('activity_configuring_' + window.location.pathname.split('/')[1], String(!val));
|
||||
}
|
||||
}
|
||||
|
||||
const setStep = (val: string) => {
|
||||
|
|
@ -46,53 +44,63 @@ export default function ActivityOptsSelector({
|
|||
useEffect(() => {
|
||||
if (!disableCache) {
|
||||
const cachedRange = parseInt(localStorage.getItem('activity_range_' + window.location.pathname.split('/')[1]) ?? '35');
|
||||
if (cachedRange) {
|
||||
rangeSetter(cachedRange);
|
||||
}
|
||||
if (cachedRange) rangeSetter(cachedRange);
|
||||
const cachedStep = localStorage.getItem('activity_step_' + window.location.pathname.split('/')[1]);
|
||||
if (cachedStep) {
|
||||
stepSetter(cachedStep);
|
||||
}
|
||||
if (cachedStep) stepSetter(cachedStep);
|
||||
const cachedConfiguring = localStorage.getItem('activity_configuring_' + window.location.pathname.split('/')[1]);
|
||||
if (cachedStep) setMenuOpen(cachedConfiguring !== "true");
|
||||
}
|
||||
}, []);
|
||||
|
||||
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}`}>
|
||||
<div className="relative w-full">
|
||||
<button
|
||||
className={`period-selector ${p === currentStep ? 'color-fg' : 'color-fg-secondary'} ${i !== stepPeriods.length - 1 ? 'pr-2' : ''}`}
|
||||
onClick={() => setMenuOpen(!collapsed)}
|
||||
className="absolute left-[75px] -top-9 text-muted hover:color-fg transition"
|
||||
title="Toggle options"
|
||||
>
|
||||
{collapsed ? <ChevronDown size={18} /> : <ChevronUp size={18} />}
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={`overflow-hidden transition-[max-height,opacity] duration-250 ease ${
|
||||
collapsed ? 'max-h-0 opacity-0' : 'max-h-[100px] opacity-100'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-wrap gap-4 mt-1 text-sm">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-muted">Step:</span>
|
||||
{stepPeriods.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
className={`px-1 rounded transition ${
|
||||
p === currentStep ? 'color-fg font-medium' : 'color-fg-secondary hover:color-fg'
|
||||
}`}
|
||||
onClick={() => setStep(p)}
|
||||
disabled={p === currentStep}
|
||||
>
|
||||
{stepDisplay(p)}
|
||||
{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}`}>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-muted">Range:</span>
|
||||
{rangePeriods.map((r) => (
|
||||
<button
|
||||
className={`period-selector ${r === currentRange ? 'color-fg' : 'color-fg-secondary'} ${i !== rangePeriods.length - 1 ? 'pr-2' : ''}`}
|
||||
key={r}
|
||||
className={`px-1 rounded transition ${
|
||||
r === currentRange ? 'color-fg font-medium' : 'color-fg-secondary hover:color-fg'
|
||||
}`}
|
||||
onClick={() => setRange(r)}
|
||||
disabled={r === currentRange}
|
||||
>
|
||||
{rangeDisplay(r)}
|
||||
{r}
|
||||
</button>
|
||||
<span className="color-fg-secondary">
|
||||
{i !== rangePeriods.length - 1 ? '|' : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ interface Props {
|
|||
|
||||
export default function LastPlays(props: Props) {
|
||||
const { user } = useAppContext()
|
||||
console.log(user)
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
queryKey: ['last-listens', {
|
||||
limit: props.limit,
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ interface Props<T extends Item> {
|
|||
export default function TopItemList<T extends Item>({ data, separators, type, className }: Props<T>) {
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col gap-1 ${className} min-w-[300px]`}>
|
||||
<div className={`flex flex-col gap-1 ${className} min-w-[200px]`}>
|
||||
{data.items.map((item, index) => {
|
||||
const key = `${type}-${item.id}`;
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -95,11 +95,15 @@ export default function EditModal({ open, setOpen, type, id }: Props) {
|
|||
}
|
||||
})
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false)
|
||||
setInput('')
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal maxW={1000} isOpen={open} onClose={() => setOpen(false)}>
|
||||
<Modal maxW={1000} isOpen={open} onClose={handleClose}>
|
||||
<div className="flex flex-col items-start gap-6 w-full">
|
||||
<div className="w-full">
|
||||
<h2>Alias Manager</h2>
|
||||
|
|
|
|||
45
client/app/components/modals/ExportModal.tsx
Normal file
45
client/app/components/modals/ExportModal.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { useState } from "react";
|
||||
import { AsyncButton } from "../AsyncButton";
|
||||
import { getExport } from "api/api";
|
||||
|
||||
export default function ExportModal() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleExport = () => {
|
||||
setLoading(true)
|
||||
fetch(`/apis/web/v1/export`, {
|
||||
method: "GET"
|
||||
})
|
||||
.then(res => {
|
||||
if (res.ok) {
|
||||
res.blob()
|
||||
.then(blob => {
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
a.href = url
|
||||
a.download = "koito_export.json"
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
window.URL.revokeObjectURL(url)
|
||||
setLoading(false)
|
||||
})
|
||||
} else {
|
||||
res.json().then(r => setError(r.error))
|
||||
setLoading(false)
|
||||
}
|
||||
}).catch(err => {
|
||||
setError(err)
|
||||
setLoading(false)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2>Export</h2>
|
||||
<AsyncButton loading={loading} onClick={handleExport}>Export Data</AsyncButton>
|
||||
{error && <p className="error">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -5,6 +5,8 @@ import { ThemeSwitcher } from "../themeSwitcher/ThemeSwitcher";
|
|||
import ThemeHelper from "../../routes/ThemeHelper";
|
||||
import { useAppContext } from "~/providers/AppProvider";
|
||||
import ApiKeysModal from "./ApiKeysModal";
|
||||
import { AsyncButton } from "../AsyncButton";
|
||||
import ExportModal from "./ExportModal";
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
|
|
@ -19,7 +21,7 @@ export default function SettingsModal({ open, setOpen } : Props) {
|
|||
const contentClasses = "w-full px-2 mt-8 sm:mt-0 sm:px-10 overflow-y-auto"
|
||||
|
||||
return (
|
||||
<Modal h={600} isOpen={open} onClose={() => setOpen(false)} maxW={900}>
|
||||
<Modal h={700} isOpen={open} onClose={() => setOpen(false)} maxW={900}>
|
||||
<Tabs
|
||||
defaultValue="Appearance"
|
||||
orientation="vertical" // still vertical, but layout is responsive via Tailwind
|
||||
|
|
@ -29,9 +31,12 @@ export default function SettingsModal({ open, setOpen } : Props) {
|
|||
<TabsTrigger className={triggerClasses} value="Appearance">Appearance</TabsTrigger>
|
||||
<TabsTrigger className={triggerClasses} value="Account">Account</TabsTrigger>
|
||||
{user && (
|
||||
<>
|
||||
<TabsTrigger className={triggerClasses} value="API Keys">
|
||||
API Keys
|
||||
</TabsTrigger>
|
||||
<TabsTrigger className={triggerClasses} value="Export">Export</TabsTrigger>
|
||||
</>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
|
|
@ -44,6 +49,9 @@ export default function SettingsModal({ open, setOpen } : Props) {
|
|||
<TabsContent value="API Keys" className={contentClasses}>
|
||||
<ApiKeysModal />
|
||||
</TabsContent>
|
||||
<TabsContent value="Export" className={contentClasses}>
|
||||
<ExportModal />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</Modal>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,36 +1,69 @@
|
|||
// ThemeSwitcher.tsx
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTheme } from '../../hooks/useTheme';
|
||||
import { themes } from '~/providers/ThemeProvider';
|
||||
import themes from '~/styles/themes.css';
|
||||
import ThemeOption from './ThemeOption';
|
||||
import { AsyncButton } from '../AsyncButton';
|
||||
|
||||
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)
|
||||
const initialTheme = {
|
||||
bg: "#1e1816",
|
||||
bgSecondary: "#2f2623",
|
||||
bgTertiary: "#453733",
|
||||
fg: "#f8f3ec",
|
||||
fgSecondary: "#d6ccc2",
|
||||
fgTertiary: "#b4a89c",
|
||||
primary: "#f5a97f",
|
||||
primaryDim: "#d88b65",
|
||||
accent: "#f9db6d",
|
||||
accentDim: "#d9bc55",
|
||||
error: "#e26c6a",
|
||||
warning: "#f5b851",
|
||||
success: "#8fc48f",
|
||||
info: "#87b8dd",
|
||||
}
|
||||
|
||||
const { setCustomTheme, getCustomTheme } = useTheme()
|
||||
const [custom, setCustom] = useState(JSON.stringify(getCustomTheme() ?? initialTheme, null, " "))
|
||||
|
||||
const handleCustomTheme = () => {
|
||||
console.log(custom)
|
||||
try {
|
||||
const theme = JSON.parse(custom)
|
||||
theme.name = "custom"
|
||||
setCustomTheme(theme)
|
||||
delete theme.name
|
||||
setCustom(JSON.stringify(theme, null, " "))
|
||||
console.log(theme)
|
||||
} catch(err) {
|
||||
console.log(err)
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (theme) {
|
||||
localStorage.setItem('theme', theme)
|
||||
setTheme(theme)
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='flex flex-col gap-10'>
|
||||
<div>
|
||||
<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>
|
||||
</>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Use Custom Theme</h2>
|
||||
<div className="flex flex-col items-center gap-3 bg-secondary p-5 rounded-lg">
|
||||
<textarea name="custom-theme" onChange={(e) => setCustom(e.target.value)} id="custom-theme-input" className="bg-(--color-bg) h-[450px] w-[300px] p-5 rounded-md" value={custom} />
|
||||
<AsyncButton onClick={handleCustomTheme}>Submit</AsyncButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,239 +1,34 @@
|
|||
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",
|
||||
},
|
||||
];
|
||||
import { createContext, useEffect, useState, useCallback, type ReactNode } from 'react';
|
||||
import { type Theme } from '~/styles/themes.css';
|
||||
import { themeVars } from '~/styles/vars.css';
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: string;
|
||||
setTheme: (theme: string) => void;
|
||||
setCustomTheme: (theme: Theme) => void;
|
||||
getCustomTheme: () => Theme | undefined;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
||||
|
||||
function toKebabCase(str: string) {
|
||||
return str.replace(/[A-Z]/g, m => '-' + m.toLowerCase());
|
||||
}
|
||||
|
||||
function applyCustomThemeVars(theme: Theme) {
|
||||
const root = document.documentElement;
|
||||
for (const [key, value] of Object.entries(theme)) {
|
||||
if (key === 'name') continue;
|
||||
root.style.setProperty(`--color-${toKebabCase(key)}`, value);
|
||||
}
|
||||
}
|
||||
|
||||
function clearCustomThemeVars() {
|
||||
for (const cssVar of Object.values(themeVars)) {
|
||||
document.documentElement.style.removeProperty(cssVar);
|
||||
}
|
||||
}
|
||||
|
||||
export function ThemeProvider({
|
||||
theme: initialTheme,
|
||||
children,
|
||||
|
|
@ -241,19 +36,60 @@ export function ThemeProvider({
|
|||
theme: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [theme, setTheme] = useState(initialTheme);
|
||||
const [theme, setThemeName] = useState(initialTheme);
|
||||
|
||||
const setTheme = (theme: string) => {
|
||||
setThemeName(theme)
|
||||
}
|
||||
|
||||
const setCustomTheme = useCallback((customTheme: Theme) => {
|
||||
localStorage.setItem('custom-theme', JSON.stringify(customTheme));
|
||||
applyCustomThemeVars(customTheme);
|
||||
setTheme('custom');
|
||||
}, []);
|
||||
|
||||
const getCustomTheme = (): Theme | undefined => {
|
||||
const themeStr = localStorage.getItem('custom-theme');
|
||||
if (!themeStr) {
|
||||
return undefined
|
||||
}
|
||||
try {
|
||||
let theme = JSON.parse(themeStr) as Theme
|
||||
return theme
|
||||
} catch (err) {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
const root = document.documentElement;
|
||||
|
||||
root.setAttribute('data-theme', theme);
|
||||
localStorage.setItem('theme', theme)
|
||||
console.log(theme)
|
||||
|
||||
if (theme === 'custom') {
|
||||
const saved = localStorage.getItem('custom-theme');
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved) as Theme;
|
||||
applyCustomThemeVars(parsed);
|
||||
} catch (err) {
|
||||
console.error('Invalid custom theme in localStorage', err);
|
||||
}
|
||||
} else {
|
||||
setTheme('yuu')
|
||||
}
|
||||
} else {
|
||||
clearCustomThemeVars()
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, setTheme }}>
|
||||
<ThemeContext.Provider value={{ theme, setTheme, setCustomTheme, getCustomTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export { ThemeContext }
|
||||
export { ThemeContext };
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ export default function Home() {
|
|||
<div className="flex-1 flex flex-col items-center gap-16 min-h-0 mt-20">
|
||||
<div className="flex flex-col md:flex-row gap-10 md:gap-20">
|
||||
<AllTimeStats />
|
||||
<ActivityGrid />
|
||||
<ActivityGrid configurable />
|
||||
</div>
|
||||
<PeriodSelector setter={setPeriod} current={period} />
|
||||
<div className="flex flex-wrap gap-10 2xl:gap-20 xl:gap-10 justify-between mx-5 md:gap-5">
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ export default function Album() {
|
|||
<div className="flex flex-wrap gap-20 mt-10">
|
||||
<LastPlays limit={30} albumId={album.id} />
|
||||
<TopTracks limit={12} period={period} albumId={album.id} />
|
||||
<ActivityGrid autoAdjust configurable albumId={album.id} />
|
||||
<ActivityGrid configurable albumId={album.id} />
|
||||
</div>
|
||||
</MediaLayout>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ export default function Artist() {
|
|||
<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} />
|
||||
<ActivityGrid configurable artistId={artist.id} />
|
||||
</div>
|
||||
<ArtistAlbums period={period} artistId={artist.id} name={artist.name} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ export default function MediaLayout(props: Props) {
|
|||
<main
|
||||
className="w-full flex flex-col flex-grow"
|
||||
style={{
|
||||
background: `linear-gradient(to bottom, ${bgColor}, var(--color-bg) 50%)`,
|
||||
background: `linear-gradient(to bottom, ${bgColor}, var(--color-bg) 700px)`,
|
||||
transition: '1000',
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ export default function Track() {
|
|||
</div>
|
||||
<div className="flex flex-wrap gap-20 mt-10">
|
||||
<LastPlays limit={20} trackId={track.id}/>
|
||||
<ActivityGrid trackId={track.id} configurable autoAdjust />
|
||||
<ActivityGrid trackId={track.id} configurable />
|
||||
</div>
|
||||
</MediaLayout>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,8 +7,44 @@ import LastPlays from "~/components/LastPlays"
|
|||
import TopAlbums from "~/components/TopAlbums"
|
||||
import TopArtists from "~/components/TopArtists"
|
||||
import TopTracks from "~/components/TopTracks"
|
||||
import { useTheme } from "~/hooks/useTheme"
|
||||
import { themes, type Theme } from "~/styles/themes.css"
|
||||
|
||||
export default function ThemeHelper() {
|
||||
const initialTheme = {
|
||||
name: "custom",
|
||||
bg: "#1e1816",
|
||||
bgSecondary: "#2f2623",
|
||||
bgTertiary: "#453733",
|
||||
fg: "#f8f3ec",
|
||||
fgSecondary: "#d6ccc2",
|
||||
fgTertiary: "#b4a89c",
|
||||
primary: "#f5a97f",
|
||||
primaryDim: "#d88b65",
|
||||
accent: "#f9db6d",
|
||||
accentDim: "#d9bc55",
|
||||
error: "#e26c6a",
|
||||
warning: "#f5b851",
|
||||
success: "#8fc48f",
|
||||
info: "#87b8dd",
|
||||
}
|
||||
|
||||
const [custom, setCustom] = useState(JSON.stringify(initialTheme, null, " "))
|
||||
const { setCustomTheme } = useTheme()
|
||||
|
||||
const handleCustomTheme = () => {
|
||||
console.log(custom)
|
||||
try {
|
||||
const theme = JSON.parse(custom) as Theme
|
||||
if (theme.name !== "custom") {
|
||||
throw new Error("theme name must be 'custom'")
|
||||
}
|
||||
console.log(theme)
|
||||
setCustomTheme(theme)
|
||||
} catch(err) {
|
||||
console.log(err)
|
||||
}
|
||||
}
|
||||
|
||||
const homeItems = 3
|
||||
|
||||
|
|
@ -24,9 +60,14 @@ export default function ThemeHelper() {
|
|||
<TopTracks period="all_time" limit={homeItems} />
|
||||
<LastPlays limit={Math.floor(homeItems * 2.5)} />
|
||||
</div>
|
||||
<div className="flex gap-10">
|
||||
<div className="flex flex-col items-center gap-3 bg-secondary p-5 rounded-lg">
|
||||
<textarea name="custom-theme" onChange={(e) => setCustom(e.target.value)} id="custom-theme-input" className="bg-(--color-bg) w-[300px] p-5 h-full rounded-md" value={custom} />
|
||||
<AsyncButton onClick={handleCustomTheme}>Submit</AsyncButton>
|
||||
</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>
|
||||
<p>You"re logged in as <strong>Example User</strong></p>
|
||||
<AsyncButton loading={false} onClick={() => {}}>Logout</AsyncButton>
|
||||
</div>
|
||||
<div className="flex flex gap-4">
|
||||
|
|
@ -63,5 +104,6 @@ export default function ThemeHelper() {
|
|||
<p className="warning">heed this warning, traveller</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
256
client/app/styles/themes.css.ts
Normal file
256
client/app/styles/themes.css.ts
Normal file
|
|
@ -0,0 +1,256 @@
|
|||
import { globalStyle } from "@vanilla-extract/css"
|
||||
import { themeVars } from "./vars.css"
|
||||
|
||||
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 THEME_KEYS = [
|
||||
'--color'
|
||||
]
|
||||
|
||||
export const themes: Theme[] = [
|
||||
{
|
||||
name: "yuu",
|
||||
bg: "#1e1816",
|
||||
bgSecondary: "#2f2623",
|
||||
bgTertiary: "#453733",
|
||||
fg: "#f8f3ec",
|
||||
fgSecondary: "#d6ccc2",
|
||||
fgTertiary: "#b4a89c",
|
||||
primary: "#fc9174",
|
||||
primaryDim: "#d88b65",
|
||||
accent: "#f9db6d",
|
||||
accentDim: "#d9bc55",
|
||||
error: "#e26c6a",
|
||||
warning: "#f5b851",
|
||||
success: "#8fc48f",
|
||||
info: "#87b8dd",
|
||||
},
|
||||
{
|
||||
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",
|
||||
}
|
||||
];
|
||||
|
||||
export default themes
|
||||
|
||||
themes.forEach((theme) => {
|
||||
const selector = `[data-theme="${theme.name}"]`
|
||||
|
||||
globalStyle(selector, {
|
||||
vars: {
|
||||
[themeVars.bg]: theme.bg,
|
||||
[themeVars.bgSecondary]: theme.bgSecondary,
|
||||
[themeVars.bgTertiary]: theme.bgTertiary,
|
||||
[themeVars.fg]: theme.fg,
|
||||
[themeVars.fgSecondary]: theme.fgSecondary,
|
||||
[themeVars.fgTertiary]: theme.fgTertiary,
|
||||
[themeVars.primary]: theme.primary,
|
||||
[themeVars.primaryDim]: theme.primaryDim,
|
||||
[themeVars.accent]: theme.accent,
|
||||
[themeVars.accentDim]: theme.accentDim,
|
||||
[themeVars.error]: theme.error,
|
||||
[themeVars.warning]: theme.warning,
|
||||
[themeVars.success]: theme.success,
|
||||
[themeVars.info]: theme.info,
|
||||
}
|
||||
})
|
||||
})
|
||||
16
client/app/styles/vars.css.ts
Normal file
16
client/app/styles/vars.css.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
export const themeVars = {
|
||||
bg: '--color-bg',
|
||||
bgSecondary: '--color-bg-secondary',
|
||||
bgTertiary: '--color-bg-tertiary',
|
||||
fg: '--color-fg',
|
||||
fgSecondary: '--color-fg-secondary',
|
||||
fgTertiary: '--color-fg-tertiary',
|
||||
primary: '--color-primary',
|
||||
primaryDim: '--color-primary-dim',
|
||||
accent: '--color-accent',
|
||||
accentDim: '--color-accent-dim',
|
||||
error: '--color-error',
|
||||
warning: '--color-warning',
|
||||
info: '--color-info',
|
||||
success: '--color-success',
|
||||
}
|
||||
|
|
@ -1,391 +1,5 @@
|
|||
/* 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 */
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
"@react-router/node": "^7.5.3",
|
||||
"@react-router/serve": "^7.5.3",
|
||||
"@tanstack/react-query": "^5.80.6",
|
||||
"@vanilla-extract/css": "^1.17.4",
|
||||
"color.js": "^1.2.0",
|
||||
"isbot": "^5.1.27",
|
||||
"lucide-react": "^0.513.0",
|
||||
|
|
@ -27,6 +28,7 @@
|
|||
"@types/node": "^20",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@vanilla-extract/vite-plugin": "^5.0.6",
|
||||
"tailwindcss": "^4.1.4",
|
||||
"typescript": "^5.8.3",
|
||||
"vite": "^6.3.3",
|
||||
|
|
|
|||
|
|
@ -2,11 +2,12 @@ import { reactRouter } from "@react-router/dev/vite";
|
|||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { defineConfig } from "vite";
|
||||
import tsconfigPaths from "vite-tsconfig-paths";
|
||||
import { vanillaExtractPlugin } from '@vanilla-extract/vite-plugin'
|
||||
|
||||
const isDocker = process.env.BUILD_TARGET === 'docker';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), reactRouter(), tsconfigPaths()],
|
||||
plugins: [tailwindcss(), reactRouter(), tsconfigPaths(), vanillaExtractPlugin()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/apis': {
|
||||
|
|
|
|||
227
client/yarn.lock
227
client/yarn.lock
|
|
@ -24,7 +24,7 @@
|
|||
resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.27.5.tgz#7d0658ec1a8420fc866d1df1b03bea0e79934c82"
|
||||
integrity sha512-KiRAp/VoJaWkkte84TvUd9qjdbZAdiqyvMxrGl1N6vzFogKmaLgoM3L1kgtLicp2HP5fBJS8JrZKLVIZGVJAVg==
|
||||
|
||||
"@babel/core@^7.21.8", "@babel/core@^7.23.7":
|
||||
"@babel/core@^7.21.8", "@babel/core@^7.23.7", "@babel/core@^7.23.9":
|
||||
version "7.27.4"
|
||||
resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.27.4.tgz#cc1fc55d0ce140a1828d1dd2a2eba285adbfb3ce"
|
||||
integrity sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g==
|
||||
|
|
@ -185,7 +185,7 @@
|
|||
dependencies:
|
||||
"@babel/helper-plugin-utils" "^7.27.1"
|
||||
|
||||
"@babel/plugin-syntax-typescript@^7.27.1":
|
||||
"@babel/plugin-syntax-typescript@^7.23.3", "@babel/plugin-syntax-typescript@^7.27.1":
|
||||
version "7.27.1"
|
||||
resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz#5147d29066a793450f220c63fa3a9431b7e6dd18"
|
||||
integrity sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==
|
||||
|
|
@ -222,6 +222,11 @@
|
|||
"@babel/plugin-transform-modules-commonjs" "^7.27.1"
|
||||
"@babel/plugin-transform-typescript" "^7.27.1"
|
||||
|
||||
"@babel/runtime@^7.12.5":
|
||||
version "7.27.6"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.27.6.tgz#ec4070a04d76bae8ddbb10770ba55714a417b7c6"
|
||||
integrity sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==
|
||||
|
||||
"@babel/template@^7.27.2":
|
||||
version "7.27.2"
|
||||
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.27.2.tgz#fa78ceed3c4e7b63ebf6cb39e5852fca45f6809d"
|
||||
|
|
@ -274,6 +279,11 @@
|
|||
dependencies:
|
||||
tslib "^2.4.0"
|
||||
|
||||
"@emotion/hash@^0.9.0":
|
||||
version "0.9.2"
|
||||
resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.9.2.tgz#ff9221b9f58b4dfe61e619a7788734bd63f6898b"
|
||||
integrity sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==
|
||||
|
||||
"@esbuild/aix-ppc64@0.25.5":
|
||||
version "0.25.5"
|
||||
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz#4e0f91776c2b340e75558f60552195f6fad09f18"
|
||||
|
|
@ -913,6 +923,13 @@
|
|||
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.7.tgz#4158d3105276773d5b7695cd4834b1722e4f37a8"
|
||||
integrity sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==
|
||||
|
||||
"@types/node@*":
|
||||
version "24.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-24.0.3.tgz#f935910f3eece3a3a2f8be86b96ba833dc286cab"
|
||||
integrity sha512-R4I/kzCYAdRLzfiCabn9hxWfbuHS573x+r0dJMkkzThEa7pbrcDWK+9zu3e7aBOouf+rQAciqPFMnxwr0aWgKg==
|
||||
dependencies:
|
||||
undici-types "~7.8.0"
|
||||
|
||||
"@types/node@^20":
|
||||
version "20.19.0"
|
||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.19.0.tgz#7006b097b15dfea06695c3bbdba98b268797f65b"
|
||||
|
|
@ -932,6 +949,70 @@
|
|||
dependencies:
|
||||
csstype "^3.0.2"
|
||||
|
||||
"@vanilla-extract/babel-plugin-debug-ids@^1.2.2":
|
||||
version "1.2.2"
|
||||
resolved "https://registry.yarnpkg.com/@vanilla-extract/babel-plugin-debug-ids/-/babel-plugin-debug-ids-1.2.2.tgz#0bcb26614d8c6c4c0d95f8f583d838ce71294633"
|
||||
integrity sha512-MeDWGICAF9zA/OZLOKwhoRlsUW+fiMwnfuOAqFVohL31Agj7Q/RBWAYweqjHLgFBCsdnr6XIfwjJnmb2znEWxw==
|
||||
dependencies:
|
||||
"@babel/core" "^7.23.9"
|
||||
|
||||
"@vanilla-extract/compiler@^0.2.3":
|
||||
version "0.2.3"
|
||||
resolved "https://registry.yarnpkg.com/@vanilla-extract/compiler/-/compiler-0.2.3.tgz#97c4bb989aea92ee8329f1ad0a3ec01bf3aa8479"
|
||||
integrity sha512-SFEDLbvd5rhpjhrLp9BtvvVNHNxWupiUht/yrsHQ7xfkpEn4xg45gbfma7aX9fsOpi82ebqFmowHd/g6jHDQnA==
|
||||
dependencies:
|
||||
"@vanilla-extract/css" "^1.17.4"
|
||||
"@vanilla-extract/integration" "^8.0.4"
|
||||
vite "^5.0.0 || ^6.0.0"
|
||||
vite-node "^3.2.2"
|
||||
|
||||
"@vanilla-extract/css@^1.17.4":
|
||||
version "1.17.4"
|
||||
resolved "https://registry.yarnpkg.com/@vanilla-extract/css/-/css-1.17.4.tgz#c73353992b8243e8ab140582bf6d673ebc709b0a"
|
||||
integrity sha512-m3g9nQDWPtL+sTFdtCGRMI1Vrp86Ay4PBYq1Bo7Bnchj5ElNtAJpOqD+zg+apthVA4fB7oVpMWNjwpa6ElDWFQ==
|
||||
dependencies:
|
||||
"@emotion/hash" "^0.9.0"
|
||||
"@vanilla-extract/private" "^1.0.9"
|
||||
css-what "^6.1.0"
|
||||
cssesc "^3.0.0"
|
||||
csstype "^3.0.7"
|
||||
dedent "^1.5.3"
|
||||
deep-object-diff "^1.1.9"
|
||||
deepmerge "^4.2.2"
|
||||
lru-cache "^10.4.3"
|
||||
media-query-parser "^2.0.2"
|
||||
modern-ahocorasick "^1.0.0"
|
||||
picocolors "^1.0.0"
|
||||
|
||||
"@vanilla-extract/integration@^8.0.4":
|
||||
version "8.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@vanilla-extract/integration/-/integration-8.0.4.tgz#eb176376b3b03c44713bf596cc41d6d97ba9f5d3"
|
||||
integrity sha512-cmOb7tR+g3ulKvFtSbmdw3YUyIS1d7MQqN+FcbwNhdieyno5xzUyfDCMjeWJhmCSMvZ6WlinkrOkgs6SHB+FRg==
|
||||
dependencies:
|
||||
"@babel/core" "^7.23.9"
|
||||
"@babel/plugin-syntax-typescript" "^7.23.3"
|
||||
"@vanilla-extract/babel-plugin-debug-ids" "^1.2.2"
|
||||
"@vanilla-extract/css" "^1.17.4"
|
||||
dedent "^1.5.3"
|
||||
esbuild "npm:esbuild@>=0.17.6 <0.26.0"
|
||||
eval "0.1.8"
|
||||
find-up "^5.0.0"
|
||||
javascript-stringify "^2.0.1"
|
||||
mlly "^1.4.2"
|
||||
|
||||
"@vanilla-extract/private@^1.0.9":
|
||||
version "1.0.9"
|
||||
resolved "https://registry.yarnpkg.com/@vanilla-extract/private/-/private-1.0.9.tgz#bb8aaf72d2e04439792f2e389d9b705cfe691bc0"
|
||||
integrity sha512-gT2jbfZuaaCLrAxwXbRgIhGhcXbRZCG3v4TTUnjw0EJ7ArdBRxkq4msNJkbuRkCgfIK5ATmprB5t9ljvLeFDEA==
|
||||
|
||||
"@vanilla-extract/vite-plugin@^5.0.6":
|
||||
version "5.0.6"
|
||||
resolved "https://registry.yarnpkg.com/@vanilla-extract/vite-plugin/-/vite-plugin-5.0.6.tgz#00084be8e872519dde5152d92241ad8ad1e85396"
|
||||
integrity sha512-9dSPIuxR2NULvVk9bqCoTaZz3CtfBrvo5hImWaiWCblWZXzCcD7jIg7Nbcpdz9MvytO+mNta82/qCWj1G9mEMQ==
|
||||
dependencies:
|
||||
"@vanilla-extract/compiler" "^0.2.3"
|
||||
"@vanilla-extract/integration" "^8.0.4"
|
||||
|
||||
accepts@~1.3.8:
|
||||
version "1.3.8"
|
||||
resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e"
|
||||
|
|
@ -940,6 +1021,11 @@ accepts@~1.3.8:
|
|||
mime-types "~2.1.34"
|
||||
negotiator "0.6.3"
|
||||
|
||||
acorn@^8.14.0:
|
||||
version "8.15.0"
|
||||
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816"
|
||||
integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==
|
||||
|
||||
ansi-regex@^5.0.1:
|
||||
version "5.0.1"
|
||||
resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304"
|
||||
|
|
@ -1114,6 +1200,11 @@ compression@^1.7.4:
|
|||
safe-buffer "5.2.1"
|
||||
vary "~1.1.2"
|
||||
|
||||
confbox@^0.1.8:
|
||||
version "0.1.8"
|
||||
resolved "https://registry.yarnpkg.com/confbox/-/confbox-0.1.8.tgz#820d73d3b3c82d9bd910652c5d4d599ef8ff8b06"
|
||||
integrity sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==
|
||||
|
||||
content-disposition@0.5.4:
|
||||
version "0.5.4"
|
||||
resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe"
|
||||
|
|
@ -1155,7 +1246,17 @@ cross-spawn@^7.0.6:
|
|||
shebang-command "^2.0.0"
|
||||
which "^2.0.1"
|
||||
|
||||
csstype@^3.0.2:
|
||||
css-what@^6.1.0:
|
||||
version "6.1.0"
|
||||
resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.1.0.tgz#fb5effcf76f1ddea2c81bdfaa4de44e79bac70f4"
|
||||
integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==
|
||||
|
||||
cssesc@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee"
|
||||
integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==
|
||||
|
||||
csstype@^3.0.2, csstype@^3.0.7:
|
||||
version "3.1.3"
|
||||
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81"
|
||||
integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==
|
||||
|
|
@ -1179,6 +1280,16 @@ dedent@^1.5.3:
|
|||
resolved "https://registry.yarnpkg.com/dedent/-/dedent-1.6.0.tgz#79d52d6389b1ffa67d2bcef59ba51847a9d503b2"
|
||||
integrity sha512-F1Z+5UCFpmQUzJa11agbyPVMbpgT/qA3/SKyJ1jyBgm7dUcUEa8v9JwDkerSQXfakBwFljIxhOJqGkjUwZ9FSA==
|
||||
|
||||
deep-object-diff@^1.1.9:
|
||||
version "1.1.9"
|
||||
resolved "https://registry.yarnpkg.com/deep-object-diff/-/deep-object-diff-1.1.9.tgz#6df7ef035ad6a0caa44479c536ed7b02570f4595"
|
||||
integrity sha512-Rn+RuwkmkDwCi2/oXOFS9Gsr5lJZu/yTGpK7wAaAIE75CC+LCGEZHpY6VQJa/RoJcrmaA/docWJZvYohlNkWPA==
|
||||
|
||||
deepmerge@^4.2.2:
|
||||
version "4.3.1"
|
||||
resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a"
|
||||
integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==
|
||||
|
||||
depd@2.0.0, depd@~2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df"
|
||||
|
|
@ -1273,7 +1384,7 @@ es-object-atoms@^1.0.0, es-object-atoms@^1.1.1:
|
|||
dependencies:
|
||||
es-errors "^1.3.0"
|
||||
|
||||
esbuild@^0.25.0:
|
||||
esbuild@^0.25.0, "esbuild@npm:esbuild@>=0.17.6 <0.26.0":
|
||||
version "0.25.5"
|
||||
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.5.tgz#71075054993fdfae76c66586f9b9c1f8d7edd430"
|
||||
integrity sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==
|
||||
|
|
@ -1319,6 +1430,14 @@ etag@~1.8.1:
|
|||
resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
|
||||
integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==
|
||||
|
||||
eval@0.1.8:
|
||||
version "0.1.8"
|
||||
resolved "https://registry.yarnpkg.com/eval/-/eval-0.1.8.tgz#2b903473b8cc1d1989b83a1e7923f883eb357f85"
|
||||
integrity sha512-EzV94NYKoO09GLXGjXj9JIlXijVck4ONSr5wiCWDvhsvj5jxSrzTmRU/9C1DyB6uToszLs8aifA6NQ7lEQdvFw==
|
||||
dependencies:
|
||||
"@types/node" "*"
|
||||
require-like ">= 0.1.1"
|
||||
|
||||
exit-hook@2.2.1:
|
||||
version "2.2.1"
|
||||
resolved "https://registry.yarnpkg.com/exit-hook/-/exit-hook-2.2.1.tgz#007b2d92c6428eda2b76e7016a34351586934593"
|
||||
|
|
@ -1379,6 +1498,14 @@ finalhandler@1.3.1:
|
|||
statuses "2.0.1"
|
||||
unpipe "~1.0.0"
|
||||
|
||||
find-up@^5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc"
|
||||
integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==
|
||||
dependencies:
|
||||
locate-path "^6.0.0"
|
||||
path-exists "^4.0.0"
|
||||
|
||||
foreground-child@^3.1.0:
|
||||
version "3.3.1"
|
||||
resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f"
|
||||
|
|
@ -1560,6 +1687,11 @@ jackspeak@^3.1.2:
|
|||
optionalDependencies:
|
||||
"@pkgjs/parseargs" "^0.11.0"
|
||||
|
||||
javascript-stringify@^2.0.1:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/javascript-stringify/-/javascript-stringify-2.1.0.tgz#27c76539be14d8bd128219a2d731b09337904e79"
|
||||
integrity sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg==
|
||||
|
||||
jiti@^2.4.2:
|
||||
version "2.4.2"
|
||||
resolved "https://registry.yarnpkg.com/jiti/-/jiti-2.4.2.tgz#d19b7732ebb6116b06e2038da74a55366faef560"
|
||||
|
|
@ -1667,12 +1799,19 @@ lightningcss@1.30.1:
|
|||
lightningcss-win32-arm64-msvc "1.30.1"
|
||||
lightningcss-win32-x64-msvc "1.30.1"
|
||||
|
||||
locate-path@^6.0.0:
|
||||
version "6.0.0"
|
||||
resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286"
|
||||
integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==
|
||||
dependencies:
|
||||
p-locate "^5.0.0"
|
||||
|
||||
lodash@^4.17.21:
|
||||
version "4.17.21"
|
||||
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
|
||||
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
|
||||
|
||||
lru-cache@^10.2.0:
|
||||
lru-cache@^10.2.0, lru-cache@^10.4.3:
|
||||
version "10.4.3"
|
||||
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119"
|
||||
integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==
|
||||
|
|
@ -1706,6 +1845,13 @@ math-intrinsics@^1.1.0:
|
|||
resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9"
|
||||
integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==
|
||||
|
||||
media-query-parser@^2.0.2:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/media-query-parser/-/media-query-parser-2.0.2.tgz#ff79e56cee92615a304a1c2fa4f2bd056c0a1d29"
|
||||
integrity sha512-1N4qp+jE0pL5Xv4uEcwVUhIkwdUO3S/9gML90nqKA7v7FcOS5vUtatfzok9S9U1EJU8dHWlcv95WLnKmmxZI9w==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.12.5"
|
||||
|
||||
media-typer@0.3.0:
|
||||
version "0.3.0"
|
||||
resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
|
||||
|
|
@ -1767,6 +1913,21 @@ mkdirp@^3.0.1:
|
|||
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-3.0.1.tgz#e44e4c5607fb279c168241713cc6e0fea9adcb50"
|
||||
integrity sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==
|
||||
|
||||
mlly@^1.4.2, mlly@^1.7.4:
|
||||
version "1.7.4"
|
||||
resolved "https://registry.yarnpkg.com/mlly/-/mlly-1.7.4.tgz#3d7295ea2358ec7a271eaa5d000a0f84febe100f"
|
||||
integrity sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==
|
||||
dependencies:
|
||||
acorn "^8.14.0"
|
||||
pathe "^2.0.1"
|
||||
pkg-types "^1.3.0"
|
||||
ufo "^1.5.4"
|
||||
|
||||
modern-ahocorasick@^1.0.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/modern-ahocorasick/-/modern-ahocorasick-1.1.0.tgz#9b1fa15d4f654be20a2ad7ecc44ec9d7645bb420"
|
||||
integrity sha512-sEKPVl2rM+MNVkGQt3ChdmD8YsigmXdn5NifZn6jiwn9LRJpWm8F3guhaqrJT/JOat6pwpbXEk6kv+b9DMIjsQ==
|
||||
|
||||
morgan@^1.10.0:
|
||||
version "1.10.0"
|
||||
resolved "https://registry.yarnpkg.com/morgan/-/morgan-1.10.0.tgz#091778abc1fc47cd3509824653dae1faab6b17d7"
|
||||
|
|
@ -1874,6 +2035,20 @@ on-headers@~1.0.2:
|
|||
resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.0.2.tgz#772b0ae6aaa525c399e489adfad90c403eb3c28f"
|
||||
integrity sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==
|
||||
|
||||
p-limit@^3.0.2:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b"
|
||||
integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==
|
||||
dependencies:
|
||||
yocto-queue "^0.1.0"
|
||||
|
||||
p-locate@^5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834"
|
||||
integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==
|
||||
dependencies:
|
||||
p-limit "^3.0.2"
|
||||
|
||||
package-json-from-dist@^1.0.0:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505"
|
||||
|
|
@ -1884,6 +2059,11 @@ parseurl@~1.3.3:
|
|||
resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
|
||||
integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==
|
||||
|
||||
path-exists@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3"
|
||||
integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==
|
||||
|
||||
path-key@^3.1.0:
|
||||
version "3.1.1"
|
||||
resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375"
|
||||
|
|
@ -1907,12 +2087,12 @@ pathe@^1.1.2:
|
|||
resolved "https://registry.yarnpkg.com/pathe/-/pathe-1.1.2.tgz#6c4cb47a945692e48a1ddd6e4094d170516437ec"
|
||||
integrity sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==
|
||||
|
||||
pathe@^2.0.3:
|
||||
pathe@^2.0.1, pathe@^2.0.3:
|
||||
version "2.0.3"
|
||||
resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716"
|
||||
integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==
|
||||
|
||||
picocolors@^1.1.1:
|
||||
picocolors@^1.0.0, picocolors@^1.1.1:
|
||||
version "1.1.1"
|
||||
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b"
|
||||
integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==
|
||||
|
|
@ -1922,6 +2102,15 @@ picomatch@^4.0.2:
|
|||
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.2.tgz#77c742931e8f3b8820946c76cd0c1f13730d1dab"
|
||||
integrity sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==
|
||||
|
||||
pkg-types@^1.3.0:
|
||||
version "1.3.1"
|
||||
resolved "https://registry.yarnpkg.com/pkg-types/-/pkg-types-1.3.1.tgz#bd7cc70881192777eef5326c19deb46e890917df"
|
||||
integrity sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==
|
||||
dependencies:
|
||||
confbox "^0.1.8"
|
||||
mlly "^1.7.4"
|
||||
pathe "^2.0.1"
|
||||
|
||||
postcss@^8.5.3:
|
||||
version "8.5.4"
|
||||
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.4.tgz#d61014ac00e11d5f58458ed7247d899bd65f99c0"
|
||||
|
|
@ -2014,6 +2203,11 @@ readdirp@^4.0.1:
|
|||
resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-4.1.2.tgz#eb85801435fbf2a7ee58f19e0921b068fc69948d"
|
||||
integrity sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==
|
||||
|
||||
"require-like@>= 0.1.1":
|
||||
version "0.1.2"
|
||||
resolved "https://registry.yarnpkg.com/require-like/-/require-like-0.1.2.tgz#ad6f30c13becd797010c468afa775c0c0a6b47fa"
|
||||
integrity sha512-oyrU88skkMtDdauHDuKVrgR+zuItqr6/c//FXzvmxRGMexSDc6hNvJInGW3LL46n+8b50RykrvwSUIIQH2LQ5A==
|
||||
|
||||
retry@^0.12.0:
|
||||
version "0.12.0"
|
||||
resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b"
|
||||
|
|
@ -2334,11 +2528,21 @@ typescript@^5.8.3:
|
|||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.8.3.tgz#92f8a3e5e3cf497356f4178c34cd65a7f5e8440e"
|
||||
integrity sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==
|
||||
|
||||
ufo@^1.5.4:
|
||||
version "1.6.1"
|
||||
resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.6.1.tgz#ac2db1d54614d1b22c1d603e3aef44a85d8f146b"
|
||||
integrity sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==
|
||||
|
||||
undici-types@~6.21.0:
|
||||
version "6.21.0"
|
||||
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb"
|
||||
integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==
|
||||
|
||||
undici-types@~7.8.0:
|
||||
version "7.8.0"
|
||||
resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.8.0.tgz#de00b85b710c54122e44fbfd911f8d70174cd294"
|
||||
integrity sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==
|
||||
|
||||
undici@^6.19.2:
|
||||
version "6.21.3"
|
||||
resolved "https://registry.yarnpkg.com/undici/-/undici-6.21.3.tgz#185752ad92c3d0efe7a7d1f6854a50f83b552d7a"
|
||||
|
|
@ -2390,7 +2594,7 @@ vary@~1.1.2:
|
|||
resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
|
||||
integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==
|
||||
|
||||
vite-node@^3.1.4:
|
||||
vite-node@^3.1.4, vite-node@^3.2.2:
|
||||
version "3.2.3"
|
||||
resolved "https://registry.yarnpkg.com/vite-node/-/vite-node-3.2.3.tgz#1c5a2282fe100114c26fd221daf506e69d392a36"
|
||||
integrity sha512-gc8aAifGuDIpZHrPjuHyP4dpQmYXqWw7D1GmDnWeNWP654UEXzVfQ5IHPSK5HaHkwB/+p1atpYpSdw/2kOv8iQ==
|
||||
|
|
@ -2410,7 +2614,7 @@ vite-tsconfig-paths@^5.1.4:
|
|||
globrex "^0.1.2"
|
||||
tsconfck "^3.0.3"
|
||||
|
||||
"vite@^5.0.0 || ^6.0.0 || ^7.0.0-0", vite@^6.3.3:
|
||||
"vite@^5.0.0 || ^6.0.0", "vite@^5.0.0 || ^6.0.0 || ^7.0.0-0", vite@^6.3.3:
|
||||
version "6.3.5"
|
||||
resolved "https://registry.yarnpkg.com/vite/-/vite-6.3.5.tgz#fec73879013c9c0128c8d284504c6d19410d12a3"
|
||||
integrity sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==
|
||||
|
|
@ -2465,3 +2669,8 @@ yallist@^5.0.0:
|
|||
version "5.0.0"
|
||||
resolved "https://registry.yarnpkg.com/yallist/-/yallist-5.0.0.tgz#00e2de443639ed0d78fd87de0d27469fbcffb533"
|
||||
integrity sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==
|
||||
|
||||
yocto-queue@^0.1.0:
|
||||
version "0.1.0"
|
||||
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
|
||||
integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ package main
|
|||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"log"
|
||||
|
||||
"github.com/gabehf/koito/engine"
|
||||
)
|
||||
|
|
@ -11,7 +13,7 @@ var Version = "dev"
|
|||
|
||||
func main() {
|
||||
if err := engine.Run(
|
||||
os.Getenv,
|
||||
readEnvOrFile,
|
||||
os.Stdout,
|
||||
Version,
|
||||
); err != nil {
|
||||
|
|
@ -19,3 +21,23 @@ func main() {
|
|||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func readEnvOrFile(envName string) string {
|
||||
envContent := os.Getenv(envName)
|
||||
|
||||
if envContent == "" {
|
||||
filename := os.Getenv(envName + "_FILE")
|
||||
|
||||
if filename != "" {
|
||||
b, err := os.ReadFile(filename)
|
||||
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load file for %s_FILE (%s): %s", envName, filename, err)
|
||||
}
|
||||
|
||||
envContent = strings.TrimSpace(string(b))
|
||||
}
|
||||
}
|
||||
|
||||
return envContent
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ JOIN artist_tracks at ON at.track_id = t.id
|
|||
JOIN artists_with_name a ON a.id = at.artist_id
|
||||
WHERE l.listened_at BETWEEN $1 AND $2
|
||||
GROUP BY a.id, a.name, a.musicbrainz_id, a.image, a.image_source, a.name
|
||||
ORDER BY listen_count DESC
|
||||
ORDER BY listen_count DESC, a.id
|
||||
LIMIT $3 OFFSET $4;
|
||||
|
||||
-- name: CountTopArtists :one
|
||||
|
|
|
|||
|
|
@ -200,3 +200,70 @@ WHERE track_id = $1;
|
|||
|
||||
-- name: DeleteListen :exec
|
||||
DELETE FROM listens WHERE track_id = $1 AND listened_at = $2;
|
||||
|
||||
-- name: GetListensExportPage :many
|
||||
SELECT
|
||||
l.listened_at,
|
||||
l.user_id,
|
||||
l.client,
|
||||
|
||||
-- Track info
|
||||
t.id AS track_id,
|
||||
t.musicbrainz_id AS track_mbid,
|
||||
t.duration AS track_duration,
|
||||
(
|
||||
SELECT json_agg(json_build_object(
|
||||
'alias', ta.alias,
|
||||
'source', ta.source,
|
||||
'is_primary', ta.is_primary
|
||||
))
|
||||
FROM track_aliases ta
|
||||
WHERE ta.track_id = t.id
|
||||
) AS track_aliases,
|
||||
|
||||
-- Release info
|
||||
r.id AS release_id,
|
||||
r.musicbrainz_id AS release_mbid,
|
||||
r.image AS release_image,
|
||||
r.image_source AS release_image_source,
|
||||
r.various_artists,
|
||||
(
|
||||
SELECT json_agg(json_build_object(
|
||||
'alias', ra.alias,
|
||||
'source', ra.source,
|
||||
'is_primary', ra.is_primary
|
||||
))
|
||||
FROM release_aliases ra
|
||||
WHERE ra.release_id = r.id
|
||||
) AS release_aliases,
|
||||
|
||||
-- Artists
|
||||
(
|
||||
SELECT json_agg(json_build_object(
|
||||
'id', a.id,
|
||||
'musicbrainz_id', a.musicbrainz_id,
|
||||
'image', a.image,
|
||||
'image_source', a.image_source,
|
||||
'aliases', (
|
||||
SELECT json_agg(json_build_object(
|
||||
'alias', aa.alias,
|
||||
'source', aa.source,
|
||||
'is_primary', aa.is_primary
|
||||
))
|
||||
FROM artist_aliases aa
|
||||
WHERE aa.artist_id = a.id
|
||||
)
|
||||
))
|
||||
FROM artist_tracks at
|
||||
JOIN artists a ON a.id = at.artist_id
|
||||
WHERE at.track_id = t.id
|
||||
) AS artists
|
||||
|
||||
FROM listens l
|
||||
JOIN tracks t ON l.track_id = t.id
|
||||
JOIN releases r ON t.release_id = r.id
|
||||
|
||||
WHERE l.user_id = @user_id::int
|
||||
AND (l.listened_at, l.track_id) > (@listened_at::timestamptz, @track_id::int)
|
||||
ORDER BY l.listened_at, l.track_id
|
||||
LIMIT $1;
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ JOIN artist_releases ar ON r.id = ar.release_id
|
|||
WHERE ar.artist_id = $5
|
||||
AND l.listened_at BETWEEN $1 AND $2
|
||||
GROUP BY r.id, r.title, r.musicbrainz_id, r.various_artists, r.image, r.image_source
|
||||
ORDER BY listen_count DESC
|
||||
ORDER BY listen_count DESC, r.id
|
||||
LIMIT $3 OFFSET $4;
|
||||
|
||||
-- name: GetTopReleasesPaginated :many
|
||||
|
|
@ -54,7 +54,7 @@ JOIN tracks t ON l.track_id = t.id
|
|||
JOIN releases_with_title r ON t.release_id = r.id
|
||||
WHERE l.listened_at BETWEEN $1 AND $2
|
||||
GROUP BY r.id, r.title, r.musicbrainz_id, r.various_artists, r.image, r.image_source
|
||||
ORDER BY listen_count DESC
|
||||
ORDER BY listen_count DESC, r.id
|
||||
LIMIT $3 OFFSET $4;
|
||||
|
||||
-- name: CountTopReleases :one
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ JOIN tracks_with_title t ON l.track_id = t.id
|
|||
JOIN releases r ON t.release_id = r.id
|
||||
WHERE l.listened_at BETWEEN $1 AND $2
|
||||
GROUP BY t.id, t.title, t.musicbrainz_id, t.release_id, r.image
|
||||
ORDER BY listen_count DESC
|
||||
ORDER BY listen_count DESC, t.id
|
||||
LIMIT $3 OFFSET $4;
|
||||
|
||||
-- name: GetTopTracksByArtistPaginated :many
|
||||
|
|
@ -68,7 +68,7 @@ JOIN artist_tracks at ON at.track_id = t.id
|
|||
WHERE l.listened_at BETWEEN $1 AND $2
|
||||
AND at.artist_id = $5
|
||||
GROUP BY t.id, t.title, t.musicbrainz_id, t.release_id, r.image
|
||||
ORDER BY listen_count DESC
|
||||
ORDER BY listen_count DESC, t.id
|
||||
LIMIT $3 OFFSET $4;
|
||||
|
||||
-- name: GetTopTracksInReleasePaginated :many
|
||||
|
|
@ -86,7 +86,7 @@ JOIN releases r ON t.release_id = r.id
|
|||
WHERE l.listened_at BETWEEN $1 AND $2
|
||||
AND t.release_id = $5
|
||||
GROUP BY t.id, t.title, t.musicbrainz_id, t.release_id, r.image
|
||||
ORDER BY listen_count DESC
|
||||
ORDER BY listen_count DESC, t.id
|
||||
LIMIT $3 OFFSET $4;
|
||||
|
||||
-- name: CountTopTracks :one
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ After logging in, open the settings menu again and find the `API Keys` tab. On t
|
|||
If you are not running Koito on an `https://` connection or `localhost`, the click-to-copy button will not work. Instead, just click on the key itself to highlight and copy it.
|
||||
:::
|
||||
|
||||
Then, direct any application you want to scrobble data from to `{your_koito_address}/apis/listenbrainz/1` and provide the api key from the UI as the token.
|
||||
Then, direct any application you want to scrobble data from to `{your_koito_address}/apis/listenbrainz/1` (or `{your_koito_address}/apis/listenbrainz` for some applications) and provide the api key from the UI as the token.
|
||||
|
||||
## Set up a relay
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,12 @@ description: The available configuration options when setting up Koito.
|
|||
|
||||
Koito is configured using **environment variables**. This is the full list of configuration options supported by Koito.
|
||||
|
||||
The suffix `_FILE` is also supported for every environment variable. This allows the use of Docker secrets, for example: `KOITO_DATABASE_URL_FILE=/run/secrets/database-url` will load the content of the file at `/run/secrets/database-url` for the environment variable `KOITO_DATABASE_URL`.
|
||||
|
||||
:::caution
|
||||
If the environment variable is defined without **and** with the suffix at the same time, the content of the environment variable without the `_FILE` suffix will have the higher priority.
|
||||
:::
|
||||
|
||||
##### KOITO_DATABASE_URL
|
||||
- Required: `true`
|
||||
- Description: A Postgres connection URI. See https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNSTRING-URIS for more information.
|
||||
|
|
|
|||
|
|
@ -190,6 +190,14 @@ func Run(
|
|||
}()
|
||||
}
|
||||
|
||||
// l.Info().Msg("Creating test export file")
|
||||
// go func() {
|
||||
// err := export.ExportData(ctx, "koito", store)
|
||||
// if err != nil {
|
||||
// l.Err(err).Msg("Failed to generate export file")
|
||||
// }
|
||||
// }()
|
||||
|
||||
l.Info().Msg("Engine: Pruning orphaned images")
|
||||
go catalog.PruneOrphanedImages(logger.NewContext(l), store)
|
||||
|
||||
|
|
@ -255,6 +263,12 @@ func RunImporter(l *zerolog.Logger, store db.DB, mbzc mbz.MusicBrainzCaller) {
|
|||
if err != nil {
|
||||
l.Err(err).Msgf("Failed to import file: %s", file.Name())
|
||||
}
|
||||
} else if strings.Contains(file.Name(), "koito") {
|
||||
l.Info().Msgf("Import file %s detecting as being Koito export", file.Name())
|
||||
err := importer.ImportKoitoFile(logger.NewContext(l), store, file.Name())
|
||||
if err != nil {
|
||||
l.Err(err).Msgf("Failed to import file: %s", file.Name())
|
||||
}
|
||||
} else {
|
||||
l.Warn().Msgf("File %s not recognized as a valid import file; make sure it is valid and named correctly", file.Name())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,11 +88,18 @@ func DeleteAliasHandler(store db.DB) http.HandlerFunc {
|
|||
|
||||
l.Debug().Msg("DeleteAliasHandler: Got request")
|
||||
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
l.Debug().Msg("DeleteAliasHandler: Failed to parse form")
|
||||
utils.WriteError(w, "form is invalid", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse query parameters
|
||||
artistIDStr := r.URL.Query().Get("artist_id")
|
||||
albumIDStr := r.URL.Query().Get("album_id")
|
||||
trackIDStr := r.URL.Query().Get("track_id")
|
||||
alias := r.URL.Query().Get("alias")
|
||||
artistIDStr := r.FormValue("artist_id")
|
||||
albumIDStr := r.FormValue("album_id")
|
||||
trackIDStr := r.FormValue("track_id")
|
||||
alias := r.FormValue("alias")
|
||||
|
||||
if alias == "" || (artistIDStr == "" && albumIDStr == "" && trackIDStr == "") {
|
||||
l.Debug().Msg("DeleteAliasHandler: Request is missing required parameters")
|
||||
|
|
@ -105,7 +112,6 @@ func DeleteAliasHandler(store db.DB) http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
var err error
|
||||
if artistIDStr != "" {
|
||||
var artistID int
|
||||
artistID, err = strconv.Atoi(artistIDStr)
|
||||
|
|
@ -176,9 +182,9 @@ func CreateAliasHandler(store db.DB) http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
artistIDStr := r.URL.Query().Get("artist_id")
|
||||
albumIDStr := r.URL.Query().Get("album_id")
|
||||
trackIDStr := r.URL.Query().Get("track_id")
|
||||
artistIDStr := r.FormValue("artist_id")
|
||||
albumIDStr := r.FormValue("album_id")
|
||||
trackIDStr := r.FormValue("track_id")
|
||||
|
||||
if artistIDStr == "" && albumIDStr == "" && trackIDStr == "" {
|
||||
l.Debug().Msg("CreateAliasHandler: Missing ID parameter")
|
||||
|
|
@ -245,11 +251,20 @@ func SetPrimaryAliasHandler(store db.DB) http.HandlerFunc {
|
|||
|
||||
l.Debug().Msg("SetPrimaryAliasHandler: Got request")
|
||||
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
l.Debug().Msg("SetPrimaryAliasHandler: Failed to parse form")
|
||||
utils.WriteError(w, "form is invalid", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse query parameters
|
||||
artistIDStr := r.URL.Query().Get("artist_id")
|
||||
albumIDStr := r.URL.Query().Get("album_id")
|
||||
trackIDStr := r.URL.Query().Get("track_id")
|
||||
alias := r.URL.Query().Get("alias")
|
||||
artistIDStr := r.FormValue("artist_id")
|
||||
albumIDStr := r.FormValue("album_id")
|
||||
trackIDStr := r.FormValue("track_id")
|
||||
alias := r.FormValue("alias")
|
||||
|
||||
l.Debug().Msgf("Alias: %s", alias)
|
||||
|
||||
if alias == "" {
|
||||
l.Debug().Msg("SetPrimaryAliasHandler: Missing alias parameter")
|
||||
|
|
@ -268,7 +283,6 @@ func SetPrimaryAliasHandler(store db.DB) http.HandlerFunc {
|
|||
}
|
||||
|
||||
var id int
|
||||
var err error
|
||||
if artistIDStr != "" {
|
||||
id, err = strconv.Atoi(artistIDStr)
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -139,7 +139,14 @@ func UpdateApiKeyLabelHandler(store db.DB) http.HandlerFunc {
|
|||
return
|
||||
}
|
||||
|
||||
idStr := r.URL.Query().Get("id")
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
l.Debug().Msg("UpdateApiKeyLabelHandler: Failed to parse form")
|
||||
utils.WriteError(w, "form is invalid", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
idStr := r.FormValue("id")
|
||||
if idStr == "" {
|
||||
l.Debug().Msg("UpdateApiKeyLabelHandler: Missing id parameter")
|
||||
utils.WriteError(w, "id is required", http.StatusBadRequest)
|
||||
|
|
|
|||
33
engine/handlers/export.go
Normal file
33
engine/handlers/export.go
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gabehf/koito/engine/middleware"
|
||||
"github.com/gabehf/koito/internal/db"
|
||||
"github.com/gabehf/koito/internal/export"
|
||||
"github.com/gabehf/koito/internal/logger"
|
||||
"github.com/gabehf/koito/internal/utils"
|
||||
)
|
||||
|
||||
func ExportHandler(store db.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="koito_export.json"`)
|
||||
ctx := r.Context()
|
||||
l := logger.FromContext(ctx)
|
||||
l.Debug().Msg("ExportHandler: Recieved request for export file")
|
||||
u := middleware.GetUserFromContext(ctx)
|
||||
if u == nil {
|
||||
l.Debug().Msg("ExportHandler: Unauthorized access")
|
||||
utils.WriteError(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
err := export.ExportData(ctx, u, store, w)
|
||||
if err != nil {
|
||||
l.Err(err).Msg("ExportHandler: Failed to create export file")
|
||||
utils.WriteError(w, "failed to create export file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -70,6 +70,7 @@ func bindRoutes(
|
|||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(middleware.ValidateSession(db))
|
||||
r.Get("/export", handlers.ExportHandler(db))
|
||||
r.Post("/replace-image", handlers.ReplaceImageHandler(db))
|
||||
r.Patch("/album", handlers.UpdateAlbumHandler(db))
|
||||
r.Post("/merge/tracks", handlers.MergeTracksHandler(db))
|
||||
|
|
@ -81,7 +82,7 @@ func bindRoutes(
|
|||
r.Delete("/track", handlers.DeleteTrackHandler(db))
|
||||
r.Delete("/listen", handlers.DeleteListenHandler(db))
|
||||
r.Post("/aliases", handlers.CreateAliasHandler(db))
|
||||
r.Delete("/aliases", handlers.DeleteAliasHandler(db))
|
||||
r.Post("/aliases/delete", handlers.DeleteAliasHandler(db))
|
||||
r.Post("/aliases/primary", handlers.SetPrimaryAliasHandler(db))
|
||||
r.Get("/user/apikeys", handlers.GetApiKeysHandler(db))
|
||||
r.Post("/user/apikeys", handlers.GenerateApiKeyHandler(db))
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ type DB interface {
|
|||
ImageHasAssociation(ctx context.Context, image uuid.UUID) (bool, error)
|
||||
GetImageSource(ctx context.Context, image uuid.UUID) (string, error)
|
||||
AlbumsWithoutImages(ctx context.Context, from int32) ([]*models.Album, error)
|
||||
GetExportPage(ctx context.Context, opts GetExportPageOpts) ([]*ExportItem, error)
|
||||
Ping(ctx context.Context) error
|
||||
Close(ctx context.Context)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -147,3 +147,10 @@ type TimeListenedOpts struct {
|
|||
ArtistID int32
|
||||
TrackID int32
|
||||
}
|
||||
|
||||
type GetExportPageOpts struct {
|
||||
UserID int32
|
||||
ListenedAt time.Time
|
||||
TrackID int32
|
||||
Limit int32
|
||||
}
|
||||
|
|
|
|||
|
|
@ -128,6 +128,7 @@ func (d *Psql) SaveArtistAliases(ctx context.Context, id int32, aliases []string
|
|||
}
|
||||
defer tx.Rollback(ctx)
|
||||
qtx := d.q.WithTx(tx)
|
||||
l.Debug().Msgf("Fetching existing artist aliases for artist %d...", id)
|
||||
existing, err := qtx.GetAllArtistAliases(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("SaveArtistAliases: GetAllArtistAliases: %w", err)
|
||||
|
|
@ -135,8 +136,10 @@ func (d *Psql) SaveArtistAliases(ctx context.Context, id int32, aliases []string
|
|||
for _, v := range existing {
|
||||
aliases = append(aliases, v.Alias)
|
||||
}
|
||||
l.Debug().Msgf("Ensuring aliases are unique...")
|
||||
utils.Unique(&aliases)
|
||||
for _, alias := range aliases {
|
||||
l.Debug().Msgf("Inserting alias %s for artist with id %d", alias, id)
|
||||
alias = strings.TrimSpace(alias)
|
||||
if alias == "" {
|
||||
return errors.New("SaveArtistAliases: aliases cannot be blank")
|
||||
|
|
|
|||
59
internal/db/psql/exports.go
Normal file
59
internal/db/psql/exports.go
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
package psql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/gabehf/koito/internal/db"
|
||||
"github.com/gabehf/koito/internal/models"
|
||||
"github.com/gabehf/koito/internal/repository"
|
||||
)
|
||||
|
||||
func (d *Psql) GetExportPage(ctx context.Context, opts db.GetExportPageOpts) ([]*db.ExportItem, error) {
|
||||
rows, err := d.q.GetListensExportPage(ctx, repository.GetListensExportPageParams{
|
||||
UserID: opts.UserID,
|
||||
TrackID: opts.TrackID,
|
||||
Limit: opts.Limit,
|
||||
ListenedAt: opts.ListenedAt,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GetExportPage: %w", err)
|
||||
}
|
||||
ret := make([]*db.ExportItem, len(rows))
|
||||
for i, row := range rows {
|
||||
|
||||
var trackAliases []models.Alias
|
||||
err = json.Unmarshal(row.TrackAliases, &trackAliases)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GetExportPage: json.Unmarshal trackAliases: %w", err)
|
||||
}
|
||||
var albumAliases []models.Alias
|
||||
err = json.Unmarshal(row.ReleaseAliases, &albumAliases)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GetExportPage: json.Unmarshal albumAliases: %w", err)
|
||||
}
|
||||
var artists []models.ArtistWithFullAliases
|
||||
err = json.Unmarshal(row.Artists, &artists)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GetExportPage: json.Unmarshal artists: %w", err)
|
||||
}
|
||||
|
||||
ret[i] = &db.ExportItem{
|
||||
TrackID: row.TrackID,
|
||||
ListenedAt: row.ListenedAt,
|
||||
UserID: row.UserID,
|
||||
Client: row.Client,
|
||||
TrackMbid: row.TrackMbid,
|
||||
TrackDuration: row.TrackDuration,
|
||||
TrackAliases: trackAliases,
|
||||
ReleaseID: row.ReleaseID,
|
||||
ReleaseMbid: row.ReleaseMbid,
|
||||
ReleaseImageSource: row.ReleaseImageSource.String,
|
||||
VariousArtists: row.VariousArtists,
|
||||
ReleaseAliases: albumAliases,
|
||||
Artists: artists,
|
||||
}
|
||||
}
|
||||
return ret, nil
|
||||
}
|
||||
|
|
@ -2,6 +2,9 @@ package db
|
|||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gabehf/koito/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type InformationSource string
|
||||
|
|
@ -24,3 +27,20 @@ type PaginatedResponse[T any] struct {
|
|||
HasNextPage bool `json:"has_next_page"`
|
||||
CurrentPage int32 `json:"current_page"`
|
||||
}
|
||||
|
||||
type ExportItem struct {
|
||||
ListenedAt time.Time
|
||||
UserID int32
|
||||
Client *string
|
||||
TrackID int32
|
||||
TrackMbid *uuid.UUID
|
||||
TrackDuration int32
|
||||
TrackAliases []models.Alias
|
||||
ReleaseID int32
|
||||
ReleaseMbid *uuid.UUID
|
||||
ReleaseImage *uuid.UUID
|
||||
ReleaseImageSource string
|
||||
VariousArtists bool
|
||||
ReleaseAliases []models.Alias
|
||||
Artists []models.ArtistWithFullAliases
|
||||
}
|
||||
|
|
|
|||
145
internal/export/export.go
Normal file
145
internal/export/export.go
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
package export
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"github.com/gabehf/koito/internal/db"
|
||||
"github.com/gabehf/koito/internal/logger"
|
||||
"github.com/gabehf/koito/internal/models"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type KoitoExport struct {
|
||||
Version string `json:"version"`
|
||||
ExportedAt time.Time `json:"exported_at"` // RFC3339
|
||||
User string `json:"user"` // username
|
||||
Listens []KoitoListen `json:"listens"`
|
||||
}
|
||||
type KoitoListen struct {
|
||||
ListenedAt time.Time `json:"listened_at"`
|
||||
Track KoitoTrack `json:"track"`
|
||||
Album KoitoAlbum `json:"album"`
|
||||
Artists []KoitoArtist `json:"artists"`
|
||||
}
|
||||
type KoitoTrack struct {
|
||||
MBID *uuid.UUID `json:"mbid"`
|
||||
Duration int `json:"duration"`
|
||||
Aliases []models.Alias `json:"aliases"`
|
||||
}
|
||||
type KoitoAlbum struct {
|
||||
ImageUrl string `json:"image_url"`
|
||||
MBID *uuid.UUID `json:"mbid"`
|
||||
Aliases []models.Alias `json:"aliases"`
|
||||
VariousArtists bool `json:"various_artists"`
|
||||
}
|
||||
type KoitoArtist struct {
|
||||
ImageUrl string `json:"image_url"`
|
||||
MBID *uuid.UUID `json:"mbid"`
|
||||
IsPrimary bool `json:"is_primary"`
|
||||
Aliases []models.Alias `json:"aliases"`
|
||||
}
|
||||
|
||||
func ExportData(ctx context.Context, user *models.User, store db.DB, out io.Writer) error {
|
||||
lastTime := time.Unix(0, 0)
|
||||
lastTrackId := int32(0)
|
||||
pageSize := int32(1000)
|
||||
|
||||
l := logger.FromContext(ctx)
|
||||
l.Info().Msg("ExportData: Generating Koito export file...")
|
||||
|
||||
exportedAt := time.Now()
|
||||
// exportFile := path.Join(cfg.ConfigDir(), fmt.Sprintf("koito_export_%d.json", exportedAt.Unix()))
|
||||
// f, err := os.Create(exportFile)
|
||||
// if err != nil {
|
||||
// return fmt.Errorf("ExportData: %w", err)
|
||||
// }
|
||||
// defer f.Close()
|
||||
|
||||
// Write the opening of the JSON manually
|
||||
_, err := fmt.Fprintf(out, "{\n \"version\": \"1\",\n \"exported_at\": \"%s\",\n \"user\": \"%s\",\n \"listens\": [\n", exportedAt.UTC().Format(time.RFC3339), user.Username)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ExportData: %w", err)
|
||||
}
|
||||
|
||||
first := true
|
||||
for {
|
||||
rows, err := store.GetExportPage(ctx, db.GetExportPageOpts{
|
||||
UserID: user.ID,
|
||||
ListenedAt: lastTime,
|
||||
TrackID: lastTrackId,
|
||||
Limit: pageSize,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("ExportData: %w", err)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
for _, r := range rows {
|
||||
// Adds a comma after each listen item
|
||||
if !first {
|
||||
_, _ = out.Write([]byte(",\n"))
|
||||
}
|
||||
first = false
|
||||
|
||||
exported := convertToExportFormat(r)
|
||||
|
||||
raw, err := json.MarshalIndent(exported, " ", " ")
|
||||
|
||||
// needed to make the listen item start at the right indent level
|
||||
out.Write([]byte(" "))
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("ExportData: marshal: %w", err)
|
||||
}
|
||||
_, _ = out.Write(raw)
|
||||
|
||||
if r.TrackID > lastTrackId {
|
||||
lastTrackId = r.TrackID
|
||||
}
|
||||
if r.ListenedAt.After(lastTime) {
|
||||
lastTime = r.ListenedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write closing of the JSON array and object
|
||||
_, err = out.Write([]byte("\n ]\n}\n"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("ExportData: f.Write: %w", err)
|
||||
}
|
||||
|
||||
l.Info().Msgf("Export successfully created")
|
||||
return nil
|
||||
}
|
||||
|
||||
func convertToExportFormat(item *db.ExportItem) *KoitoListen {
|
||||
ret := &KoitoListen{
|
||||
ListenedAt: item.ListenedAt.UTC(),
|
||||
Track: KoitoTrack{
|
||||
MBID: item.TrackMbid,
|
||||
Duration: int(item.TrackDuration),
|
||||
Aliases: item.TrackAliases,
|
||||
},
|
||||
Album: KoitoAlbum{
|
||||
MBID: item.ReleaseMbid,
|
||||
ImageUrl: item.ReleaseImageSource,
|
||||
VariousArtists: item.VariousArtists,
|
||||
Aliases: item.ReleaseAliases,
|
||||
},
|
||||
}
|
||||
for i := range item.Artists {
|
||||
ret.Artists = append(ret.Artists, KoitoArtist{
|
||||
IsPrimary: item.Artists[i].IsPrimary,
|
||||
MBID: item.Artists[i].MbzID,
|
||||
Aliases: item.Artists[i].Aliases,
|
||||
ImageUrl: item.Artists[i].ImageSource,
|
||||
})
|
||||
}
|
||||
return ret
|
||||
}
|
||||
172
internal/importer/koito.go
Normal file
172
internal/importer/koito.go
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
package importer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/gabehf/koito/internal/cfg"
|
||||
"github.com/gabehf/koito/internal/db"
|
||||
"github.com/gabehf/koito/internal/export"
|
||||
"github.com/gabehf/koito/internal/logger"
|
||||
"github.com/gabehf/koito/internal/models"
|
||||
"github.com/gabehf/koito/internal/utils"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func ImportKoitoFile(ctx context.Context, store db.DB, filename string) error {
|
||||
l := logger.FromContext(ctx)
|
||||
l.Info().Msgf("Beginning Koito import on file: %s", filename)
|
||||
data := new(export.KoitoExport)
|
||||
f, err := os.Open(path.Join(cfg.ConfigDir(), "import", filename))
|
||||
if err != nil {
|
||||
return fmt.Errorf("ImportKoitoFile: os.Open: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
err = json.NewDecoder(f).Decode(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ImportKoitoFile: Decode: %w", err)
|
||||
}
|
||||
|
||||
if data.Version != "1" {
|
||||
return fmt.Errorf("ImportKoitoFile: unupported version: %s", data.Version)
|
||||
}
|
||||
|
||||
l.Info().Msgf("Beginning data import for user: %s", data.User)
|
||||
|
||||
count := 0
|
||||
|
||||
for i := range data.Listens {
|
||||
// use this for save/get mbid for all artist/album/track
|
||||
mbid := uuid.Nil
|
||||
|
||||
artistIds := make([]int32, 0)
|
||||
for _, ia := range data.Listens[i].Artists {
|
||||
if ia.MBID != nil {
|
||||
mbid = *ia.MBID
|
||||
}
|
||||
artist, err := store.GetArtist(ctx, db.GetArtistOpts{
|
||||
MusicBrainzID: mbid,
|
||||
Name: getPrimaryAliasFromAliasSlice(ia.Aliases),
|
||||
})
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
var imgid = uuid.Nil
|
||||
// not a perfect way to check if the image url is an actual source vs manual upload but
|
||||
// im like 99% sure it will work perfectly
|
||||
if strings.HasPrefix(ia.ImageUrl, "http") {
|
||||
imgid = uuid.New()
|
||||
}
|
||||
// save artist
|
||||
artist, err := store.SaveArtist(ctx, db.SaveArtistOpts{
|
||||
Name: getPrimaryAliasFromAliasSlice(ia.Aliases),
|
||||
Image: imgid,
|
||||
ImageSrc: ia.ImageUrl,
|
||||
MusicBrainzID: mbid,
|
||||
Aliases: utils.FlattenAliases(ia.Aliases),
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("ImportKoitoFile: %w", err)
|
||||
}
|
||||
artistIds = append(artistIds, artist.ID)
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("ImportKoitoFile: %w", err)
|
||||
} else {
|
||||
artistIds = append(artistIds, artist.ID)
|
||||
}
|
||||
}
|
||||
// call associate album
|
||||
albumId := int32(0)
|
||||
if data.Listens[i].Album.MBID != nil {
|
||||
mbid = *data.Listens[i].Album.MBID
|
||||
}
|
||||
album, err := store.GetAlbum(ctx, db.GetAlbumOpts{
|
||||
MusicBrainzID: mbid,
|
||||
Title: getPrimaryAliasFromAliasSlice(data.Listens[i].Album.Aliases),
|
||||
ArtistID: artistIds[0],
|
||||
})
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
var imgid = uuid.Nil
|
||||
// not a perfect way to check if the image url is an actual source vs manual upload but
|
||||
// im like 99% sure it will work perfectly
|
||||
if strings.HasPrefix(data.Listens[i].Album.ImageUrl, "http") {
|
||||
imgid = uuid.New()
|
||||
}
|
||||
// save album
|
||||
album, err = store.SaveAlbum(ctx, db.SaveAlbumOpts{
|
||||
Title: getPrimaryAliasFromAliasSlice(data.Listens[i].Album.Aliases),
|
||||
Image: imgid,
|
||||
ImageSrc: data.Listens[i].Album.ImageUrl,
|
||||
MusicBrainzID: mbid,
|
||||
Aliases: utils.FlattenAliases(data.Listens[i].Album.Aliases),
|
||||
ArtistIDs: artistIds,
|
||||
VariousArtists: data.Listens[i].Album.VariousArtists,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("ImportKoitoFile: %w", err)
|
||||
}
|
||||
albumId = album.ID
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("ImportKoitoFile: %w", err)
|
||||
} else {
|
||||
albumId = album.ID
|
||||
}
|
||||
|
||||
// call associate track
|
||||
if data.Listens[i].Track.MBID != nil {
|
||||
mbid = *data.Listens[i].Track.MBID
|
||||
}
|
||||
track, err := store.GetTrack(ctx, db.GetTrackOpts{
|
||||
MusicBrainzID: mbid,
|
||||
Title: getPrimaryAliasFromAliasSlice(data.Listens[i].Track.Aliases),
|
||||
ArtistIDs: artistIds,
|
||||
})
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
// save track
|
||||
track, err = store.SaveTrack(ctx, db.SaveTrackOpts{
|
||||
Title: getPrimaryAliasFromAliasSlice(data.Listens[i].Track.Aliases),
|
||||
RecordingMbzID: mbid,
|
||||
Duration: int32(data.Listens[i].Track.Duration),
|
||||
ArtistIDs: artistIds,
|
||||
AlbumID: albumId,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("ImportKoitoFile: %w", err)
|
||||
}
|
||||
// save track aliases
|
||||
err = store.SaveTrackAliases(ctx, track.ID, utils.FlattenAliases(data.Listens[i].Track.Aliases), "Import")
|
||||
if err != nil {
|
||||
return fmt.Errorf("ImportKoitoFile: %w", err)
|
||||
}
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("ImportKoitoFile: %w", err)
|
||||
}
|
||||
|
||||
// save listen
|
||||
err = store.SaveListen(ctx, db.SaveListenOpts{
|
||||
TrackID: track.ID,
|
||||
Time: data.Listens[i].ListenedAt,
|
||||
UserID: 1,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("ImportKoitoFile: %w", err)
|
||||
}
|
||||
|
||||
l.Info().Msgf("ImportKoitoFile: Imported listen for track %s", track.Title)
|
||||
count++
|
||||
}
|
||||
|
||||
return finishImport(ctx, filename, count)
|
||||
}
|
||||
func getPrimaryAliasFromAliasSlice(aliases []models.Alias) string {
|
||||
for _, a := range aliases {
|
||||
if a.Primary {
|
||||
return a.Alias
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
package models
|
||||
|
||||
type Alias struct {
|
||||
ID int32 `json:"id"`
|
||||
ID int32 `json:"id,omitempty"`
|
||||
Alias string `json:"alias"`
|
||||
Source string `json:"source"`
|
||||
Primary bool `json:"is_primary"`
|
||||
|
|
|
|||
|
|
@ -17,3 +17,15 @@ type SimpleArtist struct {
|
|||
ID int32 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type ArtistWithFullAliases struct {
|
||||
ID int32 `json:"id"`
|
||||
MbzID *uuid.UUID `json:"musicbrainz_id"`
|
||||
Name string `json:"name"`
|
||||
Aliases []Alias `json:"aliases"`
|
||||
Image *uuid.UUID `json:"image"`
|
||||
ImageSource string `json:"image_source,omitempty"`
|
||||
ListenCount int64 `json:"listen_count"`
|
||||
TimeListened int64 `json:"time_listened"`
|
||||
IsPrimary bool `json:"is_primary,omitempty"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -256,7 +256,7 @@ JOIN artist_tracks at ON at.track_id = t.id
|
|||
JOIN artists_with_name a ON a.id = at.artist_id
|
||||
WHERE l.listened_at BETWEEN $1 AND $2
|
||||
GROUP BY a.id, a.name, a.musicbrainz_id, a.image, a.image_source, a.name
|
||||
ORDER BY listen_count DESC
|
||||
ORDER BY listen_count DESC, a.id
|
||||
LIMIT $3 OFFSET $4
|
||||
`
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
|
|
@ -451,6 +452,138 @@ func (q *Queries) GetLastListensPaginated(ctx context.Context, arg GetLastListen
|
|||
return items, nil
|
||||
}
|
||||
|
||||
const getListensExportPage = `-- name: GetListensExportPage :many
|
||||
SELECT
|
||||
l.listened_at,
|
||||
l.user_id,
|
||||
l.client,
|
||||
|
||||
-- Track info
|
||||
t.id AS track_id,
|
||||
t.musicbrainz_id AS track_mbid,
|
||||
t.duration AS track_duration,
|
||||
(
|
||||
SELECT json_agg(json_build_object(
|
||||
'alias', ta.alias,
|
||||
'source', ta.source,
|
||||
'is_primary', ta.is_primary
|
||||
))
|
||||
FROM track_aliases ta
|
||||
WHERE ta.track_id = t.id
|
||||
) AS track_aliases,
|
||||
|
||||
-- Release info
|
||||
r.id AS release_id,
|
||||
r.musicbrainz_id AS release_mbid,
|
||||
r.image AS release_image,
|
||||
r.image_source AS release_image_source,
|
||||
r.various_artists,
|
||||
(
|
||||
SELECT json_agg(json_build_object(
|
||||
'alias', ra.alias,
|
||||
'source', ra.source,
|
||||
'is_primary', ra.is_primary
|
||||
))
|
||||
FROM release_aliases ra
|
||||
WHERE ra.release_id = r.id
|
||||
) AS release_aliases,
|
||||
|
||||
-- Artists
|
||||
(
|
||||
SELECT json_agg(json_build_object(
|
||||
'id', a.id,
|
||||
'musicbrainz_id', a.musicbrainz_id,
|
||||
'image', a.image,
|
||||
'image_source', a.image_source,
|
||||
'aliases', (
|
||||
SELECT json_agg(json_build_object(
|
||||
'alias', aa.alias,
|
||||
'source', aa.source,
|
||||
'is_primary', aa.is_primary
|
||||
))
|
||||
FROM artist_aliases aa
|
||||
WHERE aa.artist_id = a.id
|
||||
)
|
||||
))
|
||||
FROM artist_tracks at
|
||||
JOIN artists a ON a.id = at.artist_id
|
||||
WHERE at.track_id = t.id
|
||||
) AS artists
|
||||
|
||||
FROM listens l
|
||||
JOIN tracks t ON l.track_id = t.id
|
||||
JOIN releases r ON t.release_id = r.id
|
||||
|
||||
WHERE l.user_id = $2::int
|
||||
AND (l.listened_at, l.track_id) > ($3::timestamptz, $4::int)
|
||||
ORDER BY l.listened_at, l.track_id
|
||||
LIMIT $1
|
||||
`
|
||||
|
||||
type GetListensExportPageParams struct {
|
||||
Limit int32
|
||||
UserID int32
|
||||
ListenedAt time.Time
|
||||
TrackID int32
|
||||
}
|
||||
|
||||
type GetListensExportPageRow struct {
|
||||
ListenedAt time.Time
|
||||
UserID int32
|
||||
Client *string
|
||||
TrackID int32
|
||||
TrackMbid *uuid.UUID
|
||||
TrackDuration int32
|
||||
TrackAliases []byte
|
||||
ReleaseID int32
|
||||
ReleaseMbid *uuid.UUID
|
||||
ReleaseImage *uuid.UUID
|
||||
ReleaseImageSource pgtype.Text
|
||||
VariousArtists bool
|
||||
ReleaseAliases []byte
|
||||
Artists []byte
|
||||
}
|
||||
|
||||
func (q *Queries) GetListensExportPage(ctx context.Context, arg GetListensExportPageParams) ([]GetListensExportPageRow, error) {
|
||||
rows, err := q.db.Query(ctx, getListensExportPage,
|
||||
arg.Limit,
|
||||
arg.UserID,
|
||||
arg.ListenedAt,
|
||||
arg.TrackID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var items []GetListensExportPageRow
|
||||
for rows.Next() {
|
||||
var i GetListensExportPageRow
|
||||
if err := rows.Scan(
|
||||
&i.ListenedAt,
|
||||
&i.UserID,
|
||||
&i.Client,
|
||||
&i.TrackID,
|
||||
&i.TrackMbid,
|
||||
&i.TrackDuration,
|
||||
&i.TrackAliases,
|
||||
&i.ReleaseID,
|
||||
&i.ReleaseMbid,
|
||||
&i.ReleaseImage,
|
||||
&i.ReleaseImageSource,
|
||||
&i.VariousArtists,
|
||||
&i.ReleaseAliases,
|
||||
&i.Artists,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const insertListen = `-- name: InsertListen :exec
|
||||
INSERT INTO listens (track_id, listened_at, user_id, client)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
|
|
|
|||
|
|
@ -260,7 +260,7 @@ JOIN artist_releases ar ON r.id = ar.release_id
|
|||
WHERE ar.artist_id = $5
|
||||
AND l.listened_at BETWEEN $1 AND $2
|
||||
GROUP BY r.id, r.title, r.musicbrainz_id, r.various_artists, r.image, r.image_source
|
||||
ORDER BY listen_count DESC
|
||||
ORDER BY listen_count DESC, r.id
|
||||
LIMIT $3 OFFSET $4
|
||||
`
|
||||
|
||||
|
|
@ -328,7 +328,7 @@ JOIN tracks t ON l.track_id = t.id
|
|||
JOIN releases_with_title r ON t.release_id = r.id
|
||||
WHERE l.listened_at BETWEEN $1 AND $2
|
||||
GROUP BY r.id, r.title, r.musicbrainz_id, r.various_artists, r.image, r.image_source
|
||||
ORDER BY listen_count DESC
|
||||
ORDER BY listen_count DESC, r.id
|
||||
LIMIT $3 OFFSET $4
|
||||
`
|
||||
|
||||
|
|
|
|||
|
|
@ -146,7 +146,7 @@ JOIN artist_tracks at ON at.track_id = t.id
|
|||
WHERE l.listened_at BETWEEN $1 AND $2
|
||||
AND at.artist_id = $5
|
||||
GROUP BY t.id, t.title, t.musicbrainz_id, t.release_id, r.image
|
||||
ORDER BY listen_count DESC
|
||||
ORDER BY listen_count DESC, t.id
|
||||
LIMIT $3 OFFSET $4
|
||||
`
|
||||
|
||||
|
|
@ -217,7 +217,7 @@ JOIN releases r ON t.release_id = r.id
|
|||
WHERE l.listened_at BETWEEN $1 AND $2
|
||||
AND t.release_id = $5
|
||||
GROUP BY t.id, t.title, t.musicbrainz_id, t.release_id, r.image
|
||||
ORDER BY listen_count DESC
|
||||
ORDER BY listen_count DESC, t.id
|
||||
LIMIT $3 OFFSET $4
|
||||
`
|
||||
|
||||
|
|
@ -287,7 +287,7 @@ JOIN tracks_with_title t ON l.track_id = t.id
|
|||
JOIN releases r ON t.release_id = r.id
|
||||
WHERE l.listened_at BETWEEN $1 AND $2
|
||||
GROUP BY t.id, t.title, t.musicbrainz_id, t.release_id, r.image
|
||||
ORDER BY listen_count DESC
|
||||
ORDER BY listen_count DESC, t.id
|
||||
LIMIT $3 OFFSET $4
|
||||
`
|
||||
|
||||
|
|
|
|||
|
|
@ -327,3 +327,11 @@ func ParseBool(s string) (value, ok bool) {
|
|||
return
|
||||
}
|
||||
}
|
||||
|
||||
func FlattenAliases(aliases []models.Alias) []string {
|
||||
ret := make([]string, len(aliases))
|
||||
for i := range aliases {
|
||||
ret[i] = aliases[i].Alias
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue