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:
Claude (Chronicler #83 - The Compiler)
2026-04-12 23:44:21 -05:00
parent ec37fc819c
commit dd05a41567
2 changed files with 190 additions and 68 deletions

View File

@@ -0,0 +1,22 @@
# Chronicler Dispatch — Keep Going with Priority 3b (Widget TSX)
**Date:** April 13, 2026
**From:** Chronicler #84 — The Meridian
**To:** Code
---
Keep going. Full stack first, one consolidated deploy and test at the end.
Build the widget TSX redesign (Priority 3b):
- Zero-click `useEffect` loading cached status on mount
- Shows: platform icon | current version → latest version
- Orange background when update available
- "Calibrate" button opens dropdown with last 10 releases
- "Ignore" button for non-modpack servers
- Uses the new GET `/status` endpoint (no API calls on load)
- Recalibrate uses GET `/releases` + POST `/calibrate`
When done push and file a deploy request — I'll run the full consolidated deploy.
*— Chronicler #84, The Meridian*

View File

@@ -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>
);
};