diff --git a/web/src/components/pagination.tsx b/web/src/components/pagination.tsx index 04b9298..644830e 100644 --- a/web/src/components/pagination.tsx +++ b/web/src/components/pagination.tsx @@ -24,6 +24,7 @@ import { DoubleArrowRightIcon, } from '@radix-ui/react-icons' import { Button } from '@/components/ui/button' +import { Input } from "@/components/ui/input" import { Select, SelectContent, @@ -33,14 +34,15 @@ import { } from '@/components/ui/select' import { useTranslation } from 'react-i18next' import { showNumbers } from '@/lib/utils' +import { useEffect, useState } from 'react' interface PaginationProps { totalItems: number - pageIndex: number, - pageSize: number, - hasNextPage: () => boolean, - setPageIndex: (pageIndex: number) => void, - setPageSize: (pageSize: number) => void, + pageIndex: number + pageSize: number + hasNextPage: () => boolean + setPageIndex: (pageIndex: number) => void + setPageSize: (pageSize: number) => void } export function EnvelopeListPagination({ @@ -52,8 +54,13 @@ export function EnvelopeListPagination({ setPageSize, }: PaginationProps) { const { t } = useTranslation() + const [pageInput, setPageInput] = useState(pageIndex + 1) const pageCount = Math.ceil(totalItems / pageSize) + useEffect(() => { + setPageInput(pageIndex + 1) + }, [pageIndex]) + const handlePageSizeChange = (value: string) => { const newPageSize = Number(value) setPageSize(newPageSize) @@ -69,7 +76,7 @@ export function EnvelopeListPagination({ setPageIndex(newPageIndex) } - const currentPage = pageIndex + 1; + const currentPage = pageIndex + 1 const pageNumbers = showNumbers(currentPage, pageCount) return ( @@ -97,7 +104,16 @@ export function EnvelopeListPagination({
- {t("table.page")} {pageIndex + 1} {t("table.of")} {pageCount} + {t("table.page")} + { + if (Number.isNaN(pageInput)) return + if (pageInput > 0) setPageIndex(pageInput - 1) + else setPageIndex(0) + }} + onChange={(e) => setPageInput(Number(e.target.value))} + className='mx-2 w-20' + /> + {t("table.of")} {pageCount}
) -} \ No newline at end of file +} diff --git a/web/src/components/ui/scroll-area.tsx b/web/src/components/ui/scroll-area.tsx index e8ca960..ba9b499 100644 --- a/web/src/components/ui/scroll-area.tsx +++ b/web/src/components/ui/scroll-area.tsx @@ -4,7 +4,7 @@ import { cn } from '@/lib/utils' interface ScrollAreaProps extends React.ComponentPropsWithoutRef { - orientation?: 'horizontal' | 'vertical' + orientation?: 'horizontal' | 'vertical' | 'both' } const ScrollArea = React.forwardRef< @@ -24,7 +24,12 @@ const ScrollArea = React.forwardRef< > {children} - + {orientation === "both" ? ( + <> + + + + ) : } )) diff --git a/web/src/features/search/columns-dialog.tsx b/web/src/features/search/columns-dialog.tsx new file mode 100755 index 0000000..3545576 --- /dev/null +++ b/web/src/features/search/columns-dialog.tsx @@ -0,0 +1,143 @@ +// +// Copyright (c) 2025 rustmailer.com (https://rustmailer.com) +// +// This file is part of the Bichon Email Archiving Project +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { useTranslation } from 'react-i18next' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { SquarePen } from 'lucide-react' +import { Button } from '@/components/button' +import { Checkbox } from '@/components/ui/checkbox' +import { useState } from 'react' +import { useSearchContext } from './context' +import { Label } from '@/components/ui/label' + +interface Props { + open: boolean + onOpenChange: (open: boolean) => void +} + +const defaultColumns = (t: (key: string) => string) => [ + { + label: t('search.account'), + value: "account_email" + }, + { + label: t('search.mailbox'), + value: "mailbox_name" + }, + { + label: t('search.from'), + value: "from" + }, + { + label: t('search.to'), + value: "to" + }, + { + label: t('search.subject'), + value: "subject" + }, + { + label: t('mail.attachments'), + value: "attachments" + }, + { + label: t('search.size'), + value: "size" + }, + { + label: t('search.date'), + value: "date" + }, +] + +export function ColumnsDialog({ open, onOpenChange }: Props) { + const { t } = useTranslation() + const { setColumnVisibility } = useSearchContext() + const columns = defaultColumns(t) + + const [selected, setSelected] = useState(() => { + const _columns = localStorage.getItem("searchTableColumns") + ? JSON.parse(localStorage.getItem("searchTableColumns") as string) as Record + : undefined + + if (_columns) return new Map(Object.entries(_columns).map(([key, value]) => [key, value])) + return new Map(columns.map((col) => [col.value, true])) + }) + + const handleSave = () => { + const _selected = Object.fromEntries(selected) + setColumnVisibility(_selected) + localStorage.setItem("searchTableColumns", JSON.stringify(_selected)) + onOpenChange(false) + } + + const toggleSelected = (column: string) => { + setSelected(prev => { + const value = new Map(prev) + + if (value.get(column)) { + value.set(column, false) + } else { + value.set(column, true) + } + + return value + }) + } + + return ( + + + + + + {t('common.columns')} + + + +
+
+ {columns.map(col => ( +
+ toggleSelected(col.value)} + /> + +
+ ))} +
+
+ +
+
+ + +
+
+
+
+ ) +} diff --git a/web/src/features/search/context/index.tsx b/web/src/features/search/context/index.tsx index f8e5150..3698646 100644 --- a/web/src/features/search/context/index.tsx +++ b/web/src/features/search/context/index.tsx @@ -19,8 +19,9 @@ import React from 'react' import { EmailEnvelope } from '@/api' +import { SortingState, VisibilityState } from '@tanstack/react-table' -export type SearchDialogType = 'mailbox' | 'display' | 'delete' | 'filters' | 'tags' | 'edit-tags' | 'search-form' | 'restore' +export type SearchDialogType = 'mailbox' | 'display' | 'delete' | 'filters' | 'tags' | 'edit-tags' | 'search-form' | 'restore' | 'columns' interface SearchContextType { open: SearchDialogType | null @@ -32,6 +33,10 @@ interface SearchContextType { selected: Map> setSelected: React.Dispatch>>> selectedTags: string[] + sorting: SortingState + setSorting: React.Dispatch> + columnVisibility: VisibilityState + setColumnVisibility: React.Dispatch> } const SearchContext = React.createContext(null) diff --git a/web/src/features/search/index.tsx b/web/src/features/search/index.tsx index 876678e..a5cf1e4 100644 --- a/web/src/features/search/index.tsx +++ b/web/src/features/search/index.tsx @@ -23,10 +23,9 @@ import { Main } from '@/components/layout/main'; import { useSearchMessages } from '@/hooks/use-search-messages'; import { SearchFormDialog } from './search-form'; import { EnvelopeListPagination } from '@/components/pagination'; -import { MailList } from './mail-list'; import React from 'react'; import { EmailEnvelope } from '@/api'; -import { ArrowDownWideNarrow, ArrowUpWideNarrow, Filter, SearchIcon } from 'lucide-react'; +import { Filter, SearchIcon, SquarePen } from 'lucide-react'; import { MailDisplayDrawer } from './mail-display-dialog'; import { EnvelopeDeleteDialog } from './delete-dialog'; import SearchProvider, { SearchDialogType } from './context'; @@ -39,8 +38,9 @@ import { EditTagsDialog } from './add-tag-dialog'; import { useTranslation } from 'react-i18next'; import Logo from '@/assets/logo.svg' import { RestoreMessageDialog } from './restore-message-dialog'; -import { Separator } from '@/components/ui/separator'; -import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; +import { ColumnsDialog } from './columns-dialog'; +import { MailListTable } from './mail-list-table'; +import { SortingState, VisibilityState } from '@tanstack/react-table'; export default function Search() { const { t } = useTranslation() @@ -49,6 +49,11 @@ export default function Search() { const [toDelete, setToDelete] = React.useState>>(new Map()); const [selected, setSelected] = React.useState>>(new Map()); const [selectedTags, setSelectedTags] = React.useState([]); + const [sorting, setSorting] = React.useState([{ id: "date", desc: true }]); + const [columnVisibility, setColumnVisibility] = React.useState(localStorage.getItem("searchTableColumns") + ? JSON.parse(localStorage.getItem("searchTableColumns") as string) as Record + : {} + ) const { emails, @@ -58,8 +63,6 @@ export default function Search() { isFetching, page, pageSize, - sortBy, - sortOrder, setPage, setPageSize, setSortBy, @@ -92,7 +95,23 @@ export default function Search() { <>
- +
@@ -127,7 +146,7 @@ export default function Search() {
-
+
-
- - {t('search.sort')} - - - value && setSortBy(value as "DATE" | "SIZE")} - className="gap-1" - > - - {t('search.date')} - - - {t('search.size')} - - - - -
+
{isLoading && ( @@ -204,14 +192,16 @@ export default function Search() {

} - {total > 0 && - 0 && + { setOpen('display'); setSelectedEnvelope(envelope); }} + setSortBy={setSortBy} + setSortOrder={setSortOrder} /> } {total > 0 && setOpen('restore')} /> - + + setOpen('columns')} + /> + +
); diff --git a/web/src/features/search/mail-list-table.tsx b/web/src/features/search/mail-list-table.tsx new file mode 100755 index 0000000..c8dbb74 --- /dev/null +++ b/web/src/features/search/mail-list-table.tsx @@ -0,0 +1,239 @@ +// +// Copyright (c) 2025 rustmailer.com (https://rustmailer.com) +// +// This file is part of the Bichon Email Archiving Project +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + + +import { dateFnsLocaleMap, formatBytes } from "@/lib/utils" +import { format, formatDistanceToNow } from "date-fns" +import { Paperclip } from "lucide-react" +import { Skeleton } from "@/components/ui/skeleton" +import { Checkbox } from "@/components/ui/checkbox" +import { EmailEnvelope } from "@/api" +import { useSearchContext } from "./context" +import { MailBulkActions } from "./bulk-actions" +import { useTranslation } from 'react-i18next' +import { enUS } from "date-fns/locale" +import { ColumnDef } from "@tanstack/react-table" +import LongText from "@/components/long-text" +import { DataTableColumnHeader } from "./table/data-table-column-header" +import { SearchTable } from "./table/table" +import { DataTableRowActions } from "./table/data-table-row-actions" +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' + +interface MailListProps { + items: EmailEnvelope[] + isLoading: boolean + onEnvelopeChanged: (envelope: EmailEnvelope) => void + setSortBy: (sortBy: "DATE" | "SIZE") => void + setSortOrder: (value: "desc" | "asc") => void +} + +export function MailListTable({ + items, + isLoading, + onEnvelopeChanged, + setSortBy, + setSortOrder +}: MailListProps) { + const { t, i18n } = useTranslation() + + const locale = dateFnsLocaleMap[i18n.language.toLowerCase()] ?? enUS + const { selected, setSelected } = useSearchContext() + + const columns: ColumnDef[] = [ + { + accessorKey: "id", + header: () => ( + 0 + ? true + : totalSelected > 0 + ? "indeterminate" + : false + } + onCheckedChange={handleToggleAll} + className="h-4 w-4" + /> + ), + cell: ({ row }) => ( + toggleSelected(row.original.account_id, row.original.id)} + onClick={(e) => e.stopPropagation()} + className="h-4 w-4 shrink-0" + /> + ), + meta: { className: 'text-left text-sm' }, + minSize: 25, + maxSize: 25, + }, + { + accessorKey: "account_email", + header: t('search.account'), + cell: ({ row }) => {row.original.account_email}, + meta: { className: 'text-left text-sm' }, + minSize: 166 + }, + { + accessorKey: "mailbox_name", + header: t('search.mailbox'), + cell: ({ row }) => {row.original.mailbox_name}, + meta: { className: 'text-left text-sm' }, + minSize: 116, + maxSize: 116, + }, + { + accessorKey: "from", + header: t('search.from'), + cell: ({ row }) => {row.original.from}, + meta: { className: 'text-left text-sm' }, + minSize: 150, + }, + { + accessorKey: "to", + header: t('search.to'), + cell: ({ row }) => {row.original.to.join(", ")}, + meta: { className: 'text-left text-sm' }, + }, + { + accessorKey: "subject", + header: t('search.subject'), + cell: ({ row }) => {row.original.subject}, + meta: { className: 'text-left text-sm' }, + size: 1000 + }, + { + id: "attachment_count", + header: () => , + cell: ({ row }) => {(row.original.attachments ?? []).length}, + meta: { className: 'text-left text-sm' }, + minSize: 40, + maxSize: 40 + }, + { + accessorKey: 'size', + header: ({ column }) => ( + + ), + cell: ({ row }) => {formatBytes(row.original.size)}, + meta: { className: 'text-left text-sm' }, + minSize: 100, + maxSize: 100, + }, + { + accessorKey: 'date', + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const date = new Date(row.original.date) + const title = format(date, 'yyyy-MM-dd HH:mm:ss') + return ( + + + + {formatDistanceToNow(date, { addSuffix: true, locale })} + + + {title} + + ) + }, + meta: { className: 'text-left text-sm' }, + minSize: 100, + }, + { + id: 'actions', + header: t('users.columns.actions'), + cell: DataTableRowActions, + minSize: 70, + maxSize: 70, + }, + ] + + const handleToggleAll = () => { + const total = Array.from(selected.values()).reduce((sum, set) => sum + set.size, 0) + + if (total === items.length && items.length > 0) { + setSelected(new Map()) + } else { + setSelected(prev => { + const next = new Map(prev) + for (const item of items) { + const set = new Set(next.get(item.account_id) || []) + set.add(item.id) + next.set(item.account_id, set) + } + return next + }) + } + } + + const toggleSelected = (accountId: number, mailId: number) => { + setSelected(prev => { + const next = new Map(prev) + const set = new Set(next.get(accountId) || []) + + if (set.has(mailId)) { + set.delete(mailId) + if (set.size === 0) next.delete(accountId) + else next.set(accountId, set) + } else { + set.add(mailId) + next.set(accountId, set) + } + return next + }) + } + + const totalSelected = Array.from(selected.values()).reduce((sum, set) => sum + set.size, 0) + + const hasSelected = (accountId: number, mailId: number) => selected.get(accountId)?.has(mailId) ?? false + + if (isLoading) { + return ( +
+ {Array.from({ length: 8 }).map((_, i) => ( +
+ + + + +
+ ))} +
+ ) + } + + return ( + <> + { + const target = e.target as HTMLElement + if (target.closest('input[type="checkbox"], button')) return + onEnvelopeChanged(row.original) + }} + setSortBy={setSortBy} + setSortOrder={setSortOrder} + /> + {totalSelected > 0 && } + + ) +} diff --git a/web/src/features/search/table/data-table-column-header.tsx b/web/src/features/search/table/data-table-column-header.tsx new file mode 100755 index 0000000..7de52cf --- /dev/null +++ b/web/src/features/search/table/data-table-column-header.tsx @@ -0,0 +1,83 @@ +// +// Copyright (c) 2025 rustmailer.com (https://rustmailer.com) +// +// This file is part of the Bichon Email Archiving Project +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + + +import { + ArrowDownIcon, + ArrowUpIcon, + CaretSortIcon, +} from '@radix-ui/react-icons' +import { Column } from '@tanstack/react-table' +import { cn } from '@/lib/utils' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { useTranslation } from 'react-i18next' + +interface DataTableColumnHeaderProps + extends React.HTMLAttributes { + column: Column + title: string +} + +export function DataTableColumnHeader({ + column, + title, + className, +}: DataTableColumnHeaderProps) { + if (!column.getCanSort()) { + return
{title}
+ } + const { t } = useTranslation() + return ( +
+ + + + + + column.toggleSorting(false)}> + + {t('table.asc')} + + column.toggleSorting(true)}> + + {t('table.desc')} + + + +
+ ) +} diff --git a/web/src/features/search/table/data-table-row-actions.tsx b/web/src/features/search/table/data-table-row-actions.tsx new file mode 100755 index 0000000..728a95f --- /dev/null +++ b/web/src/features/search/table/data-table-row-actions.tsx @@ -0,0 +1,121 @@ +// +// Copyright (c) 2025 rustmailer.com (https://rustmailer.com) +// +// This file is part of the Bichon Email Archiving Project +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + + +import { Row } from '@tanstack/react-table' +import { Button } from '@/components/ui/button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' +import { useTranslation } from 'react-i18next' +import { MoreVertical, TagIcon, Trash2 } from 'lucide-react' +import { EmailEnvelope } from '@/api' +import { useSearchContext } from '../context' + +interface DataTableRowActionsProps { + row: Row +} + +export function DataTableRowActions({ row }: DataTableRowActionsProps) { + const { setOpen, setCurrentEnvelope, setToDelete } = useSearchContext() + const { t } = useTranslation() + + const toggleToDelete = (accountId: number, mailId: number) => { + setToDelete(prev => { + const next = new Map(prev) + const set = new Set(next.get(accountId) || []) + + if (set.has(mailId)) { + set.delete(mailId) + if (set.size === 0) next.delete(accountId) + else next.set(accountId, set) + } else { + set.add(mailId) + next.set(accountId, set) + } + return next + }) + } + + const handleDelete = (envelope: EmailEnvelope) => { + setToDelete(new Map()) + toggleToDelete(envelope.account_id, envelope.id) + setOpen("delete") + } + + return ( + <> + + + + + + { + e.stopPropagation() + setCurrentEnvelope(row.original) + setOpen("edit-tags") + }} + > + {t('search.editTag')} + + + + + + { + e.stopPropagation() + setCurrentEnvelope(row.original) + setOpen("restore") + }} + > + {t('restore_message.restore_to_imap', 'Restore Mail')} + + + + + + { + e.stopPropagation() + handleDelete(row.original) + }} + className='!text-red-500' + > + {t('common.delete')} + + + + + + + + ) +} diff --git a/web/src/features/search/table/table.tsx b/web/src/features/search/table/table.tsx new file mode 100755 index 0000000..c10b26f --- /dev/null +++ b/web/src/features/search/table/table.tsx @@ -0,0 +1,163 @@ +// +// Copyright (c) 2025 rustmailer.com (https://rustmailer.com) +// +// This file is part of the Bichon Email Archiving Project +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + + +import { useState, MouseEvent as ReactMouseEvent, useEffect } from 'react' +import { + ColumnDef, + ColumnFiltersState, + Row, + RowData, + flexRender, + getCoreRowModel, + getFacetedRowModel, + getFacetedUniqueValues, + getFilteredRowModel, + getSortedRowModel, + useReactTable, +} from '@tanstack/react-table' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { useTranslation } from 'react-i18next' +import { EmailEnvelope } from '@/api' +import { cn } from '@/lib/utils' +import { useSearchContext } from '../context' + +declare module '@tanstack/react-table' { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + interface ColumnMeta { + className: string + } +} + +interface DataTableProps { + columns: ColumnDef[] + data: EmailEnvelope[] + onRowClick: (e: ReactMouseEvent, row: Row) => void + setSortBy: (sortBy: "DATE" | "SIZE") => void + setSortOrder: (value: "desc" | "asc") => void +} + +export function SearchTable({ columns, data, onRowClick, setSortBy, setSortOrder }: DataTableProps) { + const { sorting, setSorting, columnVisibility, setColumnVisibility } = useSearchContext() + const { t } = useTranslation() + const [rowSelection, setRowSelection] = useState({}) + const [columnFilters, setColumnFilters] = useState([]) + + useEffect(() => { + const [value] = sorting + setSortBy(value.id.toUpperCase() as "DATE" | "SIZE") + setSortOrder(value.desc ? "desc" : "asc") + }, [sorting]) + + const table = useReactTable({ + data, + columns, + state: { + sorting, + columnVisibility, + rowSelection, + columnFilters, + }, + enableRowSelection: true, + onRowSelectionChange: setRowSelection, + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + onColumnVisibilityChange: setColumnVisibility, + getCoreRowModel: getCoreRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getSortedRowModel: getSortedRowModel(), + getFacetedRowModel: getFacetedRowModel(), + getFacetedUniqueValues: getFacetedUniqueValues(), + }) + + return ( +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ) + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + onRowClick(e, row)} + > + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + {t('common.table.noResults')} + + + )} + +
+
+
+ ) +} diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 8367b9f..98c1a3d 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -47,7 +47,8 @@ "op_failed": "Operation failed", "na": "N/A", "retry": "Retry", - "deleting": "Deleting..." + "deleting": "Deleting...", + "columns": "Columns" }, "navigation": { "home": "Home", @@ -1462,4 +1463,4 @@ "failed": "Failed to restore messages", "failedTitle": "Restore Failed" } -} \ No newline at end of file +}