mirror of
https://github.com/gabehf/Koito.git
synced 2026-03-14 18:05:55 -07:00
chore: initial public commit
This commit is contained in:
commit
fc9054b78c
250 changed files with 32809 additions and 0 deletions
97
client/app/components/modals/Account.tsx
Normal file
97
client/app/components/modals/Account.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
17
client/app/components/modals/AccountPage.tsx
Normal file
17
client/app/components/modals/AccountPage.tsx
Normal 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 />
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
129
client/app/components/modals/ApiKeysModal.tsx
Normal file
129
client/app/components/modals/ApiKeysModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
40
client/app/components/modals/DeleteModal.tsx
Normal file
40
client/app/components/modals/DeleteModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
90
client/app/components/modals/ImageReplaceModal.tsx
Normal file
90
client/app/components/modals/ImageReplaceModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
59
client/app/components/modals/LoginForm.tsx
Normal file
59
client/app/components/modals/LoginForm.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
125
client/app/components/modals/MergeModal.tsx
Normal file
125
client/app/components/modals/MergeModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
84
client/app/components/modals/Modal.tsx
Normal file
84
client/app/components/modals/Modal.tsx
Normal 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
|
||||
);
|
||||
}
|
||||
124
client/app/components/modals/RenameModal.tsx
Normal file
124
client/app/components/modals/RenameModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
60
client/app/components/modals/SearchModal.tsx
Normal file
60
client/app/components/modals/SearchModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
41
client/app/components/modals/SettingsModal.tsx
Normal file
41
client/app/components/modals/SettingsModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue