v1.1.0 Priority 3b: zero-click widget with recalibrate + ignore
Widget redesign: - Zero-click: loads cached status on mount via GET /status (no API calls) - Shows platform icon + modpack name + version comparison - Orange background + arrow (current → latest) when update available - Cyan + checkmark when up to date - Refresh button triggers manual check - Calibrate button opens dropdown with last 10 releases - Ignore button hides non-modpack servers - Current release highlighted in calibrate dropdown Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ec37fc819c
commit
dd05a41567
@@ -1,98 +1,198 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { ServerContext } from '@/state/server';
|
||||
import http from '@/api/http';
|
||||
import { faCube } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faCube, faSync, faEyeSlash, faCog } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import classNames from 'classnames';
|
||||
|
||||
interface VersionData {
|
||||
success: boolean;
|
||||
interface StatusData {
|
||||
configured: boolean;
|
||||
platform?: string;
|
||||
modpack_id?: string;
|
||||
modpack_name?: string;
|
||||
current_version?: string;
|
||||
latest_version?: string;
|
||||
status?: string;
|
||||
message?: string;
|
||||
error?: string;
|
||||
update_available?: boolean;
|
||||
last_checked?: string;
|
||||
detection_method?: string;
|
||||
is_ignored?: boolean;
|
||||
}
|
||||
|
||||
interface Release {
|
||||
file_id: string;
|
||||
version: string;
|
||||
display_name: string;
|
||||
release_date?: string;
|
||||
}
|
||||
|
||||
const platformIcons: Record<string, string> = {
|
||||
curseforge: '🔥',
|
||||
modrinth: '🍃',
|
||||
technic: '⚙️',
|
||||
ftb: '📦',
|
||||
};
|
||||
|
||||
const ModpackVersionCard: React.FC = () => {
|
||||
const uuid = ServerContext.useStoreState((state) => state.server.data?.uuid);
|
||||
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
|
||||
const [data, setData] = useState<VersionData | null>(null);
|
||||
const [data, setData] = useState<StatusData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [showCalibrate, setShowCalibrate] = useState(false);
|
||||
const [releases, setReleases] = useState<Release[]>([]);
|
||||
const [loadingReleases, setLoadingReleases] = useState(false);
|
||||
|
||||
const checkForUpdates = async () => {
|
||||
// Zero-click: load cached status on mount
|
||||
useEffect(() => {
|
||||
if (!uuid) return;
|
||||
http.get(`/api/client/extensions/modpackchecker/servers/${uuid}/status`)
|
||||
.then((res) => setData(res.data))
|
||||
.catch(() => setData(null))
|
||||
.finally(() => setLoading(false));
|
||||
}, [uuid]);
|
||||
|
||||
setStatus('loading');
|
||||
// Manual refresh
|
||||
const refresh = async () => {
|
||||
if (!uuid || checking) return;
|
||||
setChecking(true);
|
||||
try {
|
||||
const response = await http.post(`/api/client/extensions/modpackchecker/servers/${uuid}/check`);
|
||||
setData(response.data);
|
||||
setStatus(response.data.success ? 'success' : 'error');
|
||||
} catch (error: unknown) {
|
||||
const err = error as { response?: { status?: number } };
|
||||
if (err.response?.status === 429) {
|
||||
setData({ success: false, error: 'rate_limited' });
|
||||
} else if (err.response?.status === 404) {
|
||||
setData({ success: false, error: 'not_found' });
|
||||
} else {
|
||||
setData({ success: false, error: 'api_error' });
|
||||
}
|
||||
setStatus('error');
|
||||
await http.post(`/api/client/extensions/modpackchecker/servers/${uuid}/check`);
|
||||
const res = await http.get(`/api/client/extensions/modpackchecker/servers/${uuid}/status`);
|
||||
setData(res.data);
|
||||
} catch {}
|
||||
setChecking(false);
|
||||
};
|
||||
|
||||
// Open calibrate dropdown
|
||||
const openCalibrate = async () => {
|
||||
if (!uuid) return;
|
||||
setShowCalibrate(true);
|
||||
setLoadingReleases(true);
|
||||
try {
|
||||
const res = await http.get(`/api/client/extensions/modpackchecker/servers/${uuid}/releases`);
|
||||
setReleases(res.data.releases || []);
|
||||
} catch {
|
||||
setReleases([]);
|
||||
}
|
||||
setLoadingReleases(false);
|
||||
};
|
||||
|
||||
// Convert error codes to short display messages
|
||||
const getErrorMessage = (error?: string): string => {
|
||||
if (!error) return 'Error';
|
||||
if (error.includes('detect') || error.includes('MODPACK')) return 'Not configured';
|
||||
if (error === 'rate_limited') return 'Wait 60s';
|
||||
if (error === 'not_found') return 'Not found';
|
||||
if (error === 'api_error') return 'API error';
|
||||
if (error.length > 20) return 'Check failed';
|
||||
return error;
|
||||
// Select a release to calibrate
|
||||
const selectRelease = async (release: Release) => {
|
||||
if (!uuid) return;
|
||||
try {
|
||||
const res = await http.post(`/api/client/extensions/modpackchecker/servers/${uuid}/calibrate`, {
|
||||
file_id: release.file_id,
|
||||
version: release.version,
|
||||
});
|
||||
setData((prev) => prev ? {
|
||||
...prev,
|
||||
current_version: release.version,
|
||||
update_available: res.data.update_available,
|
||||
} : prev);
|
||||
} catch {}
|
||||
setShowCalibrate(false);
|
||||
};
|
||||
|
||||
const getBgColor = () => {
|
||||
if (status === 'success' && data?.status === 'update_available') return 'bg-orange-500';
|
||||
if (status === 'success' && data?.success) return 'bg-cyan-500';
|
||||
return 'bg-gray-700';
|
||||
// Ignore server
|
||||
const toggleIgnore = async () => {
|
||||
if (!uuid) return;
|
||||
try {
|
||||
const res = await http.post(`/api/client/extensions/modpackchecker/servers/${uuid}/ignore`);
|
||||
setData((prev) => prev ? { ...prev, is_ignored: res.data.is_ignored } : prev);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
if (loading) return null;
|
||||
if (!data) return null;
|
||||
if (data.is_ignored) return null;
|
||||
|
||||
const hasUpdate = data.update_available;
|
||||
const configured = data.configured;
|
||||
|
||||
const bgColor = hasUpdate ? 'bg-orange-500' : configured ? 'bg-cyan-500' : 'bg-gray-700';
|
||||
const icon = data.platform ? (platformIcons[data.platform] || '📦') : '📦';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
'flex items-center rounded shadow-lg relative bg-gray-600 cursor-pointer hover:bg-gray-500 transition-colors',
|
||||
'col-span-3 md:col-span-2 lg:col-span-6',
|
||||
'px-3 py-2 md:p-3 lg:p-4 mt-2'
|
||||
)}
|
||||
onClick={status !== 'loading' ? checkForUpdates : undefined}
|
||||
title={'Click to check for modpack updates'}
|
||||
>
|
||||
<div className={classNames('w-1 h-full absolute left-0 top-0 rounded-l sm:hidden', getBgColor())} />
|
||||
|
||||
<div className={classNames(
|
||||
'hidden flex-shrink-0 items-center justify-center rounded-lg shadow-md w-12 h-12 transition-colors duration-500',
|
||||
'sm:flex sm:mr-4',
|
||||
getBgColor()
|
||||
)}>
|
||||
<FontAwesomeIcon icon={faCube} className={'w-6 h-6 text-gray-50'} />
|
||||
</div>
|
||||
|
||||
<div className={'flex flex-col justify-center overflow-hidden w-full'}>
|
||||
<p className={'font-header font-medium leading-tight text-xs md:text-sm text-gray-200'}>
|
||||
Modpack Version
|
||||
</p>
|
||||
<div className={'h-[1.75rem] w-full font-semibold text-gray-50 truncate text-sm'}>
|
||||
{status === 'idle' && <span className={'text-gray-400'}>Click to check</span>}
|
||||
{status === 'loading' && <span className={'text-gray-400'}>Checking...</span>}
|
||||
{status === 'success' && data?.success && <span>{data.latest_version}</span>}
|
||||
{(status === 'error' || (status === 'success' && !data?.success)) && (
|
||||
<span className={'text-red-400'}>{getErrorMessage(data?.error || data?.message)}</span>
|
||||
)}
|
||||
<div className={classNames(
|
||||
'rounded shadow-lg relative bg-gray-600',
|
||||
'col-span-3 md:col-span-2 lg:col-span-6',
|
||||
'px-3 py-2 md:p-3 lg:p-4 mt-2'
|
||||
)}>
|
||||
<div className={classNames('w-1 h-full absolute left-0 top-0 rounded-l sm:hidden', bgColor)} />
|
||||
|
||||
<div className={'flex items-center'}>
|
||||
<div className={classNames(
|
||||
'hidden flex-shrink-0 items-center justify-center rounded-lg shadow-md w-12 h-12',
|
||||
'sm:flex sm:mr-4', bgColor
|
||||
)}>
|
||||
<FontAwesomeIcon icon={faCube} className={'w-6 h-6 text-gray-50'} />
|
||||
</div>
|
||||
|
||||
<div className={'flex flex-col justify-center overflow-hidden w-full'}>
|
||||
<div className={'flex items-center justify-between'}>
|
||||
<p className={'font-header font-medium leading-tight text-xs md:text-sm text-gray-200'}>
|
||||
{configured ? `${icon} ${data.modpack_name || 'Modpack'}` : 'Modpack Version'}
|
||||
</p>
|
||||
<div className={'flex gap-1'}>
|
||||
<button onClick={refresh} disabled={checking}
|
||||
className={'text-gray-400 hover:text-gray-200 p-1'} title={'Refresh'}>
|
||||
<FontAwesomeIcon icon={faSync} spin={checking} className={'w-3 h-3'} />
|
||||
</button>
|
||||
{configured && (
|
||||
<button onClick={openCalibrate}
|
||||
className={'text-gray-400 hover:text-gray-200 p-1'} title={'Calibrate version'}>
|
||||
<FontAwesomeIcon icon={faCog} className={'w-3 h-3'} />
|
||||
</button>
|
||||
)}
|
||||
<button onClick={toggleIgnore}
|
||||
className={'text-gray-400 hover:text-gray-200 p-1'} title={'Hide (not a modpack)'}>
|
||||
<FontAwesomeIcon icon={faEyeSlash} className={'w-3 h-3'} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={'font-semibold text-gray-50 truncate text-sm'}>
|
||||
{!configured && <span className={'text-gray-400'}>Not detected — waiting for cron</span>}
|
||||
{configured && !hasUpdate && (
|
||||
<span className={'text-cyan-300'}>✓ Up to date — {data.latest_version}</span>
|
||||
)}
|
||||
{configured && hasUpdate && (
|
||||
<span className={'text-orange-300'}>
|
||||
↑ {data.current_version} → {data.latest_version}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Calibrate dropdown */}
|
||||
{showCalibrate && (
|
||||
<div className={'mt-2 bg-gray-800 rounded p-2 max-h-48 overflow-y-auto'}>
|
||||
<p className={'text-xs text-gray-400 mb-1'}>Select your installed version:</p>
|
||||
{loadingReleases && <p className={'text-xs text-gray-500'}>Loading releases...</p>}
|
||||
{!loadingReleases && releases.length === 0 && (
|
||||
<p className={'text-xs text-gray-500'}>No releases found</p>
|
||||
)}
|
||||
{releases.map((r) => (
|
||||
<button key={r.file_id} onClick={() => selectRelease(r)}
|
||||
className={classNames(
|
||||
'block w-full text-left px-2 py-1 text-sm rounded',
|
||||
'hover:bg-gray-700 text-gray-200',
|
||||
data.current_version === r.version ? 'bg-gray-700 text-cyan-300' : ''
|
||||
)}>
|
||||
{r.display_name}
|
||||
{r.release_date && (
|
||||
<span className={'text-gray-500 text-xs ml-2'}>
|
||||
{new Date(r.release_date).toLocaleDateString()}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
<button onClick={() => setShowCalibrate(false)}
|
||||
className={'mt-1 text-xs text-gray-500 hover:text-gray-300'}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user