chore: initial public commit

This commit is contained in:
Gabe Farrell 2025-06-11 19:45:39 -04:00
commit fc9054b78c
250 changed files with 32809 additions and 0 deletions

View file

@ -0,0 +1,97 @@
import { logout, updateUser } from "api/api"
import { useState } from "react"
import { AsyncButton } from "../AsyncButton"
import { useAppContext } from "~/providers/AppProvider"
export default function Account() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [confirmPw, setConfirmPw] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const { user, setUsername: setCtxUsername } = useAppContext()
const logoutHandler = () => {
setLoading(true)
logout()
.then(r => {
if (r.ok) {
window.location.reload()
} else {
r.json().then(r => setError(r.error))
}
}).catch(err => setError(err))
setLoading(false)
}
const updateHandler = () => {
if (password != "" && confirmPw === "") {
setError("confirm your password before submitting")
return
}
setError('')
setSuccess('')
setLoading(true)
updateUser(username, password)
.then(r => {
if (r.ok) {
setSuccess("sucessfully updated user")
if (username != "") {
setCtxUsername(username)
}
setUsername('')
setPassword('')
setConfirmPw('')
} else {
r.json().then((r) => setError(r.error))
}
}).catch(err => setError(err))
setLoading(false)
}
return (
<>
<h2>Account</h2>
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4 items-center">
<p>You're logged in as <strong>{user?.username}</strong></p>
<AsyncButton loading={loading} onClick={logoutHandler}>Logout</AsyncButton>
</div>
<h2>Update User</h2>
<div className="flex flex gap-4">
<input
name="koito-update-username"
type="text"
placeholder="Update username"
className="w-full mx-auto fg bg rounded p-2"
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
</div>
<div className="flex flex gap-4">
<input
name="koito-update-password"
type="password"
placeholder="Update password"
className="w-full mx-auto fg bg rounded p-2"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<input
name="koito-confirm-password"
type="password"
placeholder="Confirm password"
className="w-full mx-auto fg bg rounded p-2"
value={confirmPw}
onChange={(e) => setConfirmPw(e.target.value)}
/>
</div>
<div className="w-sm">
<AsyncButton loading={loading} onClick={updateHandler}>Submit</AsyncButton>
</div>
{success != "" && <p className="success">{success}</p>}
{error != "" && <p className="error">{error}</p>}
</div>
</>
)
}

View file

@ -0,0 +1,17 @@
import { useAppContext } from "~/providers/AppProvider"
import LoginForm from "./LoginForm"
import Account from "./Account"
export default function AuthForm() {
const { user } = useAppContext()
return (
<>
{ user ?
<Account />
:
<LoginForm />
}
</>
)
}

View file

@ -0,0 +1,129 @@
import { useQuery } from "@tanstack/react-query";
import { createApiKey, deleteApiKey, getApiKeys, type ApiKey } from "api/api";
import { AsyncButton } from "../AsyncButton";
import { useEffect, useState } from "react";
import { Copy, Trash } from "lucide-react";
type CopiedState = {
x: number;
y: number;
visible: boolean;
};
export default function ApiKeysModal() {
const [input, setInput] = useState('')
const [loading, setLoading ] = useState(false)
const [err, setError ] = useState<string>()
const [displayData, setDisplayData] = useState<ApiKey[]>([])
const [copied, setCopied] = useState<CopiedState | null>(null);
const { isPending, isError, data, error } = useQuery({
queryKey: [
'api-keys'
],
queryFn: () => {
return getApiKeys();
},
});
useEffect(() => {
if (data) {
setDisplayData(data)
}
}, [data])
if (isError) {
return (
<p className="error">Error: {error.message}</p>
)
}
if (isPending) {
return (
<p>Loading...</p>
)
}
const handleCopy = (e: React.MouseEvent<HTMLButtonElement>, text: string) => {
navigator.clipboard.writeText(text);
const parentRect = (e.currentTarget.closest(".relative") as HTMLElement).getBoundingClientRect();
const buttonRect = e.currentTarget.getBoundingClientRect();
setCopied({
x: buttonRect.left - parentRect.left + buttonRect.width / 2, // center of button
y: buttonRect.top - parentRect.top - 8, // above the button
visible: true,
});
setTimeout(() => setCopied(null), 1500);
};
const handleCreateApiKey = () => {
setError(undefined)
if (input === "") {
setError("a label must be provided")
return
}
setLoading(true)
createApiKey(input)
.then(r => {
setDisplayData([r, ...displayData])
setInput('')
}).catch((err) => setError(err.message))
setLoading(false)
}
const handleDeleteApiKey = (id: number) => {
setError(undefined)
setLoading(true)
deleteApiKey(id)
.then(r => {
if (r.ok) {
setDisplayData(displayData.filter((v) => v.id != id))
} else {
r.json().then((r) => setError(r.error))
}
})
setLoading(false)
}
return (
<div className="">
<h2>API Keys</h2>
<div className="flex flex-col gap-4 relative">
{displayData.map((v) => (
<div className="flex gap-2">
<div className="bg p-3 rounded-md flex-grow" key={v.key}>{v.key.slice(0, 8)+'...'} {v.label}</div>
<button onClick={(e) => handleCopy(e, v.key)} className="large-button px-5 rounded-md"><Copy size={16} /></button>
<AsyncButton loading={loading} onClick={() => handleDeleteApiKey(v.id)} confirm><Trash size={16} /></AsyncButton>
</div>
))}
<div className="flex gap-2 w-3/5">
<input
type="text"
placeholder="Add a label for a new API key"
className="mx-auto fg bg rounded-md p-3 flex-grow"
value={input}
onChange={(e) => setInput(e.target.value)}
/>
<AsyncButton loading={loading} onClick={handleCreateApiKey}>Create</AsyncButton>
</div>
{err && <p className="error">{err}</p>}
{copied?.visible && (
<div
style={{
position: "absolute",
top: copied.y,
left: copied.x,
transform: "translate(-50%, -100%)",
}}
className="pointer-events-none bg-black text-white text-sm px-2 py-1 rounded shadow-lg opacity-90 animate-fade"
>
Copied!
</div>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,40 @@
import { deleteItem } from "api/api"
import { AsyncButton } from "../AsyncButton"
import { Modal } from "./Modal"
import { useNavigate } from "react-router"
import { useState } from "react"
interface Props {
open: boolean
setOpen: Function
title: string,
id: number,
type: string
}
export default function DeleteModal({ open, setOpen, title, id, type }: Props) {
const [loading, setLoading] = useState(false)
const navigate = useNavigate()
const doDelete = () => {
setLoading(true)
deleteItem(type.toLowerCase(), id)
.then(r => {
if (r.ok) {
navigate('/')
} else {
console.log(r)
}
})
}
return (
<Modal isOpen={open} onClose={() => setOpen(false)}>
<h2>Delete "{title}"?</h2>
<p>This action is irreversible!</p>
<div className="flex flex-col mt-3 items-center">
<AsyncButton loading={loading} onClick={doDelete}>Yes, Delete It</AsyncButton>
</div>
</Modal>
)
}

View file

@ -0,0 +1,90 @@
import { useEffect, useState } from "react";
import { Modal } from "./Modal";
import { replaceImage, search, type SearchResponse } from "api/api";
import SearchResults from "../SearchResults";
import { AsyncButton } from "../AsyncButton";
interface Props {
type: string
id: number
musicbrainzId?: string
open: boolean
setOpen: Function
}
export default function ImageReplaceModal({ musicbrainzId, type, id, open, setOpen }: Props) {
const [query, setQuery] = useState('');
const [loading, setLoading] = useState(false)
const [suggestedImgLoading, setSuggestedImgLoading] = useState(true)
const doImageReplace = (url: string) => {
setLoading(true)
const formData = new FormData
formData.set(`${type.toLowerCase()}_id`, id.toString())
formData.set("image_url", url)
replaceImage(formData)
.then((r) => {
if (r.ok) {
window.location.reload()
} else {
console.log(r)
setLoading(false)
}
})
.catch((err) => console.log(err))
}
const closeModal = () => {
setOpen(false)
setQuery('')
}
return (
<Modal isOpen={open} onClose={closeModal}>
<h2>Replace Image</h2>
<div className="flex flex-col items-center">
<input
type="text"
autoFocus
// i find my stupid a(n) logic to be a little silly so im leaving it in even if its not optimal
placeholder={`Image URL`}
className="w-full mx-auto fg bg rounded p-2"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
{ query != "" ?
<div className="flex gap-2 mt-4">
<AsyncButton loading={loading} onClick={() => doImageReplace(query)}>Submit</AsyncButton>
</div> :
''}
{ type === "Album" && musicbrainzId ?
<>
<h3 className="mt-5">Suggested Image (Click to Apply)</h3>
<button
className="mt-4"
disabled={loading}
onClick={() => doImageReplace(`https://coverartarchive.org/release/${musicbrainzId}/front`)}
>
<div className={`relative`}>
{suggestedImgLoading && (
<div className="absolute inset-0 flex items-center justify-center">
<div
className="animate-spin rounded-full border-2 border-gray-300 border-t-transparent"
style={{ width: 20, height: 20 }}
/>
</div>
)}
<img
src={`https://coverartarchive.org/release/${musicbrainzId}/front`}
onLoad={() => setSuggestedImgLoading(false)}
onError={() => setSuggestedImgLoading(false)}
className={`block w-[130px] h-auto ${suggestedImgLoading ? 'opacity-0' : 'opacity-100'} transition-opacity duration-300`} />
</div>
</button>
</>
: ''
}
</div>
</Modal>
)
}

View file

@ -0,0 +1,59 @@
import { login } from "api/api"
import { useEffect, useState } from "react"
import { AsyncButton } from "../AsyncButton"
export default function LoginForm() {
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [remember, setRemember] = useState(false)
const loginHandler = () => {
if (username && password) {
setLoading(true)
login(username, password, remember)
.then(r => {
if (r.status >= 200 && r.status < 300) {
window.location.reload()
} else {
r.json().then(r => setError(r.error))
}
}).catch(err => setError(err))
setLoading(false)
} else if (username || password) {
setError("username and password are required")
}
}
return (
<>
<h2>Log In</h2>
<div className="flex flex-col items-center gap-4 w-full">
<p>Logging in gives you access to <strong>admin tools</strong>, such as updating images, merging items, deleting items, and more.</p>
<form action="#" className="flex flex-col items-center gap-4 w-3/4" onSubmit={(e) => e.preventDefault()}>
<input
name="koito-username"
type="text"
placeholder="Username"
className="w-full mx-auto fg bg rounded p-2"
onChange={(e) => setUsername(e.target.value)}
/>
<input
name="koito-password"
type="password"
placeholder="Password"
className="w-full mx-auto fg bg rounded p-2"
onChange={(e) => setPassword(e.target.value)}
/>
<div className="flex gap-2">
<input type="checkbox" name="koito-remember" id="koito-remember" onChange={() => setRemember(!remember)} />
<label htmlFor="kotio-remember">Remember me</label>
</div>
<AsyncButton loading={loading} onClick={loginHandler}>Login</AsyncButton>
</form>
<p className="error">{error}</p>
</div>
</>
)
}

View file

@ -0,0 +1,125 @@
import { useEffect, useState } from "react";
import { Modal } from "./Modal";
import { search, type SearchResponse } from "api/api";
import SearchResults from "../SearchResults";
import type { MergeFunc, MergeSearchCleanerFunc } from "~/routes/MediaItems/MediaLayout";
import { useNavigate } from "react-router";
interface Props {
open: boolean
setOpen: Function
type: string
currentId: number
currentTitle: string
mergeFunc: MergeFunc
mergeCleanerFunc: MergeSearchCleanerFunc
}
export default function MergeModal(props: Props) {
const [query, setQuery] = useState('');
const [data, setData] = useState<SearchResponse>();
const [debouncedQuery, setDebouncedQuery] = useState(query);
const [mergeTarget, setMergeTarget] = useState<{title: string, id: number}>({title: '', id: 0})
const [mergeOrderReversed, setMergeOrderReversed] = useState(false)
const navigate = useNavigate()
const closeMergeModal = () => {
props.setOpen(false)
setQuery('')
setData(undefined)
setMergeOrderReversed(false)
setMergeTarget({title: '', id: 0})
}
const toggleSelect = ({title, id}: {title: string, id: number}) => {
if (mergeTarget.id === 0) {
setMergeTarget({title: title, id: id})
} else {
setMergeTarget({title:"", id: 0})
}
}
useEffect(() => {
console.log(mergeTarget)
}, [mergeTarget])
const doMerge = () => {
let from, to
if (!mergeOrderReversed) {
from = mergeTarget
to = {id: props.currentId, title: props.currentTitle}
} else {
from = {id: props.currentId, title: props.currentTitle}
to = mergeTarget
}
props.mergeFunc(from.id, to.id)
.then(r => {
if (r.ok) {
if (mergeOrderReversed) {
navigate(`/${props.type.toLowerCase()}/${mergeTarget}`)
closeMergeModal()
} else {
window.location.reload()
}
} else {
// TODO: handle error
console.log(r)
}
})
.catch((err) => console.log(err))
}
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedQuery(query);
if (query === '') {
setData(undefined)
}
}, 300);
return () => {
clearTimeout(handler);
};
}, [query]);
useEffect(() => {
if (debouncedQuery) {
search(debouncedQuery).then((r) => {
r = props.mergeCleanerFunc(r, props.currentId)
setData(r);
});
}
}, [debouncedQuery]);
return (
<Modal isOpen={props.open} onClose={closeMergeModal}>
<h2>Merge {props.type}s</h2>
<div className="flex flex-col items-center">
<input
type="text"
autoFocus
// i find my stupid a(n) logic to be a little silly so im leaving it in even if its not optimal
placeholder={`Search for a${props.type.toLowerCase()[0] === 'a' ? 'n' : ''} ${props.type.toLowerCase()} to be merged into the current ${props.type.toLowerCase()}`}
className="w-full mx-auto fg bg rounded p-2"
onChange={(e) => setQuery(e.target.value)}
/>
<SearchResults selectorMode data={data} onSelect={toggleSelect}/>
{ mergeTarget.id !== 0 ?
<>
{mergeOrderReversed ?
<p className="mt-5"><strong>{props.currentTitle}</strong> will be merged into <strong>{mergeTarget.title}</strong></p>
:
<p className="mt-5"><strong>{mergeTarget.title}</strong> will be merged into <strong>{props.currentTitle}</strong></p>
}
<button className="hover:cursor-pointer px-5 py-2 rounded-md mt-5 bg-(--color-bg) hover:bg-(--color-bg-tertiary)" onClick={doMerge}>Merge Items</button>
<div className="flex gap-2 mt-3">
<input type="checkbox" name="reverse-merge-order" checked={mergeOrderReversed} onChange={() => setMergeOrderReversed(!mergeOrderReversed)} />
<label htmlFor="reverse-merge-order">Reverse merge order</label>
</div>
</> :
''}
</div>
</Modal>
)
}

View file

@ -0,0 +1,84 @@
import { useEffect, useRef, useState } from 'react';
import ReactDOM from 'react-dom';
export function Modal({
isOpen,
onClose,
children,
maxW,
h
}: {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
maxW?: number;
h?: number;
}) {
const modalRef = useRef<HTMLDivElement>(null);
const [shouldRender, setShouldRender] = useState(isOpen);
const [isClosing, setIsClosing] = useState(false);
// Show/hide logic
useEffect(() => {
if (isOpen) {
setShouldRender(true);
setIsClosing(false);
} else if (shouldRender) {
setIsClosing(true);
const timeout = setTimeout(() => {
setShouldRender(false);
}, 100); // Match fade-out duration
return () => clearTimeout(timeout);
}
}, [isOpen, shouldRender]);
// Close on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
if (isOpen) document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
// Close on outside click
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (
modalRef.current &&
!modalRef.current.contains(e.target as Node)
) {
onClose();
}
};
if (isOpen) document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [isOpen, onClose]);
if (!shouldRender) return null;
return ReactDOM.createPortal(
<div
className={`fixed inset-0 z-50 flex items-center justify-center bg-black/50 transition-opacity duration-100 ${
isClosing ? 'animate-fade-out' : 'animate-fade-in'
}`}
>
<div
ref={modalRef}
className={`bg-secondary rounded-lg shadow-md p-6 w-full relative max-h-3/4 overflow-y-auto transition-all duration-100 ${
isClosing ? 'animate-fade-out-scale' : 'animate-fade-in-scale'
}`}
style={{ maxWidth: maxW ?? 600, height: h ?? '' }}
>
<button
onClick={onClose}
className="absolute top-2 right-2 color-fg-tertiary hover:cursor-pointer"
>
🞪
</button>
{children}
</div>
</div>,
document.body
);
}

View file

@ -0,0 +1,124 @@
import { useQuery } from "@tanstack/react-query";
import { createAlias, deleteAlias, getAliases, setPrimaryAlias, type Alias } from "api/api";
import { Modal } from "./Modal";
import { AsyncButton } from "../AsyncButton";
import { useEffect, useState } from "react";
import { Trash } from "lucide-react";
interface Props {
type: string
id: number
open: boolean
setOpen: Function
}
export default function RenameModal({ open, setOpen, type, id }: Props) {
const [input, setInput] = useState('')
const [loading, setLoading ] = useState(false)
const [err, setError ] = useState<string>()
const [displayData, setDisplayData] = useState<Alias[]>([])
const { isPending, isError, data, error } = useQuery({
queryKey: [
'aliases',
{
type: type,
id: id
},
],
queryFn: ({ queryKey }) => {
const params = queryKey[1] as { type: string; id: number };
return getAliases(params.type, params.id);
},
});
useEffect(() => {
if (data) {
setDisplayData(data)
}
}, [data])
if (isError) {
return (
<p className="error">Error: {error.message}</p>
)
}
if (isPending) {
return (
<p>Loading...</p>
)
}
const handleSetPrimary = (alias: string) => {
setError(undefined)
setLoading(true)
setPrimaryAlias(type, id, alias)
.then(r => {
if (r.ok) {
window.location.reload()
} else {
r.json().then((r) => setError(r.error))
}
})
setLoading(false)
}
const handleNewAlias = () => {
setError(undefined)
if (input === "") {
setError("alias must be provided")
return
}
setLoading(true)
createAlias(type, id, input)
.then(r => {
if (r.ok) {
setDisplayData([...displayData, {alias: input, source: "Manual", is_primary: false, id: id}])
} else {
r.json().then((r) => setError(r.error))
}
})
setLoading(false)
}
const handleDeleteAlias = (alias: string) => {
setError(undefined)
setLoading(true)
deleteAlias(type, id, alias)
.then(r => {
if (r.ok) {
setDisplayData(displayData.filter((v) => v.alias != alias))
} else {
r.json().then((r) => setError(r.error))
}
})
setLoading(false)
}
return (
<Modal maxW={1000} isOpen={open} onClose={() => setOpen(false)}>
<h2>Alias Manager</h2>
<div className="flex flex-col gap-4">
{displayData.map((v) => (
<div className="flex gap-2">
<div className="bg p-3 rounded-md flex-grow" key={v.alias}>{v.alias} (source: {v.source})</div>
<AsyncButton loading={loading} onClick={() => handleSetPrimary(v.alias)} disabled={v.is_primary}>Set Primary</AsyncButton>
<AsyncButton loading={loading} onClick={() => handleDeleteAlias(v.alias)} confirm disabled={v.is_primary}><Trash size={16} /></AsyncButton>
</div>
))}
<div className="flex gap-2 w-3/5">
<input
type="text"
placeholder="Add a new alias"
className="mx-auto fg bg rounded-md p-3 flex-grow"
value={input}
onChange={(e) => setInput(e.target.value)}
/>
<AsyncButton loading={loading} onClick={handleNewAlias}>Submit</AsyncButton>
</div>
{err && <p className="error">{err}</p>}
</div>
</Modal>
)
}

View file

@ -0,0 +1,60 @@
import { useEffect, useState } from "react";
import { Modal } from "./Modal";
import { search, type SearchResponse } from "api/api";
import SearchResults from "../SearchResults";
interface Props {
open: boolean
setOpen: Function
}
export default function SearchModal({ open, setOpen }: Props) {
const [query, setQuery] = useState('');
const [data, setData] = useState<SearchResponse>();
const [debouncedQuery, setDebouncedQuery] = useState(query);
const closeSearchModal = () => {
setOpen(false)
setQuery('')
setData(undefined)
}
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedQuery(query);
if (query === '') {
setData(undefined)
}
}, 300);
return () => {
clearTimeout(handler);
};
}, [query]);
useEffect(() => {
if (debouncedQuery) {
search(debouncedQuery).then((r) => {
setData(r);
});
}
}, [debouncedQuery]);
return (
<Modal isOpen={open} onClose={closeSearchModal}>
<h2>Search</h2>
<div className="flex flex-col items-center">
<input
type="text"
autoFocus
placeholder="Search for an artist, album, or track"
className="w-full mx-auto fg bg rounded p-2"
onChange={(e) => setQuery(e.target.value)}
/>
<div className="h-3/4 w-full">
<SearchResults data={data} onSelect={closeSearchModal}/>
</div>
</div>
</Modal>
)
}

View file

@ -0,0 +1,41 @@
import { Modal } from "./Modal"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@radix-ui/react-tabs";
import AccountPage from "./AccountPage";
import { ThemeSwitcher } from "../themeSwitcher/ThemeSwitcher";
import ThemeHelper from "../../routes/ThemeHelper";
import { useAppContext } from "~/providers/AppProvider";
import ApiKeysModal from "./ApiKeysModal";
interface Props {
open: boolean
setOpen: Function
}
export default function SettingsModal({ open, setOpen } : Props) {
const { user } = useAppContext()
const triggerClasses = "px-4 py-2 w-full hover-bg-secondary rounded-md text-start data-[state=active]:bg-[var(--color-bg-secondary)]"
const contentClasses = "w-full px-10 overflow-y-auto"
return (
<Modal h={600} isOpen={open} onClose={() => setOpen(false)} maxW={900}>
<Tabs defaultValue="Appearance" orientation="vertical" className="flex justify-between h-full">
<TabsList className="w-full flex flex-col gap-1 items-start max-w-1/4 rounded-md bg p-2 grow-0">
<TabsTrigger className={triggerClasses} value="Appearance">Appearance</TabsTrigger>
<TabsTrigger className={triggerClasses} value="Account">Account</TabsTrigger>
{ user && <TabsTrigger className={triggerClasses} value="API Keys">API Keys</TabsTrigger>}
</TabsList>
<TabsContent value="Account" className={contentClasses}>
<AccountPage />
</TabsContent>
<TabsContent value="Appearance" className={contentClasses}>
<ThemeSwitcher />
</TabsContent>
<TabsContent value="API Keys" className={contentClasses}>
<ApiKeysModal />
</TabsContent>
</Tabs>
</Modal>
)
}