mirror of
https://github.com/gabehf/Koito.git
synced 2026-03-08 23:18:15 -07:00
feat: Rewind (#116)
* wip * chore: update counts to allow unix timeframe * feat: add db functions for counting new items * wip: endpoint working * wip * wip: initial ui done * add header, adjust ui * add time listened toggle * fix layout, year param * param fixes
This commit is contained in:
parent
c0a8c64243
commit
d4ac96f780
64 changed files with 2252 additions and 1055 deletions
|
|
@ -1,106 +1,124 @@
|
|||
import { logout, updateUser } from "api/api"
|
||||
import { useState } from "react"
|
||||
import { AsyncButton } from "../AsyncButton"
|
||||
import { useAppContext } from "~/providers/AppProvider"
|
||||
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 [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 = () => {
|
||||
setError('')
|
||||
setSuccess('')
|
||||
if (password != "" && confirmPw === "") {
|
||||
setError("confirm your new password before submitting")
|
||||
return
|
||||
const logoutHandler = () => {
|
||||
setLoading(true);
|
||||
logout()
|
||||
.then((r) => {
|
||||
if (r.ok) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
r.json().then((r) => setError(r.error));
|
||||
}
|
||||
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)
|
||||
})
|
||||
.catch((err) => setError(err));
|
||||
setLoading(false);
|
||||
};
|
||||
const updateHandler = () => {
|
||||
setError("");
|
||||
setSuccess("");
|
||||
if (password != "" && confirmPw === "") {
|
||||
setError("confirm your new 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>
|
||||
<form action="#" onSubmit={(e) => e.preventDefault()} className="flex flex-col gap-4">
|
||||
<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="w-sm">
|
||||
<AsyncButton loading={loading} onClick={updateHandler}>Submit</AsyncButton>
|
||||
</div>
|
||||
</form>
|
||||
<form action="#" onSubmit={(e) => e.preventDefault()} className="flex flex-col gap-4">
|
||||
<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 new 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>
|
||||
</form>
|
||||
{success != "" && <p className="success">{success}</p>}
|
||||
{error != "" && <p className="error">{error}</p>}
|
||||
return (
|
||||
<>
|
||||
<h3>Account</h3>
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
<h3>Update User</h3>
|
||||
<form
|
||||
action="#"
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<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="w-sm">
|
||||
<AsyncButton loading={loading} onClick={updateHandler}>
|
||||
Submit
|
||||
</AsyncButton>
|
||||
</div>
|
||||
</form>
|
||||
<form
|
||||
action="#"
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
className="flex flex-col gap-4"
|
||||
>
|
||||
<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 new 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>
|
||||
</form>
|
||||
{success != "" && <p className="success">{success}</p>}
|
||||
{error != "" && <p className="error">{error}</p>}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,53 +5,56 @@ import { submitListen } from "api/api";
|
|||
import { useNavigate } from "react-router";
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
setOpen: Function
|
||||
trackid: number
|
||||
open: boolean;
|
||||
setOpen: Function;
|
||||
trackid: number;
|
||||
}
|
||||
|
||||
export default function AddListenModal({ open, setOpen, trackid }: Props) {
|
||||
const [ts, setTS] = useState<Date>(new Date);
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const navigate = useNavigate()
|
||||
const [ts, setTS] = useState<Date>(new Date());
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const navigate = useNavigate();
|
||||
|
||||
const close = () => {
|
||||
setOpen(false)
|
||||
}
|
||||
const close = () => {
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
setLoading(true)
|
||||
submitListen(trackid.toString(), ts)
|
||||
.then(r => {
|
||||
if(r.ok) {
|
||||
setLoading(false)
|
||||
navigate(0)
|
||||
} else {
|
||||
r.json().then(r => setError(r.error))
|
||||
setLoading(false)
|
||||
}
|
||||
})
|
||||
}
|
||||
const submit = () => {
|
||||
setLoading(true);
|
||||
submitListen(trackid.toString(), ts).then((r) => {
|
||||
if (r.ok) {
|
||||
setLoading(false);
|
||||
navigate(0);
|
||||
} else {
|
||||
r.json().then((r) => setError(r.error));
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const formatForDatetimeLocal = (d: Date) => {
|
||||
const pad = (n: number) => n.toString().padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
};
|
||||
const formatForDatetimeLocal = (d: Date) => {
|
||||
const pad = (n: number) => n.toString().padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(
|
||||
d.getDate()
|
||||
)}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal isOpen={open} onClose={close}>
|
||||
<h2>Add Listen</h2>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="w-full mx-auto fg bg rounded p-2"
|
||||
value={formatForDatetimeLocal(ts)}
|
||||
onChange={(e) => setTS(new Date(e.target.value))}
|
||||
/>
|
||||
<AsyncButton loading={loading} onClick={submit}>Submit</AsyncButton>
|
||||
<p className="error">{error}</p>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
return (
|
||||
<Modal isOpen={open} onClose={close}>
|
||||
<h3>Add Listen</h3>
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<input
|
||||
type="datetime-local"
|
||||
className="w-full mx-auto fg bg rounded p-2"
|
||||
value={formatForDatetimeLocal(ts)}
|
||||
onChange={(e) => setTS(new Date(e.target.value))}
|
||||
/>
|
||||
<AsyncButton loading={loading} onClick={submit}>
|
||||
Submit
|
||||
</AsyncButton>
|
||||
<p className="error">{error}</p>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,172 +5,183 @@ import { useEffect, useRef, useState } from "react";
|
|||
import { Copy, Trash } from "lucide-react";
|
||||
|
||||
type CopiedState = {
|
||||
x: number;
|
||||
y: number;
|
||||
visible: boolean;
|
||||
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 [expandedKey, setExpandedKey] = useState<string | null>(null);
|
||||
const textRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
|
||||
const handleRevealAndSelect = (key: string) => {
|
||||
setExpandedKey(key);
|
||||
setTimeout(() => {
|
||||
const el = textRefs.current[key];
|
||||
if (el) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
const sel = window.getSelection();
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
queryKey: [
|
||||
'api-keys'
|
||||
],
|
||||
queryFn: () => {
|
||||
return getApiKeys();
|
||||
},
|
||||
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 [expandedKey, setExpandedKey] = useState<string | null>(null);
|
||||
const textRefs = useRef<Record<string, HTMLDivElement | null>>({});
|
||||
|
||||
const handleRevealAndSelect = (key: string) => {
|
||||
setExpandedKey(key);
|
||||
setTimeout(() => {
|
||||
const el = textRefs.current[key];
|
||||
if (el) {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
const sel = window.getSelection();
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
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) => {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).catch(() => fallbackCopy(text));
|
||||
} else {
|
||||
fallbackCopy(text);
|
||||
}
|
||||
|
||||
const parentRect = (
|
||||
e.currentTarget.closest(".relative") as HTMLElement
|
||||
).getBoundingClientRect();
|
||||
const buttonRect = e.currentTarget.getBoundingClientRect();
|
||||
|
||||
setCopied({
|
||||
x: buttonRect.left - parentRect.left + buttonRect.width / 2,
|
||||
y: buttonRect.top - parentRect.top - 8,
|
||||
visible: true,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setDisplayData(data)
|
||||
}
|
||||
}, [data])
|
||||
setTimeout(() => setCopied(null), 1500);
|
||||
};
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<p className="error">Error: {error.message}</p>
|
||||
)
|
||||
const fallbackCopy = (text: string) => {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.style.position = "fixed"; // prevent scroll to bottom
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
try {
|
||||
document.execCommand("copy");
|
||||
} catch (err) {
|
||||
console.error("Fallback: Copy failed", err);
|
||||
}
|
||||
if (isPending) {
|
||||
return (
|
||||
<p>Loading...</p>
|
||||
)
|
||||
document.body.removeChild(textarea);
|
||||
};
|
||||
|
||||
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 handleCopy = (e: React.MouseEvent<HTMLButtonElement>, text: string) => {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(text).catch(() => fallbackCopy(text));
|
||||
} else {
|
||||
fallbackCopy(text);
|
||||
}
|
||||
|
||||
const parentRect = (e.currentTarget.closest(".relative") as HTMLElement).getBoundingClientRect();
|
||||
const buttonRect = e.currentTarget.getBoundingClientRect();
|
||||
|
||||
setCopied({
|
||||
x: buttonRect.left - parentRect.left + buttonRect.width / 2,
|
||||
y: buttonRect.top - parentRect.top - 8,
|
||||
visible: true,
|
||||
});
|
||||
|
||||
setTimeout(() => setCopied(null), 1500);
|
||||
};
|
||||
|
||||
const fallbackCopy = (text: string) => {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.style.position = "fixed"; // prevent scroll to bottom
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
try {
|
||||
document.execCommand("copy");
|
||||
} catch (err) {
|
||||
console.error("Fallback: Copy failed", err);
|
||||
}
|
||||
document.body.removeChild(textarea);
|
||||
};
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
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
|
||||
key={v.key}
|
||||
ref={el => {
|
||||
textRefs.current[v.key] = el;
|
||||
}}
|
||||
onClick={() => handleRevealAndSelect(v.key)}
|
||||
className={`bg p-3 rounded-md flex-grow cursor-pointer select-text ${
|
||||
expandedKey === v.key ? '' : 'truncate'
|
||||
}`}
|
||||
style={{ whiteSpace: 'nowrap' }}
|
||||
title={v.key} // optional tooltip
|
||||
>
|
||||
{expandedKey === v.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>
|
||||
return (
|
||||
<div className="">
|
||||
<h3>API Keys</h3>
|
||||
<div className="flex flex-col gap-4 relative">
|
||||
{displayData.map((v) => (
|
||||
<div className="flex gap-2">
|
||||
<div
|
||||
key={v.key}
|
||||
ref={(el) => {
|
||||
textRefs.current[v.key] = el;
|
||||
}}
|
||||
onClick={() => handleRevealAndSelect(v.key)}
|
||||
className={`bg p-3 rounded-md flex-grow cursor-pointer select-text ${
|
||||
expandedKey === v.key ? "" : "truncate"
|
||||
}`}
|
||||
style={{ whiteSpace: "nowrap" }}
|
||||
title={v.key} // optional tooltip
|
||||
>
|
||||
{expandedKey === v.key
|
||||
? v.key
|
||||
: `${v.key.slice(0, 8)}... ${v.label}`}
|
||||
</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>
|
||||
)}
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,40 +1,41 @@
|
|||
import { deleteItem } from "api/api"
|
||||
import { AsyncButton } from "../AsyncButton"
|
||||
import { Modal } from "./Modal"
|
||||
import { useNavigate } from "react-router"
|
||||
import { useState } from "react"
|
||||
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
|
||||
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 [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)
|
||||
}
|
||||
})
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Modal isOpen={open} onClose={() => setOpen(false)}>
|
||||
<h3>Delete "{title}"?</h3>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ export default function EditModal({ open, setOpen, type, id }: Props) {
|
|||
<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>
|
||||
<h3>Alias Manager</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
{displayData.map((v) => (
|
||||
<div className="flex gap-2">
|
||||
|
|
|
|||
|
|
@ -1,99 +1,99 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getAlbum, type Artist } from "api/api";
|
||||
import { useEffect, useState } from "react"
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface Props {
|
||||
id: number
|
||||
type: string
|
||||
id: number;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export default function SetPrimaryArtist({ id, type }: Props) {
|
||||
const [err, setErr] = useState('')
|
||||
const [primary, setPrimary] = useState<Artist>()
|
||||
const [success, setSuccess] = useState('')
|
||||
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
queryKey: [
|
||||
'get-artists-'+type.toLowerCase(),
|
||||
{
|
||||
id: id
|
||||
},
|
||||
],
|
||||
queryFn: () => {
|
||||
return fetch('/apis/web/v1/artists?'+type.toLowerCase()+'_id='+id).then(r => r.json()) as Promise<Artist[]>;
|
||||
},
|
||||
});
|
||||
const [err, setErr] = useState("");
|
||||
const [primary, setPrimary] = useState<Artist>();
|
||||
const [success, setSuccess] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
for (let a of data) {
|
||||
if (a.is_primary) {
|
||||
setPrimary(a)
|
||||
break
|
||||
}
|
||||
}
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
queryKey: [
|
||||
"get-artists-" + type.toLowerCase(),
|
||||
{
|
||||
id: id,
|
||||
},
|
||||
],
|
||||
queryFn: () => {
|
||||
return fetch(
|
||||
"/apis/web/v1/artists?" + type.toLowerCase() + "_id=" + id
|
||||
).then((r) => r.json()) as Promise<Artist[]>;
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
for (let a of data) {
|
||||
if (a.is_primary) {
|
||||
setPrimary(a);
|
||||
break;
|
||||
}
|
||||
}, [data])
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<p className="error">Error: {error.message}</p>
|
||||
)
|
||||
}
|
||||
if (isPending) {
|
||||
return (
|
||||
<p>Loading...</p>
|
||||
)
|
||||
}
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const updatePrimary = (artist: number, val: boolean) => {
|
||||
setErr('');
|
||||
setSuccess('');
|
||||
fetch(`/apis/web/v1/artists/primary?artist_id=${artist}&${type.toLowerCase()}_id=${id}&is_primary=${val}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded"
|
||||
if (isError) {
|
||||
return <p className="error">Error: {error.message}</p>;
|
||||
}
|
||||
if (isPending) {
|
||||
return <p>Loading...</p>;
|
||||
}
|
||||
|
||||
const updatePrimary = (artist: number, val: boolean) => {
|
||||
setErr("");
|
||||
setSuccess("");
|
||||
fetch(
|
||||
`/apis/web/v1/artists/primary?artist_id=${artist}&${type.toLowerCase()}_id=${id}&is_primary=${val}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
}
|
||||
).then((r) => {
|
||||
if (r.ok) {
|
||||
setSuccess("successfully updated primary artists");
|
||||
} else {
|
||||
r.json().then((r) => setErr(r.error));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<h3>Set Primary Artist</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
<select
|
||||
name="mark-various-artists"
|
||||
id="mark-various-artists"
|
||||
className="w-60 px-3 py-2 rounded-md"
|
||||
value={primary?.name || ""}
|
||||
onChange={(e) => {
|
||||
for (let a of data) {
|
||||
if (a.name === e.target.value) {
|
||||
setPrimary(a);
|
||||
updatePrimary(a.id, true);
|
||||
}
|
||||
}
|
||||
})
|
||||
.then(r => {
|
||||
if (r.ok) {
|
||||
setSuccess('successfully updated primary artists');
|
||||
} else {
|
||||
r.json().then(r => setErr(r.error));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<h2>Set Primary Artist</h2>
|
||||
<div className="flex flex-col gap-4">
|
||||
<select
|
||||
name="mark-various-artists"
|
||||
id="mark-various-artists"
|
||||
className="w-60 px-3 py-2 rounded-md"
|
||||
value={primary?.name || ""}
|
||||
onChange={(e) => {
|
||||
for (let a of data) {
|
||||
if (a.name === e.target.value) {
|
||||
setPrimary(a);
|
||||
updatePrimary(a.id, true);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select an artist
|
||||
</option>
|
||||
{data.map((a) => (
|
||||
<option key={a.id} value={a.name}>
|
||||
{a.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{err && <p className="error">{err}</p>}
|
||||
{success && <p className="success">{success}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="" disabled>
|
||||
Select an artist
|
||||
</option>
|
||||
{data.map((a) => (
|
||||
<option key={a.id} value={a.name}>
|
||||
{a.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{err && <p className="error">{err}</p>}
|
||||
{success && <p className="success">{success}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,80 +1,77 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getAlbum } from "api/api";
|
||||
import { useEffect, useState } from "react"
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface Props {
|
||||
id: number
|
||||
id: number;
|
||||
}
|
||||
|
||||
export default function SetVariousArtists({ id }: Props) {
|
||||
const [err, setErr] = useState('')
|
||||
const [va, setVA] = useState(false)
|
||||
const [success, setSuccess] = useState('')
|
||||
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
queryKey: [
|
||||
'get-album',
|
||||
{
|
||||
id: id
|
||||
},
|
||||
],
|
||||
queryFn: ({ queryKey }) => {
|
||||
const params = queryKey[1] as { id: number };
|
||||
return getAlbum(params.id);
|
||||
},
|
||||
const [err, setErr] = useState("");
|
||||
const [va, setVA] = useState(false);
|
||||
const [success, setSuccess] = useState("");
|
||||
|
||||
const { isPending, isError, data, error } = useQuery({
|
||||
queryKey: [
|
||||
"get-album",
|
||||
{
|
||||
id: id,
|
||||
},
|
||||
],
|
||||
queryFn: ({ queryKey }) => {
|
||||
const params = queryKey[1] as { id: number };
|
||||
return getAlbum(params.id);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setVA(data.is_various_artists);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
if (isError) {
|
||||
return <p className="error">Error: {error.message}</p>;
|
||||
}
|
||||
if (isPending) {
|
||||
return <p>Loading...</p>;
|
||||
}
|
||||
|
||||
const updateVA = (val: boolean) => {
|
||||
setErr("");
|
||||
setSuccess("");
|
||||
fetch(`/apis/web/v1/album?id=${id}&is_various_artists=${val}`, {
|
||||
method: "PATCH",
|
||||
}).then((r) => {
|
||||
if (r.ok) {
|
||||
setSuccess("Successfully updated album");
|
||||
} else {
|
||||
r.json().then((r) => setErr(r.error));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setVA(data.is_various_artists)
|
||||
}
|
||||
}, [data])
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<p className="error">Error: {error.message}</p>
|
||||
)
|
||||
}
|
||||
if (isPending) {
|
||||
return (
|
||||
<p>Loading...</p>
|
||||
)
|
||||
}
|
||||
|
||||
const updateVA = (val: boolean) => {
|
||||
setErr('');
|
||||
setSuccess('');
|
||||
fetch(`/apis/web/v1/album?id=${id}&is_various_artists=${val}`, { method: 'PATCH' })
|
||||
.then(r => {
|
||||
if (r.ok) {
|
||||
setSuccess('Successfully updated album');
|
||||
} else {
|
||||
r.json().then(r => setErr(r.error));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<h2>Mark as Various Artists</h2>
|
||||
<div className="flex flex-col gap-4">
|
||||
<select
|
||||
name="mark-various-artists"
|
||||
id="mark-various-artists"
|
||||
className="w-30 px-3 py-2 rounded-md"
|
||||
value={va.toString()}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value === 'true';
|
||||
setVA(val);
|
||||
updateVA(val);
|
||||
}}
|
||||
>
|
||||
<option value="true">True</option>
|
||||
<option value="false">False</option>
|
||||
</select>
|
||||
{err && <p className="error">{err}</p>}
|
||||
{success && <p className="success">{success}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="w-full">
|
||||
<h3>Mark as Various Artists</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
<select
|
||||
name="mark-various-artists"
|
||||
id="mark-various-artists"
|
||||
className="w-30 px-3 py-2 rounded-md"
|
||||
value={va.toString()}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value === "true";
|
||||
setVA(val);
|
||||
updateVA(val);
|
||||
}}
|
||||
>
|
||||
<option value="true">True</option>
|
||||
<option value="false">False</option>
|
||||
</select>
|
||||
{err && <p className="error">{err}</p>}
|
||||
{success && <p className="success">{success}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,43 +3,45 @@ import { AsyncButton } from "../AsyncButton";
|
|||
import { getExport } from "api/api";
|
||||
|
||||
export default function ExportModal() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
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)
|
||||
})
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<h3>Export</h3>
|
||||
<AsyncButton loading={loading} onClick={handleExport}>
|
||||
Export Data
|
||||
</AsyncButton>
|
||||
{error && <p className="error">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ export default function ImageReplaceModal({
|
|||
|
||||
return (
|
||||
<Modal isOpen={open} onClose={closeModal}>
|
||||
<h2>Replace Image</h2>
|
||||
<h3>Replace Image</h3>
|
||||
<div className="flex flex-col items-center">
|
||||
<input
|
||||
type="text"
|
||||
|
|
|
|||
|
|
@ -1,59 +1,74 @@
|
|||
import { login } from "api/api"
|
||||
import { useEffect, useState } from "react"
|
||||
import { AsyncButton } from "../AsyncButton"
|
||||
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 [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")
|
||||
}
|
||||
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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<h3>Log In</h3>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,128 +2,158 @@ 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 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
|
||||
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 [replaceImage, setReplaceImage] = useState(false)
|
||||
const navigate = useNavigate()
|
||||
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 [replaceImage, setReplaceImage] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const closeMergeModal = () => {
|
||||
props.setOpen(false);
|
||||
setQuery("");
|
||||
setData(undefined);
|
||||
setMergeOrderReversed(false);
|
||||
setMergeTarget({ title: "", id: 0 });
|
||||
};
|
||||
|
||||
const closeMergeModal = () => {
|
||||
props.setOpen(false)
|
||||
setQuery('')
|
||||
setData(undefined)
|
||||
setMergeOrderReversed(false)
|
||||
setMergeTarget({title: '', id: 0})
|
||||
const toggleSelect = ({ title, id }: { title: string; id: number }) => {
|
||||
setMergeTarget({ title: title, id: id });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
console.log("mergeTarget", 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;
|
||||
}
|
||||
|
||||
const toggleSelect = ({title, id}: {title: string, id: number}) => {
|
||||
setMergeTarget({title: title, id: id})
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
console.log("mergeTarget",mergeTarget)
|
||||
}, [mergeTarget])
|
||||
|
||||
const doMerge = () => {
|
||||
let from, to
|
||||
if (!mergeOrderReversed) {
|
||||
from = mergeTarget
|
||||
to = {id: props.currentId, title: props.currentTitle}
|
||||
props
|
||||
.mergeFunc(from.id, to.id, replaceImage)
|
||||
.then((r) => {
|
||||
if (r.ok) {
|
||||
if (mergeOrderReversed) {
|
||||
navigate(`/${props.type.toLowerCase()}/${mergeTarget.id}`);
|
||||
closeMergeModal();
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
} else {
|
||||
from = {id: props.currentId, title: props.currentTitle}
|
||||
to = mergeTarget
|
||||
// TODO: handle error
|
||||
console.log(r);
|
||||
}
|
||||
props.mergeFunc(from.id, to.id, replaceImage)
|
||||
.then(r => {
|
||||
if (r.ok) {
|
||||
if (mergeOrderReversed) {
|
||||
navigate(`/${props.type.toLowerCase()}/${mergeTarget.id}`)
|
||||
closeMergeModal()
|
||||
} else {
|
||||
window.location.reload()
|
||||
}
|
||||
} else {
|
||||
// TODO: handle error
|
||||
console.log(r)
|
||||
}
|
||||
})
|
||||
.catch((err) => console.log(err))
|
||||
})
|
||||
.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);
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedQuery(query);
|
||||
if (query === '') {
|
||||
setData(undefined)
|
||||
}
|
||||
}, 300);
|
||||
}, [debouncedQuery]);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
useEffect(() => {
|
||||
if (debouncedQuery) {
|
||||
search(debouncedQuery).then((r) => {
|
||||
r = props.mergeCleanerFunc(r, props.currentId)
|
||||
setData(r);
|
||||
});
|
||||
}
|
||||
}, [debouncedQuery]);
|
||||
|
||||
return (
|
||||
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>
|
||||
<h3>Merge {props.type}s</h3>
|
||||
<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>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="reverse-merge-order"
|
||||
checked={mergeOrderReversed}
|
||||
onChange={() => setMergeOrderReversed(!mergeOrderReversed)}
|
||||
/>
|
||||
<label htmlFor="reverse-merge-order">Reverse merge order</label>
|
||||
</div>
|
||||
{
|
||||
(props.type.toLowerCase() === "album" || props.type.toLowerCase() === "artist") &&
|
||||
<div className="flex gap-2 mt-3">
|
||||
<input type="checkbox" name="replace-image" checked={replaceImage} onChange={() => setReplaceImage(!replaceImage)} />
|
||||
{(props.type.toLowerCase() === "album" ||
|
||||
props.type.toLowerCase() === "artist") && (
|
||||
<div className="flex gap-2 mt-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="replace-image"
|
||||
checked={replaceImage}
|
||||
onChange={() => setReplaceImage(!replaceImage)}
|
||||
/>
|
||||
<label htmlFor="replace-image">Replace image</label>
|
||||
</div>
|
||||
}
|
||||
</> :
|
||||
''}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,57 +4,57 @@ import { search, type SearchResponse } from "api/api";
|
|||
import SearchResults from "../SearchResults";
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
setOpen: Function
|
||||
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 [query, setQuery] = useState("");
|
||||
const [data, setData] = useState<SearchResponse>();
|
||||
const [debouncedQuery, setDebouncedQuery] = useState(query);
|
||||
|
||||
const closeSearchModal = () => {
|
||||
setOpen(false)
|
||||
setQuery('')
|
||||
setData(undefined)
|
||||
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]);
|
||||
|
||||
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>
|
||||
)
|
||||
return (
|
||||
<Modal isOpen={open} onClose={closeSearchModal}>
|
||||
<h3>Search</h3>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue