mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-05 22:05:50 +01:00
Fix: AA mirror behavior (#589)
- Refreshed available AA URLs - Fixed potential redirect from AA itself causing mirror cache errors - Added fully customizable mirror list in UI - Segmented rotation behavior to Auto mode only Fixes #588
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
||||
CheckboxFieldConfig,
|
||||
SelectFieldConfig,
|
||||
MultiSelectFieldConfig,
|
||||
TagListFieldConfig,
|
||||
HeadingFieldConfig,
|
||||
ActionButtonConfig,
|
||||
ActionResult,
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
CheckboxField,
|
||||
SelectField,
|
||||
MultiSelectField,
|
||||
TagListField,
|
||||
HeadingField,
|
||||
ActionButton,
|
||||
} from './settings/fields';
|
||||
@@ -137,6 +139,15 @@ const renderField = (
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
);
|
||||
case 'TagListField':
|
||||
return (
|
||||
<TagListField
|
||||
field={field as TagListFieldConfig}
|
||||
value={(value as string[]) ?? []}
|
||||
onChange={(v) => onChange(v)}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
);
|
||||
case 'ActionButton':
|
||||
return (
|
||||
<ActionButton
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
CheckboxFieldConfig,
|
||||
SelectFieldConfig,
|
||||
MultiSelectFieldConfig,
|
||||
TagListFieldConfig,
|
||||
OrderableListFieldConfig,
|
||||
OrderableListItem,
|
||||
ActionButtonConfig,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
CheckboxField,
|
||||
SelectField,
|
||||
MultiSelectField,
|
||||
TagListField,
|
||||
OrderableListField,
|
||||
ActionButton,
|
||||
HeadingField,
|
||||
@@ -198,6 +200,15 @@ const renderField = (
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
);
|
||||
case 'TagListField':
|
||||
return (
|
||||
<TagListField
|
||||
field={field as TagListFieldConfig}
|
||||
value={(value as string[]) ?? []}
|
||||
onChange={(v) => onChange(v)}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
);
|
||||
case 'OrderableListField':
|
||||
return (
|
||||
<OrderableListField
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { TagListFieldConfig } from '../../../types/settings';
|
||||
|
||||
interface TagListFieldProps {
|
||||
field: TagListFieldConfig;
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function normalizeTag(raw: string): string {
|
||||
let s = raw.trim();
|
||||
if (!s) return '';
|
||||
|
||||
// Strip wrapping quotes from pasted env/JSON style strings.
|
||||
if (
|
||||
(s.startsWith('"') && s.endsWith('"')) ||
|
||||
(s.startsWith("'") && s.endsWith("'"))
|
||||
) {
|
||||
s = s.slice(1, -1).trim();
|
||||
}
|
||||
|
||||
// Avoid special sentinel values.
|
||||
if (s.toLowerCase() === 'auto') return '';
|
||||
|
||||
// Basic URL normalization to keep UX friendly; backend also normalizes on save.
|
||||
if (!s.includes('://') && !s.startsWith('/')) {
|
||||
s = `https://${s}`;
|
||||
}
|
||||
s = s.replace(/\/+$/, '');
|
||||
return s.trim();
|
||||
}
|
||||
|
||||
export const TagListField = ({ field, value, onChange, disabled }: TagListFieldProps) => {
|
||||
const isDisabled = disabled ?? false;
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [draft, setDraft] = useState('');
|
||||
|
||||
const tags = useMemo(() => (value ?? []).map(String).filter((t) => t.trim() !== ''), [value]);
|
||||
|
||||
const addFromRaw = (raw: string) => {
|
||||
if (isDisabled) return;
|
||||
const parts = raw
|
||||
.split(/[\n,]+/)
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
if (parts.length === 0) return;
|
||||
|
||||
const next = [...tags];
|
||||
for (const part of parts) {
|
||||
const normalized = normalizeTag(part);
|
||||
if (!normalized) continue;
|
||||
if (next.includes(normalized)) continue;
|
||||
next.push(normalized);
|
||||
}
|
||||
|
||||
if (next.length !== tags.length) {
|
||||
onChange(next);
|
||||
}
|
||||
};
|
||||
|
||||
const removeAt = (idx: number) => {
|
||||
if (isDisabled) return;
|
||||
onChange(tags.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
const commitDraft = () => {
|
||||
const raw = draft;
|
||||
if (!raw.trim()) return;
|
||||
addFromRaw(raw);
|
||||
setDraft('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-full px-3 py-2 rounded-lg border border-[var(--border-muted)]
|
||||
bg-[var(--bg-soft)] text-sm
|
||||
${isDisabled ? 'opacity-60 cursor-not-allowed' : 'cursor-text'}`}
|
||||
onClick={() => {
|
||||
if (isDisabled) return;
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-wrap gap-2 items-center">
|
||||
{tags.map((tag, idx) => (
|
||||
<span
|
||||
key={`${tag}-${idx}`}
|
||||
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full
|
||||
border border-[var(--border-muted)] bg-[var(--bg)]
|
||||
max-w-full"
|
||||
title={tag}
|
||||
>
|
||||
<span className="truncate max-w-[22rem]">{tag}</span>
|
||||
{!isDisabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeAt(idx);
|
||||
}}
|
||||
className="p-0.5 rounded-full hover:bg-[var(--hover-surface)]"
|
||||
aria-label={`Remove ${tag}`}
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
|
||||
{!isDisabled && (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
commitDraft();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Backspace' && !draft && tags.length > 0) {
|
||||
removeAt(tags.length - 1);
|
||||
}
|
||||
}}
|
||||
onBlur={() => commitDraft()}
|
||||
placeholder={tags.length === 0 ? field.placeholder : ''}
|
||||
className="flex-1 min-w-[12rem] bg-transparent outline-none py-1"
|
||||
/>
|
||||
)}
|
||||
|
||||
{isDisabled && tags.length === 0 && (
|
||||
<span className="opacity-60">{field.placeholder || 'No values'}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ export { NumberField } from './NumberField';
|
||||
export { CheckboxField } from './CheckboxField';
|
||||
export { SelectField } from './SelectField';
|
||||
export { MultiSelectField } from './MultiSelectField';
|
||||
export { TagListField } from './TagListField';
|
||||
export { OrderableListField } from './OrderableListField';
|
||||
export { ActionButton } from './ActionButton';
|
||||
export { TableField } from './TableField';
|
||||
|
||||
@@ -7,6 +7,7 @@ export type FieldType =
|
||||
| 'CheckboxField'
|
||||
| 'SelectField'
|
||||
| 'MultiSelectField'
|
||||
| 'TagListField'
|
||||
| 'OrderableListField'
|
||||
| 'TableField'
|
||||
| 'ActionButton'
|
||||
@@ -93,6 +94,12 @@ export interface MultiSelectFieldConfig extends BaseField {
|
||||
variant?: 'pills' | 'dropdown'; // 'pills' (default) or 'dropdown' for checkbox dropdown style
|
||||
}
|
||||
|
||||
export interface TagListFieldConfig extends BaseField {
|
||||
type: 'TagListField';
|
||||
value: string[];
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
// OrderableListField types - generic drag-and-drop reorderable list
|
||||
export interface OrderableListItem {
|
||||
id: string;
|
||||
@@ -163,6 +170,7 @@ export type SettingsField =
|
||||
| CheckboxFieldConfig
|
||||
| SelectFieldConfig
|
||||
| MultiSelectFieldConfig
|
||||
| TagListFieldConfig
|
||||
| OrderableListFieldConfig
|
||||
| TableFieldConfig
|
||||
| ActionButtonConfig
|
||||
|
||||
Reference in New Issue
Block a user