mirror of
https://github.com/rustmailer/bichon.git
synced 2026-08-03 07:48:34 +02:00
feat(search): add advanced attachment filters for extension, category and mime type
This commit is contained in:
@@ -105,4 +105,32 @@ export const restore_message = async (accountId: number, envelopeIds: string[])
|
||||
envelope_ids: envelopeIds,
|
||||
});
|
||||
return response.data;
|
||||
};
|
||||
|
||||
|
||||
|
||||
export interface AttachmentMetadata {
|
||||
/**
|
||||
* A collection of unique file extensions found in attachments.
|
||||
* @example ["pdf", "docx", "png"]
|
||||
*/
|
||||
extensions: string[];
|
||||
|
||||
/**
|
||||
* A collection of high-level attachment categories.
|
||||
* @example ["document", "image", "archive"]
|
||||
*/
|
||||
categories: string[];
|
||||
|
||||
/**
|
||||
* A collection of unique MIME types (Content-Type) for the attachments.
|
||||
* @example ["application/pdf", "image/jpeg"]
|
||||
*/
|
||||
content_types: string[];
|
||||
}
|
||||
|
||||
|
||||
export const get_attachment_meta = async () => {
|
||||
const response = await axiosInstance.get<AttachmentMetadata>("api/v1/attachment_metadata");
|
||||
return response.data;
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
import * as React from "react"
|
||||
import { Check, X } from "lucide-react"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useTranslation } from "react-i18next"
|
||||
|
||||
interface MetadataSelectorFieldProps {
|
||||
label: string
|
||||
value?: string
|
||||
options: string[]
|
||||
isLoading: boolean
|
||||
onSelect: (val: string | undefined) => void
|
||||
onReset: () => void
|
||||
}
|
||||
|
||||
export function MetadataSelectorField({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
isLoading,
|
||||
onSelect,
|
||||
onReset
|
||||
}: MetadataSelectorFieldProps) {
|
||||
const { t } = useTranslation()
|
||||
const [searchTerm, setSearchTerm] = React.useState("")
|
||||
|
||||
const filteredOptions = React.useMemo(() => {
|
||||
return options.filter(opt =>
|
||||
opt.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
)
|
||||
}, [options, searchTerm])
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"group flex items-center justify-between w-full px-4 py-2 hover:bg-accent/50 transition-all text-left relative border rounded-md",
|
||||
"min-h-[48px]",
|
||||
value && "bg-accent/30 border-primary/50"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-start pr-6 overflow-hidden">
|
||||
<span className="text-[10px] font-bold uppercase opacity-50 tracking-tight leading-none">
|
||||
{label}
|
||||
</span>
|
||||
<span className={cn(
|
||||
"mt-1 truncate w-full text-xs",
|
||||
value ? "font-semibold text-primary" : "text-muted-foreground/70"
|
||||
)}>
|
||||
{value || t('search_more.any')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5">
|
||||
{value && (
|
||||
<div
|
||||
onClick={(e) => { e.stopPropagation(); onReset(); }}
|
||||
className="p-1 rounded-full hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-colors"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{value && <div className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary" />}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent align="start" className="p-0 w-64 shadow-xl">
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput
|
||||
placeholder={t('search_more.search_placeholder', { field: label })}
|
||||
value={searchTerm}
|
||||
onValueChange={setSearchTerm}
|
||||
className="h-8"
|
||||
/>
|
||||
<CommandList className="max-h-[240px]">
|
||||
{isLoading && <div className="p-4 text-[10px] text-center opacity-50">{t('common.loading')}</div>}
|
||||
<CommandEmpty className="text-[10px] p-2 text-center">{t('common.noData')}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{filteredOptions.map((opt) => (
|
||||
<CommandItem
|
||||
key={opt}
|
||||
onSelect={() => {
|
||||
value === opt ? onReset() : onSelect(opt);
|
||||
}}
|
||||
className="flex items-center justify-between py-2 px-3 cursor-pointer text-xs"
|
||||
>
|
||||
<span className="truncate">{opt}</span>
|
||||
{value === opt && <Check className="h-3 w-3 text-primary shrink-0" />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -66,7 +66,7 @@ export function MailFilterPopover() {
|
||||
{fields.map((field) => (
|
||||
<ContactSelectorField
|
||||
key={field}
|
||||
label={field}
|
||||
label={t(`search.${field}`)}
|
||||
value={filter[field] as string | undefined}
|
||||
onSelect={(email) => updateFilter(field, email)}
|
||||
onReset={() => updateFilter(field, undefined)}
|
||||
|
||||
@@ -11,6 +11,8 @@ import { Button } from "@/components/ui/button"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useAttachmentMetadata } from "@/hooks/use-attachment-metadata"
|
||||
import { MetadataSelectorField } from "./attachment-metadata-selector"
|
||||
|
||||
const SIZES = {
|
||||
tiny: { min: undefined, max: 15 * 1024 },
|
||||
@@ -34,8 +36,13 @@ export function MoreFiltersPopover() {
|
||||
const { filter, setFilter } = useSearchContext();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const { data: meta, isLoading: metaLoading } = useAttachmentMetadata();
|
||||
|
||||
const [localState, setLocalState] = React.useState({
|
||||
attachment_name: filter?.attachment_name || '',
|
||||
attachment_extension: filter?.attachment_extension || '',
|
||||
attachment_category: filter?.attachment_category || '',
|
||||
attachment_content_type: filter?.attachment_content_type || '',
|
||||
message_id: filter?.message_id || '',
|
||||
size_preset: getPresetFromSize(filter?.min_size, filter?.max_size),
|
||||
has_attachment: filter?.has_attachment || false
|
||||
@@ -45,6 +52,9 @@ export function MoreFiltersPopover() {
|
||||
if (open) {
|
||||
setLocalState({
|
||||
attachment_name: filter?.attachment_name || '',
|
||||
attachment_extension: filter?.attachment_extension || '',
|
||||
attachment_category: filter?.attachment_category || '',
|
||||
attachment_content_type: filter?.attachment_content_type || '',
|
||||
message_id: filter?.message_id || '',
|
||||
size_preset: getPresetFromSize(filter?.min_size, filter?.max_size),
|
||||
has_attachment: filter?.has_attachment || false
|
||||
@@ -59,6 +69,15 @@ export function MoreFiltersPopover() {
|
||||
if (localState.attachment_name) next.attachment_name = localState.attachment_name;
|
||||
else delete next.attachment_name;
|
||||
|
||||
if (localState.attachment_extension) next.attachment_extension = localState.attachment_extension;
|
||||
else delete next.attachment_extension;
|
||||
|
||||
if (localState.attachment_category) next.attachment_category = localState.attachment_category;
|
||||
else delete next.attachment_category;
|
||||
|
||||
if (localState.attachment_content_type) next.attachment_content_type = localState.attachment_content_type;
|
||||
else delete next.attachment_content_type;
|
||||
|
||||
if (localState.message_id) next.message_id = localState.message_id;
|
||||
else delete next.message_id;
|
||||
|
||||
@@ -79,7 +98,10 @@ export function MoreFiltersPopover() {
|
||||
filter?.min_size,
|
||||
filter?.max_size,
|
||||
filter?.message_id,
|
||||
filter?.has_attachment
|
||||
filter?.has_attachment,
|
||||
filter?.attachment_extension,
|
||||
filter?.attachment_category,
|
||||
filter?.attachment_content_type
|
||||
].filter(Boolean).length;
|
||||
|
||||
return (
|
||||
@@ -118,6 +140,9 @@ export function MoreFiltersPopover() {
|
||||
delete next.max_size;
|
||||
delete next.message_id;
|
||||
delete next.has_attachment;
|
||||
delete next.attachment_extension;
|
||||
delete next.attachment_category;
|
||||
delete next.attachment_content_type;
|
||||
return next;
|
||||
});
|
||||
setOpen(false);
|
||||
@@ -132,9 +157,19 @@ export function MoreFiltersPopover() {
|
||||
<Checkbox
|
||||
id="has_attachment"
|
||||
checked={localState.has_attachment}
|
||||
onCheckedChange={(checked) =>
|
||||
setLocalState(prev => ({ ...prev, has_attachment: checked as boolean }))
|
||||
}
|
||||
onCheckedChange={(checked) => {
|
||||
const isChecked = checked as boolean;
|
||||
setLocalState(prev => ({
|
||||
...prev,
|
||||
has_attachment: isChecked,
|
||||
...(isChecked ? {} : {
|
||||
attachment_name: '',
|
||||
attachment_extension: '',
|
||||
attachment_category: '',
|
||||
attachment_content_type: ''
|
||||
})
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="has_attachment"
|
||||
@@ -144,15 +179,46 @@ export function MoreFiltersPopover() {
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">{t('search_more.attachment_name_label')}</Label>
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
value={localState.attachment_name}
|
||||
onChange={(e) => setLocalState(prev => ({ ...prev, attachment_name: e.target.value }))}
|
||||
placeholder={t('search_more.attachment_name_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
{localState.has_attachment && (
|
||||
<div className="space-y-3 p-2 bg-muted/30 rounded-lg border border-dashed border-border animate-in fade-in slide-in-from-top-1">
|
||||
<MetadataSelectorField
|
||||
label={t('search_more.extension')}
|
||||
value={localState.attachment_extension}
|
||||
options={meta?.extensions || []}
|
||||
isLoading={metaLoading}
|
||||
onSelect={(v) => setLocalState(p => ({ ...p, attachment_extension: v, attachment_category: '', attachment_content_type: '' }))}
|
||||
onReset={() => setLocalState(p => ({ ...p, attachment_extension: '' }))}
|
||||
/>
|
||||
|
||||
<MetadataSelectorField
|
||||
label={t('search_more.category')}
|
||||
value={localState.attachment_category}
|
||||
options={meta?.categories || []}
|
||||
isLoading={metaLoading}
|
||||
onSelect={(v) => setLocalState(p => ({ ...p, attachment_category: v, attachment_extension: '', attachment_content_type: '' }))}
|
||||
onReset={() => setLocalState(p => ({ ...p, attachment_category: '' }))}
|
||||
/>
|
||||
|
||||
<MetadataSelectorField
|
||||
label={t('search_more.content_types')}
|
||||
value={localState.attachment_content_type}
|
||||
options={meta?.content_types || []}
|
||||
isLoading={metaLoading}
|
||||
onSelect={(v) => setLocalState(p => ({ ...p, attachment_content_type: v, attachment_extension: '', attachment_category: '' }))}
|
||||
onReset={() => setLocalState(p => ({ ...p, attachment_content_type: '' }))}
|
||||
/>
|
||||
|
||||
<div className="space-y-1 px-1">
|
||||
<Label className="text-xs text-muted-foreground">{t('search_more.attachment_name_label')}</Label>
|
||||
<Input
|
||||
className="h-8 text-xs"
|
||||
value={localState.attachment_name}
|
||||
onChange={(e) => setLocalState(prev => ({ ...prev, attachment_name: e.target.value }))}
|
||||
placeholder={t('search_more.attachment_name_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs text-muted-foreground">{t('search_more.message_size_label')}</Label>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { AttachmentMetadata, get_attachment_meta } from '@/api/mailbox/envelope/api';
|
||||
import { useQuery, UseQueryOptions } from '@tanstack/react-query';
|
||||
|
||||
export const ATTACHMENT_METADATA_KEY = ['attachment_metadata'] as const;
|
||||
|
||||
export const useAttachmentMetadata = (options?: Partial<UseQueryOptions<AttachmentMetadata>>) => {
|
||||
return useQuery({
|
||||
queryKey: ATTACHMENT_METADATA_KEY,
|
||||
queryFn: get_attachment_meta,
|
||||
staleTime: 1000 * 60 * 10,
|
||||
gcTime: 1000 * 60 * 30,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
@@ -1539,7 +1539,12 @@
|
||||
"medium": "متوسط (2 – 10 ميغابايت)",
|
||||
"large": "كبير (10 – 20 ميغابايت)",
|
||||
"huge": "ضخم (> 20 ميغابايت)"
|
||||
}
|
||||
},
|
||||
"any": "الكل",
|
||||
"search_placeholder": "بحث {{field}}...",
|
||||
"extension": "امتداد الملف",
|
||||
"category": "فئة المرفق",
|
||||
"content_types": "نوع محدد"
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "العرض",
|
||||
|
||||
@@ -1539,7 +1539,12 @@
|
||||
"medium": "Mellem (2 – 10 MB)",
|
||||
"large": "Stor (10 – 20 MB)",
|
||||
"huge": "Meget stor (> 20 MB)"
|
||||
}
|
||||
},
|
||||
"any": "Alle",
|
||||
"search_placeholder": "Søg {{field}}...",
|
||||
"extension": "Filendelse",
|
||||
"category": "Vedhæftningskategori",
|
||||
"content_types": "Specifik type"
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "Visning",
|
||||
|
||||
@@ -1539,7 +1539,12 @@
|
||||
"medium": "Mittel (2 – 10 MB)",
|
||||
"large": "Groß (10 – 20 MB)",
|
||||
"huge": "Sehr groß (> 20 MB)"
|
||||
}
|
||||
},
|
||||
"any": "Beliebig",
|
||||
"search_placeholder": "{{field}} suchen...",
|
||||
"extension": "Dateiendung",
|
||||
"category": "Anhangskategorie",
|
||||
"content_types": "Spezifischer Typ"
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "Ansicht",
|
||||
|
||||
@@ -1539,7 +1539,12 @@
|
||||
"medium": "Medium (2 – 10 MB)",
|
||||
"large": "Large (10 – 20 MB)",
|
||||
"huge": "Huge (> 20 MB)"
|
||||
}
|
||||
},
|
||||
"any": "Any",
|
||||
"search_placeholder": "Search {{field}}...",
|
||||
"extension": "Extension",
|
||||
"category": "Category",
|
||||
"content_types": "Specific Type"
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "View",
|
||||
|
||||
@@ -1539,7 +1539,12 @@
|
||||
"medium": "Mediano (2 – 10 MB)",
|
||||
"large": "Grande (10 – 20 MB)",
|
||||
"huge": "Muy grande (> 20 MB)"
|
||||
}
|
||||
},
|
||||
"any": "Cualquiera",
|
||||
"search_placeholder": "Buscar {{field}}...",
|
||||
"extension": "Extensión de archivo",
|
||||
"category": "Categoría de adjunto",
|
||||
"content_types": "Tipo específico"
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "Vista",
|
||||
|
||||
@@ -1539,7 +1539,12 @@
|
||||
"medium": "Keskikokoinen (2 – 10 MB)",
|
||||
"large": "Suuri (10 – 20 MB)",
|
||||
"huge": "Erittäin suuri (> 20 MB)"
|
||||
}
|
||||
},
|
||||
"any": "Mikä tahansa",
|
||||
"search_placeholder": "Etsi {{field}}...",
|
||||
"extension": "Tiedostopääte",
|
||||
"category": "Liitekategoria",
|
||||
"content_types": "Tarkka tyyppi"
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "Näkymä",
|
||||
|
||||
@@ -1539,7 +1539,12 @@
|
||||
"medium": "Moyenne (2 – 10 Mo)",
|
||||
"large": "Grande (10 – 20 Mo)",
|
||||
"huge": "Très grande (> 20 Mo)"
|
||||
}
|
||||
},
|
||||
"any": "Tous",
|
||||
"search_placeholder": "Rechercher {{field}}...",
|
||||
"extension": "Extension de fichier",
|
||||
"category": "Catégorie de pièce jointe",
|
||||
"content_types": "Type spécifique"
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "Affichage",
|
||||
|
||||
@@ -1539,7 +1539,12 @@
|
||||
"medium": "Media (2 – 10 MB)",
|
||||
"large": "Grande (10 – 20 MB)",
|
||||
"huge": "Molto grande (> 20 MB)"
|
||||
}
|
||||
},
|
||||
"any": "Qualsiasi",
|
||||
"search_placeholder": "Cerca {{field}}...",
|
||||
"extension": "Estensione file",
|
||||
"category": "Categoria allegato",
|
||||
"content_types": "Tipo specifico"
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "Vista",
|
||||
|
||||
@@ -456,8 +456,8 @@
|
||||
"before": "以前:",
|
||||
"selectDate": "日付を選択",
|
||||
"pleaseSelectAtLeastOne": "少なくとも1つの検索条件を選択してください",
|
||||
"cc": "CC",
|
||||
"bcc": "BCC",
|
||||
"cc": "CC(写し)",
|
||||
"bcc": "BCC(ブラインド写し)",
|
||||
"attachmentName": "添付ファイル名",
|
||||
"messageId": "メッセージID",
|
||||
"minSize": "最小サイズ",
|
||||
@@ -1539,7 +1539,12 @@
|
||||
"medium": "中 (2 – 10 MB)",
|
||||
"large": "大 (10 – 20 MB)",
|
||||
"huge": "特大 (> 20 MB)"
|
||||
}
|
||||
},
|
||||
"any": "制限なし",
|
||||
"search_placeholder": "{{field}}を検索...",
|
||||
"extension": "拡張子",
|
||||
"category": "添付ファイルカテゴリ",
|
||||
"content_types": "具体的な形式"
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "表示",
|
||||
|
||||
@@ -1539,7 +1539,12 @@
|
||||
"medium": "보통 (2 – 10 MB)",
|
||||
"large": "큼 (10 – 20 MB)",
|
||||
"huge": "매우 큼 (> 20 MB)"
|
||||
}
|
||||
},
|
||||
"any": "제한 없음",
|
||||
"search_placeholder": "{{field}} 검색...",
|
||||
"extension": "파일 확장자",
|
||||
"category": "첨부 파일 카테고리",
|
||||
"content_types": "구체적인 형식"
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "보기",
|
||||
|
||||
@@ -1539,7 +1539,12 @@
|
||||
"medium": "Gemiddeld (2 – 10 MB)",
|
||||
"large": "Groot (10 – 20 MB)",
|
||||
"huge": "Zeer groot (> 20 MB)"
|
||||
}
|
||||
},
|
||||
"any": "Alle",
|
||||
"search_placeholder": "Zoek {{field}}...",
|
||||
"extension": "Bestandsextensie",
|
||||
"category": "Bijlagecategorie",
|
||||
"content_types": "Specifiek type"
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "Weergave",
|
||||
|
||||
@@ -1539,7 +1539,12 @@
|
||||
"medium": "Middels (2 – 10 MB)",
|
||||
"large": "Stor (10 – 20 MB)",
|
||||
"huge": "Svært stor (> 20 MB)"
|
||||
}
|
||||
},
|
||||
"any": "Alle",
|
||||
"search_placeholder": "Søk {{field}}...",
|
||||
"extension": "Filendelse",
|
||||
"category": "Vedleggskategori",
|
||||
"content_types": "Spesifikk type"
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "Visning",
|
||||
|
||||
@@ -1539,7 +1539,12 @@
|
||||
"medium": "Średni (2 – 10 MB)",
|
||||
"large": "Duży (10 – 20 MB)",
|
||||
"huge": "Bardzo duży (> 20 MB)"
|
||||
}
|
||||
},
|
||||
"any": "Dowolne",
|
||||
"search_placeholder": "Szukaj {{field}}...",
|
||||
"extension": "Rozszerzenie pliku",
|
||||
"category": "Kategoria załącznika",
|
||||
"content_types": "Konkretny typ"
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "Widok",
|
||||
|
||||
@@ -1539,7 +1539,12 @@
|
||||
"medium": "Médio (2 – 10 MB)",
|
||||
"large": "Grande (10 – 20 MB)",
|
||||
"huge": "Muito grande (> 20 MB)"
|
||||
}
|
||||
},
|
||||
"any": "Qualquer",
|
||||
"search_placeholder": "Buscar {{field}}...",
|
||||
"extension": "Extensão de arquivo",
|
||||
"category": "Categoria de anexo",
|
||||
"content_types": "Tipo específico"
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "Visualização",
|
||||
|
||||
@@ -1539,7 +1539,12 @@
|
||||
"medium": "Средний (2–10 МБ)",
|
||||
"large": "Большой (10–20 МБ)",
|
||||
"huge": "Очень большой (> 20 МБ)"
|
||||
}
|
||||
},
|
||||
"any": "Любой",
|
||||
"search_placeholder": "Поиск {{field}}...",
|
||||
"extension": "Расширение файла",
|
||||
"category": "Категория вложения",
|
||||
"content_types": "Конкретный тип"
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "Вид",
|
||||
|
||||
@@ -1539,7 +1539,12 @@
|
||||
"medium": "Mellan (2–10 MB)",
|
||||
"large": "Stor (10–20 MB)",
|
||||
"huge": "Mycket stor (> 20 MB)"
|
||||
}
|
||||
},
|
||||
"any": "Alla",
|
||||
"search_placeholder": "Sök {{field}}...",
|
||||
"extension": "Filändelse",
|
||||
"category": "Bilagskategori",
|
||||
"content_types": "Specifik typ"
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "Vy",
|
||||
|
||||
@@ -1539,7 +1539,12 @@
|
||||
"medium": "一般(2–10 MB)",
|
||||
"large": "較大(10–20 MB)",
|
||||
"huge": "極大(> 20 MB)"
|
||||
}
|
||||
},
|
||||
"any": "不限",
|
||||
"search_placeholder": "搜尋{{field}}...",
|
||||
"extension": "檔案副檔名",
|
||||
"category": "附件分類",
|
||||
"content_types": "具體類型"
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "檢視",
|
||||
|
||||
@@ -1539,7 +1539,12 @@
|
||||
"medium": "普通 (2 - 10 MB)",
|
||||
"large": "较大 (10 - 20 MB)",
|
||||
"huge": "极大 (> 20 MB)"
|
||||
}
|
||||
},
|
||||
"any": "不限",
|
||||
"search_placeholder": "搜索{{field}}...",
|
||||
"extension": "文件后缀",
|
||||
"category": "附件分类",
|
||||
"content_types": "具体类型"
|
||||
},
|
||||
"search_view": {
|
||||
"button_label": "视图",
|
||||
|
||||
Reference in New Issue
Block a user