Compare commits

...

4 commits

Author SHA1 Message Date
89362f473f ci: fix yarn v1 vite hoisting issue for vitest on amd64
All checks were successful
continuous-integration/drone/push Build is passing
2026-03-15 17:40:15 +07:00
943d9b2a0c web: Add drag-and-drop domain group reassignment in ZoneList
When display_by_groups is enabled, domains are now draggable and group
containers act as drop targets. Dropping a domain onto a different group
updates its group via the API and refreshes the domain list.
2026-03-15 17:40:15 +07:00
d4090f983a Add a security policy 2026-03-15 17:40:15 +07:00
94806782e1 ci: Add SBOM generation in SPDX format 2026-03-15 17:40:15 +07:00
12 changed files with 365 additions and 169 deletions

View file

@ -22,55 +22,19 @@ steps:
- yarn config set network-timeout 100000
- yarn --cwd web install
- tar --transform="s@.@./happydomain-${DRONE_COMMIT}@" --exclude-vcs --exclude=./web/node_modules/.cache -czf /dev/shm/happydomain-src.tar.gz .
- mv /dev/shm/happydomain-src.tar.gz .
- mkdir deploy
- mv /dev/shm/happydomain-src.tar.gz deploy
- yarn --cwd web --offline generate:api && sed -i "s/hey-api\.ts';/hey-api';/" web/src/lib/api-base/client.gen.ts
- yarn --cwd web --offline build
- yarn --cwd web-admin --offline generate:api && sed -i "s/hey-api\.ts';/hey-api';/" web/src/lib/api-admin/client.gen.ts
- yarn --cwd web-admin --offline build
- name: deploy sources
image: plugins/s3
settings:
endpoint: https://blob.nemunai.re
path_style: true
region: garage
bucket: happydomain-dl
access_key:
from_secret: s3_access_key
secret_key:
from_secret: s3_secret_key
source: happydomain-src.tar.gz
target: /${DRONE_BRANCH//\//-}/
when:
event:
- push
branch:
exclude:
- renovate/*
- name: deploy sources for release
image: plugins/s3
settings:
endpoint: https://blob.nemunai.re
path_style: true
region: garage
bucket: happydomain-dl
access_key:
from_secret: s3_access_key
secret_key:
from_secret: s3_secret_key
source: happydomain-src.tar.gz
target: /${DRONE_TAG}/
when:
event:
- tag
- name: backend-commit
image: golang:1-alpine
commands:
- apk add --no-cache git
- go build -tags netgo,swagger,web -ldflags '-w -X "main.Version=${DRONE_BRANCH}-${DRONE_COMMIT}" -X main.build=${DRONE_BUILD_NUMBER}' -o happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDomain/
- ln happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} happydomain
- go build -tags netgo,swagger,web -ldflags '-w -X "main.Version=${DRONE_BRANCH}-${DRONE_COMMIT}" -X main.build=${DRONE_BUILD_NUMBER}' -o deploy/happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDomain/
- ln deploy/happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} happydomain
environment:
CGO_ENABLED: 0
when:
@ -82,14 +46,48 @@ steps:
image: golang:1-alpine
commands:
- apk add --no-cache git
- go build -tags netgo,swagger,web -ldflags '-w -X main.Version=${DRONE_TAG##v} -X main.build=${DRONE_BUILD_NUMBER}' -o happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDomain/
- ln happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} happydomain
- go build -tags netgo,swagger,web -ldflags '-w -X main.Version=${DRONE_TAG##v} -X main.build=${DRONE_BUILD_NUMBER}' -o deploy/happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDomain/
- ln deploy/happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} happydomain
environment:
CGO_ENABLED: 0
when:
event:
- tag
- name: generate SBOM
image: nemunaire/drone-syft
settings:
select_catalogers: go,npm
output: spdx-json=deploy/happydomain-sbom.spdx.json
source_name: happyDomain
- name: build-commit macOS
image: golang:1-alpine
commands:
- apk add --no-cache git
- go build -tags netgo,swagger,web -ldflags '-w -X "main.Version=${DRONE_BRANCH}-${DRONE_COMMIT}" -X main.build=${DRONE_BUILD_NUMBER}' -o deploy/happydomain-darwin-${DRONE_STAGE_ARCH} ./cmd/happyDomain/
environment:
CGO_ENABLED: 0
GOOS: darwin
GOARCH: amd64
when:
event:
exclude:
- tag
- name: build-tag macOS
image: golang:1-alpine
commands:
- apk add --no-cache git
- go build -tags netgo,swagger,web -ldflags '-w -X "main.Version=${DRONE_TAG##v}" -X main.build=${DRONE_BUILD_NUMBER}' -o deploy/happydomain-darwin-${DRONE_STAGE_ARCH} ./cmd/happyDomain/
environment:
CGO_ENABLED: 0
GOOS: darwin
GOARCH: amd64
when:
event:
- tag
- name: deploy
image: plugins/s3
settings:
@ -101,8 +99,9 @@ steps:
from_secret: s3_access_key
secret_key:
from_secret: s3_secret_key
source: happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
source: deploy/*
target: /${DRONE_BRANCH//\//-}/
strip_prefix: deploy/
when:
event:
- push
@ -121,72 +120,9 @@ steps:
from_secret: s3_access_key
secret_key:
from_secret: s3_secret_key
source: happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
target: /${DRONE_TAG}/
when:
event:
- tag
- name: build-commit macOS
image: golang:1-alpine
commands:
- apk add --no-cache git
- go build -tags netgo,swagger,web -ldflags '-w -X "main.Version=${DRONE_BRANCH}-${DRONE_COMMIT}" -X main.build=${DRONE_BUILD_NUMBER}' -o happydomain-darwin-${DRONE_STAGE_ARCH} ./cmd/happyDomain/
environment:
CGO_ENABLED: 0
GOOS: darwin
GOARCH: amd64
when:
event:
exclude:
- tag
- name: build-tag macOS
image: golang:1-alpine
commands:
- apk add --no-cache git
- go build -tags netgo,swagger,web -ldflags '-w -X "main.Version=${DRONE_TAG##v}" -X main.build=${DRONE_BUILD_NUMBER}' -o happydomain-darwin-${DRONE_STAGE_ARCH} ./cmd/happyDomain/
environment:
CGO_ENABLED: 0
GOOS: darwin
GOARCH: amd64
when:
event:
- tag
- name: deploy macOS
image: plugins/s3
settings:
endpoint: https://blob.nemunai.re
path_style: true
region: garage
bucket: happydomain-dl
access_key:
from_secret: s3_access_key
secret_key:
from_secret: s3_secret_key
source: happydomain-darwin-${DRONE_STAGE_ARCH}
target: /${DRONE_BRANCH//\//-}/
when:
event:
- push
branch:
exclude:
- renovate/*
- name: deploy macOS release
image: plugins/s3
settings:
endpoint: https://blob.nemunai.re
path_style: true
region: garage
bucket: happydomain-dl
access_key:
from_secret: s3_access_key
secret_key:
from_secret: s3_secret_key
source: happydomain-darwin-${DRONE_STAGE_ARCH}
source: deploy/*
target: /${DRONE_TAG}/
strip_prefix: deploy/
when:
event:
- tag
@ -210,11 +146,11 @@ steps:
from_secret: git_nemunaire_token
base_url: https://git.nemunai.re
draft: true
prerelease: true
files:
- happydomain-src.tar.gz
- happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
- happydomain-darwin-${DRONE_STAGE_ARCH}
- happydomain-sbom.spdx.json
when:
event:
- tag
@ -226,11 +162,11 @@ steps:
from_secret: codeberg_token
base_url: https://codeberg.org
draft: true
prerelease: true
files:
- happydomain-src.tar.gz
- happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
- happydomain-darwin-${DRONE_STAGE_ARCH}
- happydomain-sbom.spdx.json
when:
event:
- tag
@ -241,12 +177,12 @@ steps:
api_key:
from_secret: github_release_token
draft: true
prerelease: true
github_url: https://github.com
files:
- happydomain-src.tar.gz
- happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
- happydomain-darwin-${DRONE_STAGE_ARCH}
- happydomain-sbom.spdx.json
when:
event:
- tag
@ -294,8 +230,8 @@ steps:
image: golang:1-alpine
commands:
- apk add --no-cache git
- go build -tags netgo,swagger,web -ldflags '-w -X "main.Version=${DRONE_BRANCH}-${DRONE_COMMIT}" -X main.build=${DRONE_BUILD_NUMBER}' -o happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDomain/
- ln happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} happydomain
- go build -tags netgo,swagger,web -ldflags '-w -X "main.Version=${DRONE_BRANCH}-${DRONE_COMMIT}" -X main.build=${DRONE_BUILD_NUMBER}' -o deploy/happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDomain/
- ln deploy/happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} happydomain
environment:
CGO_ENABLED: 0
when:
@ -307,8 +243,8 @@ steps:
image: golang:1-alpine
commands:
- apk add --no-cache git
- go build -tags netgo,swagger,web -ldflags '-w -X main.Version=${DRONE_TAG##v} -X main.build=${DRONE_BUILD_NUMBER}' -o happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDomain/
- ln happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} happydomain
- go build -tags netgo,swagger,web -ldflags '-w -X main.Version=${DRONE_TAG##v} -X main.build=${DRONE_BUILD_NUMBER}' -o deploy/happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} ./cmd/happyDomain/
- ln deploy/happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH} happydomain
environment:
CGO_ENABLED: 0
when:
@ -324,6 +260,33 @@ steps:
environment:
CGO_ENABLED: 0
- name: build-commit macOS
image: golang:1-alpine
commands:
- apk add --no-cache git
- go build -tags netgo,swagger,web -ldflags '-w -X "main.Version=${DRONE_BRANCH}-${DRONE_COMMIT}" -X main.build=${DRONE_BUILD_NUMBER}' -o deploy/happydomain-darwin-${DRONE_STAGE_ARCH} ./cmd/happyDomain/
environment:
CGO_ENABLED: 0
GOOS: darwin
GOARCH: arm64
when:
event:
exclude:
- tag
- name: build-tag macOS
image: golang:1-alpine
commands:
- apk add --no-cache git
- go build -tags netgo,swagger,web -ldflags '-w -X "main.Version=${DRONE_TAG##v}" -X main.build=${DRONE_BUILD_NUMBER}' -o deploy/happydomain-darwin-${DRONE_STAGE_ARCH} ./cmd/happyDomain/
environment:
CGO_ENABLED: 0
GOOS: darwin
GOARCH: arm64
when:
event:
- tag
- name: deploy
image: plugins/s3
settings:
@ -335,8 +298,9 @@ steps:
from_secret: s3_access_key
secret_key:
from_secret: s3_secret_key
source: happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
source: deploy/*
target: /${DRONE_BRANCH//\//-}/
strip_prefix: deploy/
when:
event:
- push
@ -355,72 +319,9 @@ steps:
from_secret: s3_access_key
secret_key:
from_secret: s3_secret_key
source: happydomain-${DRONE_STAGE_OS}-${DRONE_STAGE_ARCH}
target: /${DRONE_TAG}/
when:
event:
- tag
- name: build-commit macOS
image: golang:1-alpine
commands:
- apk add --no-cache git
- go build -tags netgo,swagger,web -ldflags '-w -X "main.Version=${DRONE_BRANCH}-${DRONE_COMMIT}" -X main.build=${DRONE_BUILD_NUMBER}' -o happydomain-darwin-${DRONE_STAGE_ARCH} ./cmd/happyDomain/
environment:
CGO_ENABLED: 0
GOOS: darwin
GOARCH: arm64
when:
event:
exclude:
- tag
- name: build-tag macOS
image: golang:1-alpine
commands:
- apk add --no-cache git
- go build -tags netgo,swagger,web -ldflags '-w -X "main.Version=${DRONE_TAG##v}" -X main.build=${DRONE_BUILD_NUMBER}' -o happydomain-darwin-${DRONE_STAGE_ARCH} ./cmd/happyDomain/
environment:
CGO_ENABLED: 0
GOOS: darwin
GOARCH: arm64
when:
event:
- tag
- name: deploy macOS
image: plugins/s3
settings:
endpoint: https://blob.nemunai.re
path_style: true
region: garage
bucket: happydomain-dl
access_key:
from_secret: s3_access_key
secret_key:
from_secret: s3_secret_key
source: happydomain-darwin-${DRONE_STAGE_ARCH}
target: /${DRONE_BRANCH//\//-}/
when:
event:
- push
branch:
exclude:
- renovate/*
- name: deploy macOS release
image: plugins/s3
settings:
endpoint: https://blob.nemunai.re
path_style: true
region: garage
bucket: happydomain-dl
access_key:
from_secret: s3_access_key
secret_key:
from_secret: s3_secret_key
source: happydomain-darwin-${DRONE_STAGE_ARCH}
source: deploy/*
target: /${DRONE_TAG}/
strip_prefix: deploy/
when:
event:
- tag

64
SECURITY.md Normal file
View file

@ -0,0 +1,64 @@
# Security Policy
## Supported Versions
Only the latest version of happyDomain is supported with security fixes.
| Version | Supported |
| ------- | --------- |
| latest | ✓ |
| < latest| ✗ |
## Scope
### In scope
- happyDomain application code (API/backend and web frontend)
- Other websites directly operated by the happyDomain team: documentation, main website, blog, git redirection, downloads website, demo instance, insights
### Out of scope
- Vulnerabilities in third-party dependencies that are not directly exploitable in happyDomain
- Social engineering attacks
- Denial-of-service attacks requiring significant resources
## Reporting a Vulnerability
If you discover a security vulnerability in happyDomain, please report it privately.
By email: security@happydomain.org
On GitHub: https://github.com/happydomain/happydomain/security/advisories
On Gitlab: https://gitlab.com/happyDomain/happyDomain/-/issues/new (check Confidential issue before submitting)
On Framagit: https://framagit.org/happyDomain/happyDomain/-/issues/new (check Confidential issue before submitting)
Please include:
- description of the vulnerability
- steps to reproduce
- potential impact
## Disclosure policy
We follow a responsible disclosure process.
After receiving a report we will:
1. acknowledge within 72 hours
2. investigate the issue
3. prepare a fix
4. publish a security advisory when the fix is available
## Safe Harbor
We consider security research conducted in good faith to be authorized. We will not pursue legal action against researchers who:
- Report vulnerabilities through the channels listed above
- Avoid accessing, modifying, or deleting data that doesn't belong to them
- Avoid degrading the availability of our services
- Do not publicly disclose the vulnerability before a fix is available
## Credits
We are happy to credit security researchers who responsibly disclose vulnerabilities.

View file

@ -35,6 +35,9 @@
"vite": "^7.0.0",
"vitest": "^4.0.0"
},
"resolutions": {
"vite": "^7.0.0"
},
"type": "module",
"dependencies": {
"@hey-api/openapi-ts": "^0.90.6",

View file

@ -25,7 +25,7 @@
import { createEventDispatcher } from "svelte";
import HListGroup from "$lib/components/ListGroup.svelte";
import { groups } from "$lib/stores/domains";
import { groups, newlyGroups } from "$lib/stores/domains";
import { t } from "$lib/translations";
const dispatch = createEventDispatcher();
@ -49,7 +49,7 @@
<HListGroup
button
items={$groups}
items={[...$groups, ...$newlyGroups.filter((g) => !$groups.includes(g))]}
{flush}
isActive={(item) => selectedGroup != null && item === selectedGroup}
on:click={selectGroup}

View file

@ -44,7 +44,7 @@
import ZoneList from "$lib/components/zones/ZoneList.svelte";
import { updateDomain } from "$lib/api/domains";
import type { Domain } from "$lib/model/domain";
import { groups, domains, refreshDomains } from "$lib/stores/domains";
import { groups, domains, newlyGroups, refreshDomains } from "$lib/stores/domains";
import { t } from "$lib/translations";
interface Props {
@ -66,6 +66,7 @@
if (newgroup.length && mygroups.indexOf(newgroup) < 0) {
mygroups.push(newgroup);
mygroups = mygroups;
newlyGroups.update((gs) => gs.includes(newgroup) ? gs : [...gs, newgroup]);
}
newgroup = "";
}

View file

@ -0,0 +1,95 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-2026 happyDomain
Authors: Pierre-Olivier Mercier, et al.
This program is offered under a commercial and under the AGPL license.
For commercial licensing, contact us at <contact@happydomain.org>.
For AGPL licensing:
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 <https://www.gnu.org/licenses/>.
-->
<script module lang="ts">
import type { ModalController } from "$lib/model/modal_controller";
export const controls: ModalController = {
Open(): void {},
};
</script>
<script lang="ts">
import {
Button,
Input,
InputGroup,
Modal,
ModalBody,
ModalHeader,
} from "@sveltestrap/sveltestrap";
import { newlyGroups } from "$lib/stores/domains";
import { t } from "$lib/translations";
interface Props {
isOpen?: boolean;
}
let { isOpen = $bindable(false) }: Props = $props();
const toggle = () => (isOpen = !isOpen);
let newgroup = $state("");
let inputEl: HTMLInputElement | undefined = $state();
function focusInput() {
inputEl?.focus();
}
function addGroup(e: SubmitEvent) {
e.preventDefault();
if (newgroup.length) {
newlyGroups.update((gs) => (gs.includes(newgroup) ? gs : [...gs, newgroup]));
}
newgroup = "";
isOpen = false;
}
function Open(): void {
newgroup = "";
isOpen = true;
}
controls.Open = Open;
</script>
<Modal {isOpen} {toggle} on:open={focusInput}>
<ModalHeader {toggle}>
{$t("domaingroups.new")}
</ModalHeader>
<ModalBody>
<form onsubmit={addGroup}>
<InputGroup>
<Input
placeholder={$t("domaingroups.new")}
required
bind:value={newgroup}
bind:inner={inputEl}
/>
<Button type="submit" color="primary" disabled={newgroup.length < 1}>
{$t("common.add")}
</Button>
</InputGroup>
</form>
</ModalBody>
</Modal>

View file

@ -22,18 +22,14 @@
-->
<script lang="ts">
import {
Button,
Card,
Icon,
} from "@sveltestrap/sveltestrap";
import { Button, Card, Icon } from "@sveltestrap/sveltestrap";
import DomainGroupList from "$lib/components/forms/DomainGroupList.svelte";
import DomainGroupModal, { controls as ctrlDomainGroup } from "$lib/components/modals/DomainGroup.svelte";
import NewDomainGroupModal, { controls as ctrlNewDomainGroup } from "$lib/components/modals/NewDomainGroup.svelte";
import { domains } from "$lib/stores/domains";
import { t } from "$lib/translations";
interface Props {
class?: string;
filteredGroup?: string | null;
@ -46,18 +42,32 @@
<Card class="mb-3 ${className}">
<div class="card-header d-flex justify-content-between align-items-center">
{$t("domaingroups.title")}
<Button
type="button"
size="sm"
color="light"
title={$t("domaingroups.manage")}
on:click={() => ctrlDomainGroup.Open()}
>
<Icon name="grid-fill" />
</Button>
<div>
<Button
type="button"
size="sm"
color="light"
class="d-none d-lg-inline-block"
title={$t("domaingroups.new")}
on:click={() => ctrlNewDomainGroup.Open()}
>
<Icon name="plus-lg" />
</Button>
<Button
type="button"
size="sm"
color="light"
class="d-lg-none"
title={$t("domaingroups.manage")}
on:click={() => ctrlDomainGroup.Open()}
>
<Icon name="grid-fill" />
</Button>
</div>
</div>
<DomainGroupList flush bind:selectedGroup={filteredGroup} />
</Card>
{/if}
<NewDomainGroupModal />
<DomainGroupModal />

View file

@ -58,7 +58,7 @@
<FilterDomainInput class="mb-3" />
{#if filteredDomains.length}
<ZoneList button display_by_groups domains={filteredDomains} links />
<ZoneList button display_by_groups domains={filteredDomains} links show_empty_groups />
{:else}
<div class="my-4 text-center text-muted">
{$t("domains.filtered-no-result")}

View file

@ -27,7 +27,8 @@
import { Badge } from "@sveltestrap/sveltestrap";
import { ListGroup } from "@sveltestrap/sveltestrap";
import DomainWithProvider from "$lib/components/domains/DomainWithProvider.svelte";
import { domains_idx } from "$lib/stores/domains";
import { updateDomain } from "$lib/api/domains";
import { domains_idx, newlyGroups, refreshDomains } from "$lib/stores/domains";
import { t } from "$lib/translations";
const dispatch = createEventDispatcher();
@ -44,38 +45,87 @@
flush?: boolean;
links?: boolean;
display_by_groups?: boolean;
show_empty_groups?: boolean;
domains?: Array<ZoneListDomain>;
no_domain?: import('svelte').Snippet;
badges?: import('svelte').Snippet<[any]>;
[key: string]: any
no_domain?: import("svelte").Snippet;
badges?: import("svelte").Snippet<[any]>;
[key: string]: any;
}
let {
flush = false,
links = false,
display_by_groups = false,
show_empty_groups = false,
domains = [],
no_domain,
badges,
...rest
}: Props = $props();
function genGroups(domains: Array<ZoneListDomain>, display_by_groups: boolean) {
function genGroups(domains: Array<ZoneListDomain>, display_by_groups: boolean, show_empty_groups: boolean, extraGroups: Array<string>) {
if (!display_by_groups) {
return { "": domains };
}
const groups: Record<string, Array<ZoneListDomain>> = { };
const groups: Record<string, Array<ZoneListDomain>> = {};
for (const domain of domains) {
const group = domain.group ?? '';
const group = domain.group ?? "";
(groups[group] ??= []).push(domain);
}
if (show_empty_groups) {
for (const g of extraGroups) {
if (!(g in groups)) {
groups[g] = [];
}
}
}
return groups;
}
let groups: Record<string, Array<ZoneListDomain>> = $derived(genGroups(domains, display_by_groups));
let localDomains: Array<ZoneListDomain> = $state([...domains]);
$effect(() => {
localDomains = [...domains];
});
let groups: Record<string, Array<ZoneListDomain>> = $derived(
genGroups(localDomains, display_by_groups, show_empty_groups, $newlyGroups),
);
let draggedDomain: ZoneListDomain | null = $state(null);
let dragOverGroup: string | null = $state(null);
async function handleDrop(targetGroup: string) {
if (!draggedDomain || (draggedDomain.group ?? "") === targetGroup) return;
const fullDomain = $domains_idx[draggedDomain.domain] ?? $domains_idx[draggedDomain.id];
if (!fullDomain) return;
const prevGroup = draggedDomain.group;
const domainId = draggedDomain.domain || draggedDomain.id;
// Optimistic update
localDomains = localDomains.map((d) =>
d.domain === domainId || d.id === domainId ? { ...d, group: targetGroup } : d,
);
draggedDomain = null;
dragOverGroup = null;
fullDomain.group = targetGroup;
try {
await updateDomain(fullDomain);
refreshDomains();
} catch {
// Revert on error
fullDomain.group = prevGroup;
localDomains = localDomains.map((d) =>
d.domain === domainId || d.id === domainId ? { ...d, group: prevGroup } : d,
);
}
}
function getDomainHref(domain: ZoneListDomain): string | undefined {
if (links && !domain.href) {
@ -99,35 +149,76 @@
{#if domains.length === 0}
{@render no_domain?.()}
{:else}
{#each Object.keys(groups).sort((a,b) => !a || !b ? (!a ? 1 : -1) : a.toLowerCase().localeCompare(b.toLowerCase())) as gname}
{#each Object.keys(groups).sort((a, b) => {
const aEmpty = groups[a].length === 0;
const bEmpty = groups[b].length === 0;
if (aEmpty !== bEmpty) return aEmpty ? 1 : -1;
if (!a || !b) return !a ? 1 : -1;
return a.toLowerCase().localeCompare(b.toLowerCase());
}) as gname}
{@const gdomains = groups[gname]}
<div
class:mb-2={Object.keys(groups).length != 1}
class:drag-over={display_by_groups && dragOverGroup === gname}
ondragover={display_by_groups
? (e) => {
e.preventDefault();
dragOverGroup = gname;
}
: undefined}
ondragleave={display_by_groups
? (e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node))
dragOverGroup = null;
}
: undefined}
ondrop={display_by_groups
? (e) => {
e.preventDefault();
handleDrop(gname);
}
: undefined}
>
{#if Object.keys(groups).length != 1}
<div class="d-flex align-items-center">
<hr class="flex-fill">
<h3
class="px-2"
>
<hr class="flex-fill" />
<h3 class="px-2">
{#if gname === ""}
{$t("domaingroups.no-group")}
{:else}
{gname}
{/if}
</h3>
<hr class="flex-fill">
<hr class="flex-fill" />
</div>
{/if}
<ListGroup
{flush}
>
<ListGroup {flush}>
{#if display_by_groups && gdomains.length === 0}
<div
class="list-group-item text-center text-muted py-3 empty-group-placeholder"
>
{$t("domaingroups.drop-here")}
</div>
{/if}
{#each gdomains as item}
{@const href = getDomainHref(item)}
{#if href}
<a
class="list-group-item list-group-item-action d-flex justify-content-between align-items-center text-dark"
class:draggable-item={display_by_groups}
{href}
draggable={display_by_groups || undefined}
ondragstart={display_by_groups
? () => {
draggedDomain = item;
}
: undefined}
ondragend={display_by_groups
? () => {
draggedDomain = null;
dragOverGroup = null;
}
: undefined}
onclick={() => dispatch("click", item)}
>
{@render domainRow(item)}
@ -135,7 +226,20 @@
{:else}
<button
class="list-group-item list-group-item-action d-flex justify-content-between align-items-center text-dark"
class:draggable-item={display_by_groups}
type="button"
draggable={display_by_groups || undefined}
ondragstart={display_by_groups
? () => {
draggedDomain = item;
}
: undefined}
ondragend={display_by_groups
? () => {
draggedDomain = null;
dragOverGroup = null;
}
: undefined}
onclick={() => dispatch("click", item)}
>
{@render domainRow(item)}
@ -147,3 +251,18 @@
{/each}
{/if}
</div>
<style>
.draggable-item {
cursor: grab;
}
.drag-over {
background-color: var(--bs-primary-bg-subtle, #cfe2ff);
border-radius: 0.25rem;
outline: 2px dashed var(--bs-primary, #0d6efd);
}
.empty-group-placeholder {
min-height: 3rem;
border-style: dashed;
}
</style>

View file

@ -156,6 +156,7 @@
"all": "All groups",
"manage": "Manage your domain groups",
"new": "New group",
"drop-here": "Drop a domain here",
"no-group": "Miscellaneous",
"title": "Your groups"
},

View file

@ -485,6 +485,7 @@
"all": "Tous les groupes",
"manage": "Vos groupes de domaines",
"new": "Nouveau groupe",
"drop-here": "Déposez un domaine ici",
"no-group": "Divers",
"title": "Vos groupes"
},

View file

@ -24,6 +24,7 @@ import { listDomains } from "$lib/api/domains";
import type { Domain } from "$lib/model/domain";
export const domains: Writable<Array<Domain> | undefined> = writable(undefined);
export const newlyGroups: Writable<Array<string>> = writable([]);
export async function refreshDomains() {
const data = await listDomains();