fix(ui): harden add downloads modal layout

This commit is contained in:
NimBold
2026-08-11 10:51:11 +03:30
parent 753d590aee
commit ae6a00304e
9 changed files with 96 additions and 49 deletions
+33 -27
View File
@@ -2032,14 +2032,19 @@ export const AddDownloadsModal = () => {
</div>
</div>
<textarea
className={`add-download-control add-download-links-input w-full h-32 p-3 text-[13px] resize-none ${
id="add-download-links-input"
aria-describedby="add-download-links-hint"
className={`add-download-control add-download-links-input w-full h-24 p-3 text-[13px] resize-none ${
isRtl ? 'add-download-links-input--rtl' : ''
}`}
placeholder={t($ => $.addDownloads.pastePlaceholder)}
value={urls}
onChange={(e) => setUrls(e.target.value)}
/>
<div className="flex justify-end">
<div className="add-download-url-meta px-1">
<p id="add-download-links-hint" className="add-download-input-hint min-w-0 flex-1 text-[10px] leading-4 text-text-muted">
{t($ => $.addDownloads.pasteHint)}
</p>
<button
type="button"
onClick={() => void addTorrentFiles()}
@@ -2062,15 +2067,14 @@ export const AddDownloadsModal = () => {
</p>
);
})}
<div className="add-download-selection-toolbar px-1">
<div
className="add-download-selection-status"
role="status"
aria-live="polite"
aria-atomic="true"
aria-label={localizedSelectedSummary}
>
<span className="sr-only">{localizedSelectedSummary}</span>
<div
className="add-download-selection-summary px-1"
role="status"
aria-live="polite"
aria-atomic="true"
>
<span className="sr-only">{localizedSelectedSummary}</span>
<div className="add-download-selection-status" aria-hidden="true">
<span className="add-download-status-chip" data-tone="ready">
<strong>{readyMetadataCount}</strong>
<span>{t($ => $.addDownloads.selectedSummaryReady)}</span>
@@ -2088,7 +2092,23 @@ export const AddDownloadsModal = () => {
<span>{t($ => $.addDownloads.selectedSummaryBlocked)}</span>
</span>
</div>
<div className="add-download-selection-actions">
</div>
</div>
<div className="grid grid-cols-4 gap-3">
<SummaryBox title={t($ => $.addDownloads.files)} value={selectedItems.length === parsedItems.length ? parsedItems.length : `${selectedItems.length}/${parsedItems.length}`} icon={FileText} color="text-blue-500" />
<SummaryBox title={t($ => $.addDownloads.required)} value={requiredStr === 'Unknown' ? t($ => $.addDownloads.unknown) : requiredStr} icon={Database} color="text-orange-500" />
<SummaryBox title={t($ => $.addDownloads.free)} value={freeSpace === 'Unknown' ? t($ => $.addDownloads.unknown) : freeSpace} icon={HardDrive} color="text-green-500" />
<SummaryBox title={t($ => $.addDownloads.unknown)} value={selectedItems.filter(i => !i.sizeBytes).length} icon={FileText} color="text-purple-500" />
</div>
<div className="flex flex-col gap-2 flex-1 min-h-0 min-w-0 overflow-hidden">
<div className="add-download-preview-heading flex flex-wrap items-center justify-between gap-2">
<div className="add-download-section-title flex items-center gap-2">
<ArrowRight size={16} className="text-blue-500" />
{t($ => $.addDownloads.preview)}
</div>
<div className="add-download-preview-actions flex items-center gap-1">
<button
type="button"
onClick={() => setParsedItems(refreshFailedMetadataRows)}
@@ -2101,26 +2121,12 @@ export const AddDownloadsModal = () => {
type="button"
onClick={toggleAllRows}
disabled={parsedItems.length === 0}
className="add-download-link-button ms-3 text-[11px] font-medium"
className="add-download-link-button text-[11px] font-medium"
>
{allRowsSelected ? t($ => $.addDownloads.clearSelection) : t($ => $.addDownloads.selectAll)}
</button>
</div>
</div>
</div>
<div className="grid grid-cols-4 gap-3">
<SummaryBox title={t($ => $.addDownloads.files)} value={selectedItems.length === parsedItems.length ? parsedItems.length : `${selectedItems.length}/${parsedItems.length}`} icon={FileText} color="text-blue-500" />
<SummaryBox title={t($ => $.addDownloads.required)} value={requiredStr === 'Unknown' ? t($ => $.addDownloads.unknown) : requiredStr} icon={Database} color="text-orange-500" />
<SummaryBox title={t($ => $.addDownloads.free)} value={freeSpace === 'Unknown' ? t($ => $.addDownloads.unknown) : freeSpace} icon={HardDrive} color="text-green-500" />
<SummaryBox title={t($ => $.addDownloads.unknown)} value={selectedItems.filter(i => !i.sizeBytes).length} icon={FileText} color="text-purple-500" />
</div>
<div className="flex flex-col gap-2 flex-1 min-h-0 min-w-0 overflow-hidden">
<div className="add-download-section-title flex items-center gap-2">
<ArrowRight size={16} className="text-blue-500" />
{t($ => $.addDownloads.preview)}
</div>
<div className="add-download-preview flex-1 min-h-0 min-w-0 overflow-hidden flex flex-col">
<div className="add-download-preview-header px-3 py-2 flex text-[11px] font-semibold text-text-muted uppercase tracking-wider">
<div className="flex-[2]">{t($ => $.addDownloads.file)}</div>
+2 -1
View File
@@ -653,7 +653,8 @@ const common = {
pauseBeforeReplace: 'Pause {{file}} before replacing it.',
cannotReplace: 'Cannot replace {{file}}: file is not owned by a Firelink download.',
downloadLinks: 'Download Links',
pastePlaceholder: 'Paste HTTP, HTTPS, FTP, SFTP, or magnet URLs here... You can also choose .torrent files below.\n\nFor media downloads, paste links from YouTube, X, TikTok, Instagram, Reddit, etc.',
pastePlaceholder: 'Paste HTTP(S), FTP/SFTP, magnet, or media URLs…',
pasteHint: 'Media links from YouTube, X, TikTok, Instagram, and Reddit are supported.',
playlistSummary: 'Playlist “{{title}}”: {{loaded}}{{total}} entries loaded{{truncated}}{{skipped}}',
safeEntryLimit: ' (safe entry limit reached)',
selectedSummary: '{{ready}} selected ready, {{fallback}} fallback, {{mediaRetry}} media retry, {{blocked}} blocked',
+2 -1
View File
@@ -653,7 +653,8 @@ const fa = {
pauseBeforeReplace: 'قبل از جایگزینی {{file}}، آن را متوقف کنید.',
cannotReplace: 'نمی‌توان {{file}} را جایگزین کرد: فایل متعلق به یک دانلود Firelink نیست.',
downloadLinks: 'پیوندهای دانلود',
pastePlaceholder: 'URLهای HTTP، HTTPS، FTP، SFTP یا magnet را اینجا جای‌گذاری کنید… همچنین می‌توانید فایل‌های .torrent را از پایین انتخاب کنید.\n\nبرای دانلود رسانه، پیوندهایی از \u2066YouTube\u2069، \u2066X\u2069، \u2066TikTok\u2069، \u2066Instagram\u2069، \u2066Reddit\u2069 و غیره جای‌گذاری کنید.',
pastePlaceholder: 'URLهای \u2066HTTP(S)\u2069، \u2066FTP/SFTP\u2069، \u2066magnet\u2069 یا رسانه را اینجا جای‌گذاری کنید',
pasteHint: 'پیوندهای \u2066YouTube\u2069، \u2066X\u2069، \u2066TikTok\u2069، \u2066Instagram\u2069 و \u2066Reddit\u2069 پشتیبانی می‌شوند.',
playlistSummary: 'لیست پخش "{{title}}": {{loaded}} از {{total}} ورودی بارگیری شد{{truncated}}{{skipped}}',
safeEntryLimit: ' (به حد مجاز ایمن ورودی‌ها رسیدیم)',
selectedSummary: '{{ready}} انتخاب‌شده آماده، {{fallback}} اطلاعات جایگزین، {{mediaRetry}} تلاش مجدد رسانه، {{blocked}} مسدودشده',
+2 -1
View File
@@ -653,7 +653,8 @@ const he = {
pauseBeforeReplace: 'השהה את {{file}} לפני החלפתו.',
cannotReplace: 'לא ניתן להחליף את {{file}}: הקובץ אינו שייך להורדת Firelink.',
downloadLinks: 'קישורי הורדה',
pastePlaceholder: 'הדבק כאן כתובות \u2066HTTP\u2069, \u2066HTTPS\u2069, \u2066FTP\u2069, \u2066SFTP\u2069 או magnet… אפשר גם לבחור קובצי ‎.torrent‎ למטה.\n\nעבור הורדות מדיה, הדבק קישורים מ-\u2066YouTube\u2069, \u2066X\u2069, \u2066TikTok\u2069, \u2066Instagram\u2069, \u2066Reddit\u2069 וכו\'.',
pastePlaceholder: 'הדביקו כאן כתובות \u2066HTTP(S)\u2069, \u2066FTP/SFTP\u2069, \u2066magnet\u2069 או מדיה…',
pasteHint: 'קישורי \u2066YouTube\u2069, \u2066X\u2069, \u2066TikTok\u2069, \u2066Instagram\u2069 ו-\u2066Reddit\u2069 נתמכים.',
playlistSummary: 'רשימת השמעה "{{title}}": {{loaded}} מתוך {{total}} פריטים נטענו{{truncated}}{{skipped}}',
safeEntryLimit: ' (הושגה מגבלת הפריטים הבטוחה)',
selectedSummary: '{{ready}} נבחרו ומוכנים, {{fallback}} לגיבוי, {{mediaRetry}} מדיה לניסיון חוזר, {{blocked}} חסומים',
+2 -1
View File
@@ -653,7 +653,8 @@ const ru = {
pauseBeforeReplace: 'Приостановите {{file}} перед заменой.',
cannotReplace: 'Невозможно заменить {{file}}: файл не принадлежит загрузке Firelink.',
downloadLinks: 'Ссылки для скачивания',
pastePlaceholder: 'Вставьте сюда URL-адреса HTTP, HTTPS, FTP, SFTP или magnet… Также можно выбрать файлы .torrent ниже.\n\nДля загрузки медиа вставляйте ссылки с YouTube, X, TikTok, Instagram, Reddit и т. д.',
pastePlaceholder: 'Вставьте URL HTTP(S), FTP/SFTP, magnet или медиа…',
pasteHint: 'Поддерживаются ссылки YouTube, X, TikTok, Instagram и Reddit.',
playlistSummary: 'Плейлист «{{title}}»: загружено {{loaded}} из {{total}} элементов{{truncated}}{{skipped}}',
safeEntryLimit: ' (достигнут безопасный лимит элементов)',
selectedSummary: 'Выбрано: {{ready}} готовых, {{fallback}} с резервными данными, {{mediaRetry}} повторов медиа, {{blocked}} заблокировано',
+2 -1
View File
@@ -653,7 +653,8 @@ const uk = {
pauseBeforeReplace: 'Призупиніть {{file}} перед заміною.',
cannotReplace: 'Неможливо замінити {{file}}: файл не належить до завантажень Firelink.',
downloadLinks: 'Посилання для завантаження',
pastePlaceholder: 'Вставте сюди URL-адреси HTTP, HTTPS, FTP, SFTP або magnet… Також можна вибрати файли .torrent нижче.\n\nДля медіазавантажень вставте посилання з YouTube, X, TikTok, Instagram, Reddit тощо.',
pastePlaceholder: 'Вставте URL HTTP(S), FTP/SFTP, magnet або медіа…',
pasteHint: 'Підтримуються посилання YouTube, X, TikTok, Instagram і Reddit.',
playlistSummary: 'Плейлист “{{title}}”: {{loaded}} з {{total}} елементів завантажено{{truncated}}{{skipped}}',
safeEntryLimit: ' (досягнуто безпечного ліміту елементів)',
selectedSummary: '{{ready}} вибрано готових, {{fallback}} резервних, {{mediaRetry}} повторних медіа, {{blocked}} заблоковано',
+2 -1
View File
@@ -653,7 +653,8 @@ const zhCN = {
pauseBeforeReplace: '请在替换 {{file}} 前暂停它。',
cannotReplace: '无法替换 {{file}}:文件不属于 Firelink 下载。',
downloadLinks: '下载链接',
pastePlaceholder: '在此粘贴 HTTP、HTTPS、FTPSFTPmagnet URL…也可以在下方选择 .torrent 文件。\n\n对于媒体下载,请粘贴来自 YouTube、X、TikTok、Instagram、Reddit 等的链接。',
pastePlaceholder: '在此粘贴 HTTP(S)、FTP/SFTPmagnet 或媒体 URL…',
pasteHint: '支持 YouTube、X、TikTok、Instagram 和 Reddit 链接。',
playlistSummary: '播放列表“{{title}}”:已加载 {{loaded}} / {{total}} 个条目{{truncated}}{{skipped}}',
safeEntryLimit: ' (达到安全条目限制)',
selectedSummary: '准备就绪 {{ready}} 个,后备项 {{fallback}} 个,媒体重试 {{mediaRetry}} 个,已屏蔽 {{blocked}} 个',
+10
View File
@@ -76,6 +76,16 @@ describe('translation catalogs', () => {
expect(allMismatches).toEqual([]);
});
it('isolates mixed-direction Add Downloads guidance in RTL locales', () => {
for (const locale of ['fa', 'he'] as const) {
for (const key of ['addDownloads.pastePlaceholder', 'addDownloads.pasteHint']) {
const value = catalogFor(locale).get(key) ?? '';
expect(value).toContain('\u2066');
expect(value).toContain('\u2069');
}
}
});
it('reports exact English duplicates for translation review', () => {
const english = catalogFor('en');
const duplicates = APP_LOCALES.filter((locale) => locale !== 'en').flatMap((locale) => {
+41 -16
View File
@@ -1602,6 +1602,18 @@ html[data-list-density="relaxed"] {
text-align: right;
}
.add-download-url-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
}
.add-download-input-hint {
overflow-wrap: anywhere;
}
.add-download-link-button {
border-radius: 6px;
color: hsl(var(--accent-color));
@@ -1613,19 +1625,16 @@ html[data-list-density="relaxed"] {
background: hsl(var(--accent-color) / 0.1);
}
.add-download-selection-toolbar {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
.add-download-selection-summary {
min-width: 0;
}
.add-download-selection-status {
display: flex;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
min-width: 0;
flex-wrap: wrap;
align-items: center;
gap: 5px;
gap: 6px;
color: hsl(var(--text-muted));
font-size: 10px;
line-height: 1.25;
@@ -1635,12 +1644,20 @@ html[data-list-density="relaxed"] {
display: inline-flex;
align-items: baseline;
gap: 4px;
justify-content: center;
width: 100%;
min-width: 0;
min-height: 22px;
padding: 3px 7px;
border: 1px solid hsl(var(--border-modal));
border-radius: 999px;
background: hsl(var(--surface-raised) / 0.46);
white-space: nowrap;
text-align: center;
overflow-wrap: anywhere;
}
.add-download-status-chip > span {
min-width: 0;
}
.add-download-status-chip strong {
@@ -1666,11 +1683,17 @@ html[data-list-density="relaxed"] {
color: hsl(0 78% 68%);
}
.add-download-selection-actions {
.add-download-preview-heading {
min-width: 0;
}
.add-download-preview-actions {
display: flex;
align-items: center;
gap: 4px;
white-space: nowrap;
flex-wrap: wrap;
min-width: 0;
max-width: 100%;
}
.add-download-torrent-advanced-toggle {
@@ -1699,13 +1722,15 @@ html[data-list-density="relaxed"] {
}
@media (max-width: 720px) {
.add-download-selection-toolbar {
grid-template-columns: minmax(0, 1fr);
justify-items: stretch;
.add-download-preview-actions {
width: 100%;
justify-content: flex-start;
}
}
.add-download-selection-actions {
justify-content: flex-end;
@media (min-width: 900px) {
.add-download-selection-status {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}