diff --git a/Components/Repo/Repo.module.css b/Components/Repo/Repo.module.css index 05e5886d..1fa13429 100644 --- a/Components/Repo/Repo.module.css +++ b/Components/Repo/Repo.module.css @@ -33,6 +33,26 @@ 0 10px 30px var(--shadow-md); } +.cardArchived { + background: var(--surface-2); + border-style: dashed; + box-shadow: none; + opacity: 0.62; + filter: grayscale(0.7); +} +.cardArchived::before { + background: var(--text-faint); + opacity: 0.6; +} +.cardArchived:hover { + opacity: 1; + filter: grayscale(0); + border-color: var(--border); + box-shadow: + 0 1px 2px var(--shadow-sm), + 0 6px 16px -8px var(--shadow-md); +} + /* Left thiny rail */ .card::before { content: ''; @@ -127,6 +147,9 @@ animation: pulseRed 4s infinite; animation-delay: 0.5s; } +.statusArchived { + background: var(--text-muted); +} @keyframes pulseGreen { 0% { @@ -286,6 +309,28 @@ align-items: center; justify-content: flex-end; align-self: center; + gap: 4px; +} + +.compactButton { + display: inline-flex; + align-items: center; + justify-content: center; + height: 30px; + width: 30px; + border: none; + border-radius: 8px; + background: transparent; + color: var(--primary); + cursor: pointer; + transition: background 0.15s ease; +} +.compactButton:hover:not(:disabled) { + background: #6d4aff14; +} +.compactButton:disabled { + opacity: 0.5; + cursor: not-allowed; } @media all and (max-width: 1000px) { diff --git a/Components/Repo/Repo.tsx b/Components/Repo/Repo.tsx index 88194764..18461165 100644 --- a/Components/Repo/Repo.tsx +++ b/Components/Repo/Repo.tsx @@ -1,6 +1,6 @@ import { useState, useMemo } from 'react'; import classes from './Repo.module.css'; -import { IconSettings, IconChevronDown, IconBellOff, IconLockPlus, IconCloud } from '@tabler/icons-react'; +import { IconSettings, IconChevronDown, IconBellOff, IconLockPlus, IconCloud, IconPackage, IconArchive } from '@tabler/icons-react'; import StorageBar from '../UI/StorageBar/StorageBar'; import InfoTooltip from '../UI/InfoTooltip/InfoTooltip'; import RepoIcon from './RepoIcon'; @@ -8,6 +8,7 @@ import QuickCommands from './QuickCommands/QuickCommands'; import { Repository, WizardEnvType, Optional, DateFormatEnum } from '~/types'; import { fromUnixTime, formatDistanceStrict } from 'date-fns'; import { formatDate } from '~/helpers/functions'; +import { compactRepository } from '~/helpers/functions/compactRepository'; import useMedia from 'use-media'; type RepoProps = Omit & { @@ -50,6 +51,15 @@ export default function Repo(props: RepoProps) { //States const [displayDetails, setDisplayDetails] = useState(displayDetailsFromLS); + const [isCompacting, setIsCompacting] = useState(false); + + const compactEnabled = props.wizardEnv?.DISABLE_COMPACT_REPO !== 'true' && !props.archived; + + const compactHandler = async () => { + setIsCompacting(true); + await compactRepository(props.repositoryName); + setIsCompacting(false); + }; //BUTTON : Display or not repo details for ONE repo const displayDetailsForOneHandler = (boolean: boolean) => { @@ -69,19 +79,34 @@ export default function Repo(props: RepoProps) { props.lastSave === 0 ? undefined : formatDate(props.lastSave, props.dateFormat); //Repo identity: gradient icon avatar with status badge - const repoIdentity = () => ( - - ); + const getStatusInfo = (): { className: string; title: string } => { + if (props.archived) { + return { className: classes.statusArchived, title: 'Archived' }; + } + if (props.status) { + return { className: classes.statusOk, title: 'Status OK' }; + } + return { className: classes.statusKo, title: 'Status error' }; + }; + + const repoIdentity = () => { + const status = getStatusInfo(); + return ( + + ); + }; //Indicator chips (append-only, alert, comment) const indicatorChips = () => ( <> + {props.archived && ( +
+ +
+ )} {props.appendOnlyMode && (
@@ -114,7 +139,7 @@ export default function Repo(props: RepoProps) { // ---------- MOBILE ---------- if (isMobile) { return ( -
+
{repoIdentity()} @@ -133,7 +158,7 @@ export default function Repo(props: RepoProps) { // ---------- DESKTOP ---------- return ( -
+
{repoIdentity()} @@ -195,6 +220,17 @@ export default function Repo(props: RepoProps) {
+ {compactEnabled && ( + + )} + )}
diff --git a/Containers/RepoManage/ConfirmDialog.module.css b/Containers/RepoManage/ConfirmDialog.module.css new file mode 100644 index 00000000..4c909c4f --- /dev/null +++ b/Containers/RepoManage/ConfirmDialog.module.css @@ -0,0 +1,153 @@ +.wrapper { + text-align: center; + margin: auto; + width: 100%; + max-width: 520px; + color: var(--text-strong); + display: flex; + flex-direction: column; + align-items: center; + padding: 8px 0 4px; +} + +.iconCircle { + display: flex; + align-items: center; + justify-content: center; + height: 72px; + width: 72px; + border-radius: 50%; + margin-bottom: 18px; +} + +.iconCircleDanger { + background: var(--danger-soft); + border: 1px solid var(--danger-border); + color: var(--danger); +} + +.iconCircleWarning { + background: rgba(245, 158, 11, 0.12); + border: 1px solid rgba(245, 158, 11, 0.35); + color: var(--amber); +} + +.wrapper h1 { + font-size: 1.4rem; + font-weight: 700; + letter-spacing: -0.01em; + margin: 0 0 4px; + color: var(--text-strong); +} + +.repoName { + color: var(--primary); + font-weight: 700; +} + +.message { + border-radius: 14px; + padding: 16px 18px; + margin: 18px 0 22px; + font-size: 0.9rem; + line-height: 1.5; + text-align: left; +} + +.message b { + font-weight: 700; +} + +.messageDanger { + background: var(--danger-soft); + border: 1px solid var(--danger-border); + color: var(--danger-text); +} + +.messageWarning { + background: rgba(245, 158, 11, 0.1); + border: 1px solid rgba(245, 158, 11, 0.3); + color: var(--text-secondary); +} + +.messageWarning b { + color: var(--text-strong); +} + +.buttons { + display: flex; + justify-content: center; + gap: 12px; + width: 100%; +} + +.cancel { + border: 1px solid var(--border-strong); + padding: 0.7rem 1.4rem; + background: var(--surface); + color: var(--text-secondary); + border-radius: 12px; + cursor: pointer; + font-weight: 600; + font-size: 0.95rem; + transition: + background 0.15s ease, + border-color 0.15s ease; +} + +.cancel:hover:not(:disabled) { + background: var(--surface-hover); + border-color: var(--border-strong); +} + +.cancel:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.confirm { + border: 0; + padding: 0.7rem 1.5rem; + color: #fff; + border-radius: 12px; + cursor: pointer; + font-weight: 600; + font-size: 0.95rem; + transition: + transform 0.15s ease, + box-shadow 0.15s ease, + filter 0.15s ease; +} + +.confirm:hover:not(:disabled) { + filter: brightness(1.05); + transform: translateY(-1px); +} + +.confirm:active:not(:disabled) { + transform: translateY(0); +} + +.confirm:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.confirmDanger { + background: linear-gradient(135deg, #ff5d5d 0%, #ff2d2d 100%); + box-shadow: 0 3px 8px -3px rgba(255, 45, 45, 0.3); +} + +.confirmDanger:hover:not(:disabled) { + box-shadow: 0 5px 12px -4px rgba(255, 45, 45, 0.35); +} + +.confirmWarning { + background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 100%); + color: #3a2c05; + box-shadow: 0 3px 8px -3px rgba(245, 158, 11, 0.3); +} + +.confirmWarning:hover:not(:disabled) { + box-shadow: 0 5px 12px -4px rgba(245, 158, 11, 0.35); +} diff --git a/Containers/RepoManage/ConfirmDialog.tsx b/Containers/RepoManage/ConfirmDialog.tsx new file mode 100644 index 00000000..3d39e6fc --- /dev/null +++ b/Containers/RepoManage/ConfirmDialog.tsx @@ -0,0 +1,62 @@ +import { ReactNode } from 'react'; +import classes from './ConfirmDialog.module.css'; + +type ConfirmDialogVariant = 'danger' | 'warning'; + +type ConfirmDialogProps = { + variant: ConfirmDialogVariant; + icon: ReactNode; + title: ReactNode; + children: ReactNode; + confirmLabel: string; + busyLabel?: string; + isBusy?: boolean; + onConfirm: () => void; + onCancel: () => void; +}; + +/** Highlights a repository name inside a dialog title. */ +export function DialogHighlight({ children }: { children: ReactNode }) { + return {children}; +} + +/** + * Generic confirmation dialog used for the repository maintenance/danger + * actions (delete, break-lock, archive). + */ +export default function ConfirmDialog({ + variant, + icon, + title, + children, + confirmLabel, + busyLabel, + isBusy = false, + onConfirm, + onCancel, +}: ConfirmDialogProps) { + const iconCircleClass = + variant === 'danger' ? classes.iconCircleDanger : classes.iconCircleWarning; + const messageClass = variant === 'danger' ? classes.messageDanger : classes.messageWarning; + const confirmClass = variant === 'danger' ? classes.confirmDanger : classes.confirmWarning; + + return ( +
+
{icon}
+

{title}

+
{children}
+
+ + +
+
+ ); +} diff --git a/Containers/RepoManage/RepoActionsFooter.module.css b/Containers/RepoManage/RepoActionsFooter.module.css new file mode 100644 index 00000000..d485e7ae --- /dev/null +++ b/Containers/RepoManage/RepoActionsFooter.module.css @@ -0,0 +1,64 @@ +.footer { + margin-top: 40px; + display: flex; + flex-direction: column; +} + +.divider { + height: 1px; + background: var(--border-strong); + margin: 0 0 16px; +} + +.row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.maintenanceActions { + display: flex; + align-items: center; + gap: 12px; +} + +.actionButton, +.deleteButton { + display: inline-flex; + align-items: center; + gap: 7px; + border: 1px solid var(--border-strong); + background: transparent; + color: var(--text-secondary); + border-radius: 10px; + padding: 0.5rem 0.9rem; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + transition: + background 0.15s ease, + border-color 0.15s ease, + color 0.15s ease; +} + +.actionButton:hover:not(:disabled) { + background: var(--surface-hover); + color: var(--text-strong); +} + +.actionButton:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.deleteButton { + border-color: var(--danger-border); + color: var(--danger-text); +} + +.deleteButton:hover { + background: linear-gradient(135deg, #ff5d5d 0%, #ff2d2d 100%); + border-color: transparent; + color: #fff; +} diff --git a/Containers/RepoManage/RepoActionsFooter.tsx b/Containers/RepoManage/RepoActionsFooter.tsx new file mode 100644 index 00000000..df99b848 --- /dev/null +++ b/Containers/RepoManage/RepoActionsFooter.tsx @@ -0,0 +1,69 @@ +import { IconArchive, IconArchiveOff, IconLockOpen, IconTrash } from '@tabler/icons-react'; +import classes from './RepoActionsFooter.module.css'; + +type RepoActionsFooterProps = { + isArchived: boolean; + isArchiving: boolean; + onBreakLock: () => void; + onArchive: () => void; + onUnarchive: () => void; + onDelete: () => void; +}; + +/** + * Maintenance and danger actions shown at the bottom of the edit form: + * break-lock, archive/unarchive and delete. + */ +export default function RepoActionsFooter({ + isArchived, + isArchiving, + onBreakLock, + onArchive, + onUnarchive, + onDelete, +}: RepoActionsFooterProps) { + return ( +
+
+
+
+ + {isArchived ? ( + + ) : ( + + )} +
+ +
+
+ ); +} diff --git a/Containers/RepoManage/RepoManage.module.css b/Containers/RepoManage/RepoManage.module.css index c9f8b75d..606bb94e 100644 --- a/Containers/RepoManage/RepoManage.module.css +++ b/Containers/RepoManage/RepoManage.module.css @@ -19,6 +19,8 @@ border: 1px solid var(--border); padding: 30px 32px 28px; overflow: auto; + scrollbar-width: none; + -ms-overflow-style: none; border-radius: 20px; box-shadow: 0 1px 2px var(--shadow-sm), @@ -27,6 +29,10 @@ animation: modaleIn 0.26s cubic-bezier(0.16, 1, 0.3, 1) both; } +.modale::-webkit-scrollbar { + display: none; +} + .modale h2 { margin: 0 0 0.4rem; font-size: 1.35rem; @@ -238,131 +244,48 @@ color: var(--text-secondary); } -/* DELETE DIALOG */ +.formFieldset { + border: 0; + margin: 0; + padding: 0; + min-width: 0; + transition: + opacity 0.2s ease, + filter 0.2s ease; +} -.deleteDialogWrapper { - text-align: center; - margin: auto; - width: 100%; - max-width: 520px; - color: var(--text-strong); - display: flex; - flex-direction: column; - align-items: center; - padding: 8px 0 4px; +.formFieldsetArchived { + opacity: 0.5; + filter: grayscale(0.7); + pointer-events: none; + user-select: none; } -.deleteIconCircle { +.archivedBanner { display: flex; align-items: center; - justify-content: center; - height: 72px; - width: 72px; - border-radius: 50%; - background: var(--danger-soft); - border: 1px solid var(--danger-border); - color: var(--danger); - margin-bottom: 18px; -} - -.deleteDialogWrapper h1 { - font-size: 1.4rem; - font-weight: 700; - letter-spacing: -0.01em; - margin: 0 0 4px; - color: var(--text-strong); -} - -.deleteRepoName { - color: var(--primary); - font-weight: 700; -} - -.deleteDialogMessage { - background: var(--danger-soft); - border: 1px solid var(--danger-border); - color: var(--danger-text); - border-radius: 14px; - padding: 16px 18px; - margin: 18px 0 22px; - font-size: 0.9rem; - line-height: 1.5; + gap: 8px; text-align: left; -} - -.deleteDialogMessage b { - font-weight: 700; -} - -.deleteDialogButtonWrapper { - display: flex; - justify-content: center; - gap: 12px; width: 100%; -} - -.cancelButton { - border: 1px solid var(--border-strong); - padding: 0.7rem 1.4rem; - background: var(--surface); + max-width: 600px; + margin: 6px auto 16px; + background: rgba(245, 158, 11, 0.1); + border: 1px solid rgba(245, 158, 11, 0.3); color: var(--text-secondary); border-radius: 12px; - cursor: pointer; - font-weight: 600; - font-size: 0.95rem; - transition: - background 0.15s ease, - border-color 0.15s ease; -} - -.cancelButton:hover:not(:disabled) { - background: var(--surface-hover); - border-color: var(--border-strong); -} - -.cancelButton:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.deleteButton { - border: 0; - padding: 0.7rem 1.5rem; - background: linear-gradient(135deg, #ff5d5d 0%, #ff2d2d 100%); - color: #fff; - border-radius: 12px; - cursor: pointer; - font-weight: 600; - font-size: 0.95rem; - box-shadow: 0 3px 8px -3px rgba(255, 45, 45, 0.3); - transition: - transform 0.15s ease, - box-shadow 0.15s ease, - filter 0.15s ease; -} - -.deleteButton:hover { - filter: brightness(1.05); - transform: translateY(-1px); - box-shadow: 0 5px 12px -4px rgba(255, 45, 45, 0.35); + padding: 10px 14px; + font-size: 0.85rem; + line-height: 1.4; + box-sizing: border-box; } -.deleteButton:active { - transform: translateY(0); +.archivedBanner svg { + color: var(--amber); + flex-shrink: 0; } -.littleDeleteButton { - margin-top: 14px; - border: none; - font-weight: 500; - font-size: 0.85rem; - color: var(--danger-text); - background: none; - cursor: pointer; - transition: color 0.15s ease; +.archivedBanner b { + color: var(--text-strong); + font-weight: 700; } -.littleDeleteButton:hover { - color: var(--danger-strong); - text-decoration: underline; -} diff --git a/Containers/RepoManage/RepoManage.tsx b/Containers/RepoManage/RepoManage.tsx index eaa26b02..2a3afdd2 100644 --- a/Containers/RepoManage/RepoManage.tsx +++ b/Containers/RepoManage/RepoManage.tsx @@ -1,4 +1,10 @@ -import { IconAlertCircle, IconExternalLink, IconX } from '@tabler/icons-react'; +import { + IconArchive, + IconAlertCircle, + IconExternalLink, + IconLockOpen, + IconX, +} from '@tabler/icons-react'; import dynamic from 'next/dynamic'; import Link from 'next/link'; import { useRouter } from 'next/router'; @@ -8,9 +14,13 @@ import Select from 'react-select'; import { bwSelectStyles, bwSelectTheme } from '~/Components/UI/Select/bwSelectStyles'; import { toast, ToastOptions } from 'react-toastify'; import 'react-toastify/dist/ReactToastify.css'; +import { useSWRConfig } from 'swr'; import { useLoader } from '~/contexts/LoaderContext'; import { alertOptions, Optional, Repository, StorageTarget } from '~/types'; import { DEFAULT_REPO_ICON } from '~/Components/Repo/repoIcons'; +import ConfirmDialog, { DialogHighlight } from './ConfirmDialog'; +import RepoActionsFooter from './RepoActionsFooter'; +import { useRepoActions } from './useRepoActions'; import classes from './RepoManage.module.css'; // Lazy-loaded: the curated icon grid only ships when the add/edit form is opened. @@ -36,6 +46,7 @@ type DataForm = { export default function RepoManage(props: RepoManageProps) { const router = useRouter(); + const { mutate } = useSWRConfig(); const targetRepo = props.mode === 'edit' && router.query.slug ? props.repoList?.find((repo) => repo.id.toString() === router.query.slug) @@ -58,8 +69,9 @@ export default function RepoManage(props: RepoManageProps) { progress: undefined, }; - const [deleteDialog, setDeleteDialog] = useState(false); const [isLoading, setIsLoading] = useState(false); + const isArchived = !!targetRepo?.archived; + const actions = useRepoActions(targetRepo); const [icon, setIcon] = useState( (props.mode === 'edit' ? targetRepo?.icon : undefined) ?? DEFAULT_REPO_ICON ); @@ -100,55 +112,6 @@ export default function RepoManage(props: RepoManageProps) { } } - //Delete a repo - const deleteHandler = async (repositoryName?: string) => { - start(); - if (!repositoryName) { - stop(); - toast.error('Repository name not found', toastOptions); - router.replace('/'); - return; - } - //API Call for delete - await fetch('/api/v1/repositories/' + repositoryName, { - method: 'DELETE', - headers: { - 'Content-type': 'application/json', - }, - }) - .then(async (response) => { - if (response.ok) { - toast.success( - '🗑 The repository ' + repositoryName + ' has been successfully deleted', - toastOptions - ); - router.replace('/'); - } else { - if (response.status == 403) { - toast.warning( - '🔒 The server is currently protected against repository deletion.', - toastOptions - ); - setIsLoading(false); - router.replace('/'); - } else { - const errorMessage = await response.json(); - toast.error(`An error has occurred : ${errorMessage.message.stderr}`, toastOptions); - router.replace('/'); - console.log('Fail to delete'); - } - } - }) - .catch((error) => { - toast.error('An error has occurred', toastOptions); - router.replace('/'); - console.log(error); - }) - .finally(() => { - stop(); - }); - }; - const isSSHKeyUnique = async (sshPublicKey: string): Promise => { try { // Extract the first two columns of the SSH key in the form @@ -218,6 +181,7 @@ export default function RepoManage(props: RepoManageProps) { .then(async (response) => { if (response.ok) { toast.success('New repository added ! 🥳', toastOptions); + await mutate('/api/v1/repositories'); router.replace('/'); } else { const errorMessage = await response.json(); @@ -260,6 +224,7 @@ export default function RepoManage(props: RepoManageProps) { 'The repository ' + targetRepo?.repositoryName + ' has been successfully edited !', toastOptions ); + await mutate('/api/v1/repositories'); router.replace('/'); } else { const errorMessage = await response.json(); @@ -287,43 +252,75 @@ export default function RepoManage(props: RepoManageProps) {
- {deleteDialog ? ( -
-
- + {actions.deleteDialog ? ( + } + title={ + <> + Delete the repository{' '} + {targetRepo?.repositoryName} ? + + } + confirmLabel='Yes, delete it !' + isBusy={actions.isDeleting} + onCancel={actions.closeDeleteDialog} + onConfirm={actions.confirmDelete} + > +
+ You are about to permanently delete the repository {targetRepo?.repositoryName}{' '} + and all the backups it contains.
-

- Delete the repository{' '} - {targetRepo?.repositoryName} ? -

-
-
- You are about to permanently delete the repository{' '} - {targetRepo?.repositoryName} and all the backups it contains. -
-
The data will not be recoverable and it will not be possible to go back.
+
The data will not be recoverable and it will not be possible to go back.
+ + ) : actions.breakLockDialog ? ( + } + title={ + <> + Break the lock on {targetRepo?.repositoryName} ? + + } + confirmLabel='Break the lock' + busyLabel='Releasing…' + isBusy={actions.isBreakingLock} + onCancel={actions.closeBreakLockDialog} + onConfirm={actions.confirmBreakLock} + > +
+ This releases a stale lock left by an interrupted operation (e.g. a container restart + during a compaction). +
+
+ Only do this if backups fail with a lock error and{' '} + no backup or compaction is currently running on this repository.
-
+ + ) : actions.archiveDialog ? ( + } + title={ <> - - + Archive {targetRepo?.repositoryName} ? + } + confirmLabel='Archive it' + busyLabel='Archiving…' + isBusy={actions.isArchiving} + onCancel={actions.closeArchiveDialog} + onConfirm={actions.confirmArchive} + > +
+ Archiving freezes this repository: the client can no longer connect (no backup, + prune or restore) and it stops triggering notifications.
-
+
+ The data is kept untouched and this is fully reversible. You can simply + unarchive the repository at any time to resume normal operations. +
+
) : (
{props.mode == 'edit' && ( @@ -339,197 +336,217 @@ export default function RepoManage(props: RepoManageProps) { )} {props.mode == 'add' &&

Add a repository

} + {isArchived && ( +
+ + + This repository is archived and frozen. Unarchive it below to make changes. + +
+ )}
- {/* ALIAS */} - - - {errors.alias && {errors.alias.message}} - {/* ICON */} - - - {/* SSH KEY */} - -