Spaces:
Build error
Build error
'use client'; | |
import { useStatus } from '@/context/status'; | |
import { PageWrapper } from '../shared/page-wrapper'; | |
import { | |
// SERVICE_DETAILS, | |
ServiceId, | |
} from '../../../../core/src/utils/constants'; | |
import { useUserData } from '@/context/userData'; | |
import { restrictToVerticalAxis } from '@dnd-kit/modifiers'; | |
import { | |
arrayMove, | |
SortableContext, | |
verticalListSortingStrategy, | |
useSortable, | |
} from '@dnd-kit/sortable'; | |
import { CSS } from '@dnd-kit/utilities'; | |
import { IconButton } from '../ui/button'; | |
import { FiArrowLeft, FiArrowRight, FiSettings } from 'react-icons/fi'; | |
import { Switch } from '../ui/switch'; | |
import { Modal } from '../ui/modal'; | |
import { useState, useEffect } from 'react'; | |
import { | |
DndContext, | |
useSensors, | |
PointerSensor, | |
TouchSensor, | |
useSensor, | |
} from '@dnd-kit/core'; | |
import TemplateOption from '../shared/template-option'; | |
import { Button } from '../ui/button'; | |
import MarkdownLite from '../shared/markdown-lite'; | |
import { Alert } from '../ui/alert'; | |
import { useMenu } from '@/context/menu'; | |
import { PageControls } from '../shared/page-controls'; | |
import { SettingsCard } from '../shared/settings-card'; | |
import { TextInput } from '../ui/text-input'; | |
export function ServicesMenu() { | |
return ( | |
<> | |
<PageWrapper className="space-y-4 p-4 sm:p-8"> | |
<Content /> | |
</PageWrapper> | |
</> | |
); | |
} | |
// we show all services, along with its signUpText and a setting icon button, and switch to enable/disable the service. | |
// this will be in a sortable lis twith dnd, similar to the addons menu. | |
// when the setting icon button is clicked, it will open a modal with all the credentials (option definitions) for the service | |
// | |
function Content() { | |
const { status } = useStatus(); | |
if (!status) return null; | |
const { setUserData, userData } = useUserData(); | |
const { setSelectedMenu, nextMenu, previousMenu } = useMenu(); | |
const [modalOpen, setModalOpen] = useState(false); | |
const [modalService, setModalService] = useState<ServiceId | null>(null); | |
const [modalValues, setModalValues] = useState<Record<string, any>>({}); | |
const [isDragging, setIsDragging] = useState(false); | |
// DND logic | |
function handleDragEnd(event: any) { | |
const { active, over } = event; | |
if (!over) return; | |
if (active.id !== over.id) { | |
setUserData((prev) => { | |
const services = prev.services ?? []; | |
const oldIndex = services.findIndex((s) => s.id === active.id); | |
const newIndex = services.findIndex((s) => s.id === over.id); | |
const newServices = arrayMove(services, oldIndex, newIndex); | |
return { ...prev, services: newServices }; | |
}); | |
} | |
setIsDragging(false); | |
} | |
function handleDragStart(event: any) { | |
setIsDragging(true); | |
} | |
// Modal handlers | |
const handleServiceClick = (service: ServiceId) => { | |
setModalService(service); | |
const svc = userData.services?.find((s) => s.id === service); | |
setModalValues(svc?.credentials || {}); | |
setModalOpen(true); | |
}; | |
const handleModalClose = () => { | |
setModalOpen(false); | |
setModalService(null); | |
setModalValues({}); | |
}; | |
const handleModalSubmit = (values: Record<string, any>) => { | |
setUserData((prev) => { | |
const newUserData = { ...prev }; | |
newUserData.services = (newUserData.services ?? []).map((service) => { | |
if (service.id === modalService) { | |
return { | |
...service, | |
enabled: true, | |
credentials: values, | |
}; | |
} | |
return service; | |
}); | |
return newUserData; | |
}); | |
handleModalClose(); | |
}; | |
const handleModalValuesChange = (newValues: Record<string, any>) => { | |
setModalValues((prevValues) => ({ | |
...prevValues, | |
...newValues, | |
})); | |
}; | |
useEffect(() => { | |
const allServiceIds: ServiceId[] = Object.keys( | |
status.settings.services | |
) as ServiceId[]; | |
const currentServices = userData.services ?? []; | |
// Remove any services not in SERVICE_DETAILS and apply forced/default credentials | |
let filtered = currentServices.filter((s) => allServiceIds.includes(s.id)); | |
// Add any missing services from SERVICE_DETAILS | |
const missing = allServiceIds.filter( | |
(id) => !filtered.some((s) => s.id === id) | |
); | |
if (missing.length > 0 || filtered.length !== currentServices.length) { | |
const toAdd = missing.map((id) => { | |
const svcMeta = status.settings.services[id]!; | |
const credentials: Record<string, any> = {}; | |
let enabled = false; | |
return { | |
id, | |
enabled, | |
credentials, | |
}; | |
}); | |
setUserData((prev: any) => ({ | |
...prev, | |
services: [...filtered, ...toAdd], | |
})); | |
} | |
}, [status.settings.services]); | |
const sensors = useSensors( | |
useSensor(PointerSensor), | |
useSensor(TouchSensor, { | |
activationConstraint: { | |
delay: 150, | |
tolerance: 8, | |
}, | |
}) | |
); | |
useEffect(() => { | |
function preventTouchMove(e: TouchEvent) { | |
if (isDragging) { | |
e.preventDefault(); | |
} | |
} | |
function handleDragEnd() { | |
setIsDragging(false); | |
} | |
if (isDragging) { | |
document.body.addEventListener('touchmove', preventTouchMove, { | |
passive: false, | |
}); | |
// Add listeners for when drag ends outside context | |
document.addEventListener('pointerup', handleDragEnd); | |
document.addEventListener('touchend', handleDragEnd); | |
} else { | |
document.body.removeEventListener('touchmove', preventTouchMove); | |
} | |
// Cleanup | |
return () => { | |
document.body.removeEventListener('touchmove', preventTouchMove); | |
document.removeEventListener('pointerup', handleDragEnd); | |
document.removeEventListener('touchend', handleDragEnd); | |
}; | |
}, [isDragging]); | |
const invalidServices = | |
userData.services | |
?.filter((service) => { | |
const svcMeta = status.settings.services[service.id]; | |
if (!svcMeta) return false; | |
// Check if any required credential is missing | |
return ( | |
service.enabled && | |
svcMeta.credentials.some((cred) => !service.credentials?.[cred.id]) | |
); | |
}) | |
.map((service) => status.settings.services[service.id]?.name) ?? []; | |
// Render | |
return ( | |
<> | |
<div className="flex items-center w-full"> | |
<div> | |
<h2>Services</h2> | |
<p className="text-[--muted]"> | |
Provide credentials for any services you want to use. | |
</p> | |
</div> | |
<div className="hidden lg:block lg:ml-auto"> | |
<PageControls /> | |
</div> | |
</div> | |
{invalidServices && invalidServices.length > 0 && ( | |
<div className="mb-6"> | |
<Alert | |
intent="alert" | |
title="Missing Credentials" | |
description={ | |
<> | |
The following services are missing credentials: | |
<div className="flex flex-col gap-1 mt-2"> | |
{invalidServices.map((service) => ( | |
<div key={service} className="flex items-center"> | |
<div className="w-1.5 h-1.5 rounded-full bg-current mr-2" /> | |
{service} | |
</div> | |
))} | |
</div> | |
</> | |
} | |
/> | |
</div> | |
)} | |
<div className="bg-[--card] border border-[--border] rounded-xl p-4 mb-6 shadow-sm"> | |
<DndContext | |
modifiers={[restrictToVerticalAxis]} | |
onDragEnd={handleDragEnd} | |
onDragStart={handleDragStart} | |
sensors={sensors} | |
> | |
<SortableContext | |
items={userData.services?.map((s) => s.id) || []} | |
strategy={verticalListSortingStrategy} | |
> | |
<div className="space-y-2"> | |
<ul className="space-y-2"> | |
{(userData.services?.length ?? 0) === 0 ? ( | |
<li> | |
<div className="flex flex-col items-center justify-center py-12"> | |
<span className="text-lg text-muted-foreground font-semibold text-center"> | |
Looks like you don't have any services configured. | |
<br /> | |
Add and configure services above. | |
</span> | |
</div> | |
</li> | |
) : ( | |
userData.services?.map((service, idx) => { | |
const svcMeta = status.settings.services[service.id]!; | |
return ( | |
<SortableServiceItem | |
key={service.id} | |
service={service} | |
meta={svcMeta} | |
onEdit={() => handleServiceClick(service.id)} | |
onToggleEnabled={(v: boolean) => { | |
setUserData((prev) => { | |
return { | |
...prev, | |
services: (prev.services ?? []).map((s) => | |
s.id === service.id ? { ...s, enabled: v } : s | |
), | |
}; | |
}); | |
}} | |
/> | |
); | |
}) | |
)} | |
</ul> | |
</div> | |
</SortableContext> | |
</DndContext> | |
</div> | |
<SettingsCard | |
title="RPDB" | |
description="Provide your RPDB API key if you want catalogs of supported types to use posters from RPDB" | |
> | |
<TextInput | |
label="RPDB API Key" | |
// help="Get your API Key from " | |
help={ | |
<span> | |
Get your API Key from{' '} | |
<a | |
href="https://ratingposterdb.com/api-key/" | |
target="_blank" | |
rel="noopener noreferrer" | |
className="text-[--brand] hover:underline" | |
> | |
here | |
</a> | |
</span> | |
} | |
value={userData.rpdbApiKey} | |
onValueChange={(v) => { | |
setUserData((prev) => ({ | |
...prev, | |
rpdbApiKey: v, | |
})); | |
}} | |
/> | |
</SettingsCard> | |
<ServiceModal | |
open={modalOpen} | |
onOpenChange={setModalOpen} | |
serviceId={modalService} | |
values={modalValues} | |
onChange={handleModalValuesChange} | |
onSubmit={handleModalSubmit} | |
onClose={handleModalClose} | |
/> | |
</> | |
); | |
} | |
function SortableServiceItem({ | |
service, | |
meta, | |
onEdit, | |
onToggleEnabled, | |
}: { | |
service: any; | |
meta: any; | |
onEdit: () => void; | |
onToggleEnabled: (v: boolean) => void; | |
}) { | |
const { | |
attributes, | |
listeners, | |
setNodeRef, | |
transform, | |
transition, | |
isDragging, | |
} = useSortable({ id: service.id }); | |
const style = { | |
transform: CSS.Transform.toString(transform), | |
transition, | |
opacity: isDragging ? 0.5 : 1, | |
}; | |
const disableEdit = meta.credentials.every((cred: any) => { | |
return cred.forced; | |
}); | |
return ( | |
<li ref={setNodeRef} style={style}> | |
<div className="px-2.5 py-2 bg-[var(--background)] rounded-[--radius-md] border flex gap-3 relative"> | |
<div | |
className="rounded-full w-6 h-auto bg-[--muted] md:bg-[--subtle] md:hover:bg-[--subtle-highlight] cursor-move" | |
{...attributes} | |
{...listeners} | |
/> | |
<div className="flex-1 flex flex-col justify-center min-w-0"> | |
<span className="font-mono text-base truncate"> | |
{meta?.name || service.id} | |
</span> | |
<span className="text-sm text-[--muted] font-normal italic break-words"> | |
<MarkdownLite>{meta?.signUpText}</MarkdownLite> | |
</span> | |
</div> | |
<div className="flex items-center gap-3"> | |
<Switch | |
value={!!service.enabled} | |
onValueChange={onToggleEnabled} | |
disabled={disableEdit} | |
/> | |
<IconButton | |
icon={<FiSettings />} | |
intent="primary-outline" | |
onClick={onEdit} | |
disabled={disableEdit} | |
/> | |
</div> | |
</div> | |
</li> | |
); | |
} | |
function ServiceModal({ | |
open, | |
onOpenChange, | |
serviceId, | |
values, | |
onChange, | |
onSubmit, | |
onClose, | |
}: { | |
open: boolean; | |
onOpenChange: (v: boolean) => void; | |
serviceId: ServiceId | null; | |
values: Record<string, any>; | |
onChange: (v: Record<string, any>) => void; | |
onSubmit: (v: Record<string, any>) => void; | |
onClose: () => void; | |
}) { | |
const { status } = useStatus(); | |
if (!status) return null; | |
if (!serviceId) return null; | |
const meta = status.settings.services[serviceId]!; | |
const credentials = meta.credentials || []; | |
const handleCredentialChange = (optId: string, newValue: any) => { | |
// Create a new object with all existing values plus the updated one | |
const updatedValues = { | |
...values, | |
[optId]: newValue, | |
}; | |
onChange(updatedValues); | |
}; | |
return ( | |
<Modal | |
open={open} | |
onOpenChange={onOpenChange} | |
title={`Configure ${meta.name}`} | |
> | |
<form | |
className="space-y-4" | |
onSubmit={(e) => { | |
e.preventDefault(); | |
onSubmit(values); | |
}} | |
> | |
{credentials.map((opt) => ( | |
<TemplateOption | |
key={opt.id} | |
option={opt} | |
value={opt.forced || opt.default || values[opt.id]} | |
onChange={(v) => handleCredentialChange(opt.id, v)} | |
/> | |
))} | |
<div className="flex gap-2"> | |
<Button | |
type="button" | |
className="w-full" | |
intent="primary-outline" | |
onClick={onClose} | |
> | |
Cancel | |
</Button> | |
<Button type="submit" className="w-full"> | |
Save | |
</Button> | |
</div> | |
</form> | |
</Modal> | |
); | |
} | |