brunner56's picture
implement app
0bfe2e3
'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>
);
}