From c16b557c21edc065becc6bdf1743d98de2a52bb9 Mon Sep 17 00:00:00 2001 From: Gabe Farrell <90876006+gabehf@users.noreply.github.com> Date: Wed, 18 Jun 2025 08:48:19 -0400 Subject: [PATCH] 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 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 --- .github/workflows/test.yml | 32 ++ CHANGELOG.md | 22 +- client/api/api.ts | 36 +- client/app/app.css | 7 + client/app/components/ActivityGrid.tsx | 122 +++--- .../app/components/ActivityOptsSelector.tsx | 112 ++--- client/app/components/LastPlays.tsx | 1 - client/app/components/TopItemList.tsx | 2 +- .../components/modals/EditModal/EditModal.tsx | 6 +- client/app/components/modals/ExportModal.tsx | 45 ++ .../app/components/modals/SettingsModal.tsx | 16 +- .../themeSwitcher/ThemeSwitcher.tsx | 71 +++- client/app/providers/ThemeProvider.tsx | 324 ++++----------- client/app/routes/Home.tsx | 2 +- client/app/routes/MediaItems/Album.tsx | 2 +- client/app/routes/MediaItems/Artist.tsx | 2 +- client/app/routes/MediaItems/MediaLayout.tsx | 2 +- client/app/routes/MediaItems/Track.tsx | 2 +- client/app/routes/ThemeHelper.tsx | 112 +++-- client/app/styles/themes.css.ts | 256 ++++++++++++ client/app/styles/vars.css.ts | 16 + client/app/themes.css | 386 ------------------ client/package.json | 2 + client/vite.config.ts | 3 +- client/yarn.lock | 227 +++++++++- cmd/api/main.go | 24 +- db/queries/artist.sql | 2 +- db/queries/listen.sql | 69 +++- db/queries/release.sql | 4 +- db/queries/track.sql | 6 +- docs/src/content/docs/guides/scrobbler.md | 2 +- .../content/docs/reference/configuration.md | 16 +- engine/engine.go | 14 + engine/handlers/alias.go | 40 +- engine/handlers/apikeys.go | 9 +- engine/handlers/export.go | 33 ++ engine/routes.go | 3 +- internal/db/db.go | 1 + internal/db/opts.go | 7 + internal/db/psql/artist.go | 3 + internal/db/psql/exports.go | 59 +++ internal/db/types.go | 20 + internal/export/export.go | 145 +++++++ internal/importer/koito.go | 172 ++++++++ internal/models/alias.go | 2 +- internal/models/artist.go | 12 + internal/repository/artist.sql.go | 2 +- internal/repository/listen.sql.go | 133 ++++++ internal/repository/release.sql.go | 4 +- internal/repository/track.sql.go | 6 +- internal/utils/utils.go | 8 + 51 files changed, 1746 insertions(+), 858 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 client/app/components/modals/ExportModal.tsx create mode 100644 client/app/styles/themes.css.ts create mode 100644 client/app/styles/vars.css.ts create mode 100644 engine/handlers/export.go create mode 100644 internal/db/psql/exports.go create mode 100644 internal/export/export.go create mode 100644 internal/importer/koito.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..f37ea6a --- /dev/null +++ b/.github/workflows/test.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index fd91fc3..f2dc277 100644 --- a/CHANGELOG.md +++ b/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 \ No newline at end of file +- 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 \ No newline at end of file diff --git a/client/api/api.ts b/client/api/api.ts index ca2cf91..be2cda0 100644 --- a/client/api/api.ts +++ b/client/api/api.ts @@ -53,6 +53,7 @@ function getStats(period: string): Promise { } function search(q: string): Promise { + q = encodeURIComponent(q) return fetch(`/apis/web/v1/search?q=${q}`).then(r => r.json() as Promise) } @@ -131,8 +132,12 @@ function deleteApiKey(id: number): Promise { }) } function updateApiKeyLabel(id: number, label: string): Promise { - 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 { return fetch(`/apis/web/v1/aliases?${type}_id=${id}`).then(r => r.json() as Promise) } function createAlias(type: string, id: number, alias: string): Promise { - 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 { - 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 { - 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 { @@ -179,6 +196,8 @@ function deleteListen(listen: Listen): Promise { method: "DELETE" }) } +function getExport() { +} export { getLastListens, @@ -207,6 +226,7 @@ export { updateApiKeyLabel, deleteListen, getAlbum, + getExport, } type Track = { id: number diff --git a/client/app/app.css b/client/app/app.css index f0b786e..143572c 100644 --- a/client/app/app.css +++ b/client/app/app.css @@ -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); } diff --git a/client/app/components/ActivityGrid.tsx b/client/app/components/ActivityGrid.tsx index 818d6e3..16953df 100644 --- a/client/app/components/ActivityGrid.tsx +++ b/client/app/components/ActivityGrid.tsx @@ -45,7 +45,6 @@ export default function ActivityGrid({ albumId = 0, trackId = 0, configurable = false, - autoAdjust = false, }: Props) { const [color, setColor] = useState(getPrimaryColor()) @@ -111,24 +110,26 @@ export default function ActivityGrid({ const getDarkenAmount = (v: number, t: number): number => { - if (autoAdjust) { - // automatically adjust the target value based on step - // the smartest way to do this would be to have the api return the - // highest value in the range. too bad im not smart - switch (stepState) { - case 'day': - t = 10 - break; - case 'week': - t = 20 - break; - case 'month': - t = 50 - break; - case 'year': - t = 100 - break; - } + // 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 * adjustment + break; + case 'week': + t = 20 * adjustment + break; + case 'month': + t = 50 * adjustment + break; + case 'year': + t = 100 * adjustment + break; } v = Math.min(v, t) @@ -142,45 +143,58 @@ export default function ActivityGrid({ } } - return (
-

Activity

- {configurable ? ( - - ) : ( - '' - )} -
- {data.map((item) => ( + 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 ( +
+

Activity

+ {configurable ? ( + + ) : null} + + {chunks.map((chunk, index) => (
- + {chunk.map((item) => (
0 - ? 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)'}`} - >
-
+ key={new Date(item.start_time).toString()} + className="w-[10px] sm:w-[12px] h-[10px] sm:h-[12px]" + > + +
0 + ? 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)' + }`} + >
+
+
+ ))}
))}
-
- ); -} +} diff --git a/client/app/components/ActivityOptsSelector.tsx b/client/app/components/ActivityOptsSelector.tsx index 213f8a6..803cb0d 100644 --- a/client/app/components/ActivityOptsSelector.tsx +++ b/client/app/components/ActivityOptsSelector.tsx @@ -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) => { @@ -42,56 +40,66 @@ export default function ActivityOptsSelector({ localStorage.setItem('activity_range_' + window.location.pathname.split('/')[1], String(val)); } }; - + useEffect(() => { if (!disableCache) { const cachedRange = parseInt(localStorage.getItem('activity_range_' + window.location.pathname.split('/')[1]) ?? '35'); - if (cachedRange) { - rangeSetter(cachedRange); - } + 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 ( -
-
-

Step:

- {stepPeriods.map((p, i) => ( -
- - - {i !== stepPeriods.length - 1 ? '|' : ''} - +
+ + +
+
+
+ Step: + {stepPeriods.map((p) => ( + + ))}
- ))} -
-
-

Range:

- {rangePeriods.map((r, i) => ( -
- - - {i !== rangePeriods.length - 1 ? '|' : ''} - +
+ Range: + {rangePeriods.map((r) => ( + + ))}
- ))} +
); diff --git a/client/app/components/LastPlays.tsx b/client/app/components/LastPlays.tsx index c1e1add..9463245 100644 --- a/client/app/components/LastPlays.tsx +++ b/client/app/components/LastPlays.tsx @@ -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, diff --git a/client/app/components/TopItemList.tsx b/client/app/components/TopItemList.tsx index 491625e..5b20d39 100644 --- a/client/app/components/TopItemList.tsx +++ b/client/app/components/TopItemList.tsx @@ -14,7 +14,7 @@ interface Props { export default function TopItemList({ data, separators, type, className }: Props) { return ( -
+
{data.items.map((item, index) => { const key = `${type}-${item.id}`; return ( diff --git a/client/app/components/modals/EditModal/EditModal.tsx b/client/app/components/modals/EditModal/EditModal.tsx index 78ce169..23796f1 100644 --- a/client/app/components/modals/EditModal/EditModal.tsx +++ b/client/app/components/modals/EditModal/EditModal.tsx @@ -95,11 +95,15 @@ export default function EditModal({ open, setOpen, type, id }: Props) { } }) setLoading(false) + } + const handleClose = () => { + setOpen(false) + setInput('') } return ( - setOpen(false)}> +

Alias Manager

diff --git a/client/app/components/modals/ExportModal.tsx b/client/app/components/modals/ExportModal.tsx new file mode 100644 index 0000000..25c8ddf --- /dev/null +++ b/client/app/components/modals/ExportModal.tsx @@ -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 ( +
+

Export

+ Export Data + {error &&

{error}

} +
+ ) +} \ No newline at end of file diff --git a/client/app/components/modals/SettingsModal.tsx b/client/app/components/modals/SettingsModal.tsx index 4ae62d6..31d915b 100644 --- a/client/app/components/modals/SettingsModal.tsx +++ b/client/app/components/modals/SettingsModal.tsx @@ -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 ( - setOpen(false)} maxW={900}> + setOpen(false)} maxW={900}> Appearance Account {user && ( - - API Keys - + <> + + API Keys + + Export + )} @@ -44,6 +49,9 @@ export default function SettingsModal({ open, setOpen } : Props) { + + + ) diff --git a/client/app/components/themeSwitcher/ThemeSwitcher.tsx b/client/app/components/themeSwitcher/ThemeSwitcher.tsx index e051f50..14eda1e 100644 --- a/client/app/components/themeSwitcher/ThemeSwitcher.tsx +++ b/client/app/components/themeSwitcher/ThemeSwitcher.tsx @@ -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 ( - <> -

Select Theme

-
- {themes.map((t) => ( - - ))} +
+
+

Select Theme

+
+ {themes.map((t) => ( + + ))} +
+
+
+

Use Custom Theme

+
+