feat: add admin components for input, metrics, tables, and user forms
- Introduced AdminInput component for standardized input fields. - Created AdminMetricCard for displaying metrics with customizable tones. - Added AdminPlaceholderTable for loading states in tables. - Developed AdminSectionCard for consistent section layouts. - Implemented AdminSectionShell for organizing admin sections. - Added AdminSelect for dropdown selections with v-model support. - Created AdminTable for displaying tabular data with loading and empty states. - Introduced AdminTextarea for multi-line text input. - Developed AdminUserFormFields for user creation and editing forms. - Added useAdminPageHeader composable for managing admin page header state.
This commit is contained in:
@@ -1,369 +1,218 @@
|
||||
<script setup lang="ts">
|
||||
import { client as rpcClient } from '@/api/rpcclient';
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import AppDialog from '@/components/ui/AppDialog.vue';
|
||||
import AppInput from '@/components/ui/AppInput.vue';
|
||||
import BaseTable from '@/components/ui/BaseTable.vue';
|
||||
import CheckIcon from '@/components/icons/CheckIcon.vue';
|
||||
import LinkIcon from '@/components/icons/LinkIcon.vue';
|
||||
import PlusIcon from '@/components/icons/PlusIcon.vue';
|
||||
import TrashIcon from '@/components/icons/TrashIcon.vue';
|
||||
import { useAppConfirm } from '@/composables/useAppConfirm';
|
||||
import { useAppToast } from '@/composables/useAppToast';
|
||||
import SettingsNotice from '@/routes/settings/components/SettingsNotice.vue';
|
||||
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
|
||||
import SettingsTableSkeleton from '@/routes/settings/components/SettingsTableSkeleton.vue';
|
||||
import { useQuery } from '@pinia/colada';
|
||||
import type { ColumnDef } from '@tanstack/vue-table';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import DomainsDnsDialog from './components/DomainsDnsDialog.vue';
|
||||
import DomainsDnsEmbedCode from './components/DomainsDnsEmbedCode.vue';
|
||||
import DomainsDnsNotices from './components/DomainsDnsNotices.vue';
|
||||
import DomainsDnsTable from './components/DomainsDnsTable.vue';
|
||||
import DomainsDnsToolbar from './components/DomainsDnsToolbar.vue';
|
||||
import { mapDomainItem, normalizeDomainInput } from './helpers';
|
||||
import type { DomainItem } from './types';
|
||||
|
||||
const toast = useAppToast();
|
||||
const confirm = useAppConfirm();
|
||||
const { t } = useTranslation();
|
||||
|
||||
type DomainApiItem = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
created_at?: string;
|
||||
};
|
||||
|
||||
type DomainItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
addedAt: string;
|
||||
};
|
||||
|
||||
const newDomain = ref('');
|
||||
const showAddDialog = ref(false);
|
||||
const adding = ref(false);
|
||||
const removingId = ref<string | null>(null);
|
||||
|
||||
const normalizeDomainInput = (value: string) => value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^https?:\/\//, '')
|
||||
.replace(/^www\./, '')
|
||||
.replace(/\/$/, '');
|
||||
|
||||
const formatDate = (value?: string) => {
|
||||
if (!value) return '-';
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value.split('T')[0] || value;
|
||||
}
|
||||
|
||||
return date.toISOString().split('T')[0];
|
||||
};
|
||||
|
||||
const mapDomainItem = (item: DomainApiItem): DomainItem => ({
|
||||
id: item.id || `${item.name || 'domain'}:${item.created_at || ''}`,
|
||||
name: item.name || '',
|
||||
addedAt: formatDate(item.created_at),
|
||||
});
|
||||
|
||||
const { data: domainsSnapshot, error, isPending, refetch } = useQuery({
|
||||
key: () => ['settings', 'domains'],
|
||||
query: async () => {
|
||||
const response = await rpcClient.listDomains();
|
||||
return (response.domains || []).map(mapDomainItem);
|
||||
},
|
||||
key: () => ['settings', 'domains'],
|
||||
query: async () => {
|
||||
const response = await rpcClient.listDomains();
|
||||
return (response.domains || []).map(mapDomainItem);
|
||||
},
|
||||
});
|
||||
|
||||
const domains = computed(() => domainsSnapshot.value || []);
|
||||
const isInitialLoading = computed(() => isPending.value && !domainsSnapshot.value);
|
||||
|
||||
const iframeCode = computed(() => '<iframe src="https://holistream.com/embed" width="100%" height="500" frameborder="0" allowfullscreen></iframe>');
|
||||
|
||||
const refetchDomains = () => refetch((fetchError) => {
|
||||
throw fetchError;
|
||||
});
|
||||
|
||||
watch(error, (value, previous) => {
|
||||
if (!value || value === previous || adding.value || removingId.value !== null) return;
|
||||
if (!value || value === previous || adding.value || removingId.value !== null) return;
|
||||
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('settings.domainsDns.toast.failedSummary'),
|
||||
detail: (value as any)?.message || t('settings.domainsDns.toast.failedDetail'),
|
||||
life: 5000,
|
||||
});
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('settings.domainsDns.toast.failedSummary'),
|
||||
detail: (value as any)?.message || t('settings.domainsDns.toast.failedDetail'),
|
||||
life: 5000,
|
||||
});
|
||||
});
|
||||
|
||||
const openAddDialog = () => {
|
||||
newDomain.value = '';
|
||||
showAddDialog.value = true;
|
||||
newDomain.value = '';
|
||||
showAddDialog.value = true;
|
||||
};
|
||||
|
||||
const closeAddDialog = () => {
|
||||
showAddDialog.value = false;
|
||||
newDomain.value = '';
|
||||
showAddDialog.value = false;
|
||||
newDomain.value = '';
|
||||
};
|
||||
|
||||
const handleAddDomain = async () => {
|
||||
if (adding.value) return;
|
||||
if (adding.value) return;
|
||||
|
||||
const domainName = normalizeDomainInput(newDomain.value);
|
||||
if (!domainName || !domainName.includes('.') || /[\/\s]/.test(domainName)) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('settings.domainsDns.toast.invalidSummary'),
|
||||
detail: t('settings.domainsDns.toast.invalidDetail'),
|
||||
life: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const exists = domains.value.some(domain => domain.name === domainName);
|
||||
if (exists) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('settings.domainsDns.toast.duplicateSummary'),
|
||||
detail: t('settings.domainsDns.toast.duplicateDetail'),
|
||||
life: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
adding.value = true;
|
||||
try {
|
||||
await rpcClient.createDomain({
|
||||
name: domainName,
|
||||
});
|
||||
|
||||
await refetchDomains();
|
||||
closeAddDialog();
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('settings.domainsDns.toast.addedSummary'),
|
||||
detail: t('settings.domainsDns.toast.addedDetail', { domain: domainName }),
|
||||
life: 3000,
|
||||
});
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
const message = String(e?.message || '').toLowerCase();
|
||||
|
||||
if (message.includes('already exists')) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('settings.domainsDns.toast.duplicateSummary'),
|
||||
detail: t('settings.domainsDns.toast.duplicateDetail'),
|
||||
life: 3000,
|
||||
});
|
||||
} else if (message.includes('invalid domain')) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('settings.domainsDns.toast.invalidSummary'),
|
||||
detail: t('settings.domainsDns.toast.invalidDetail'),
|
||||
life: 3000,
|
||||
});
|
||||
} else {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('settings.domainsDns.toast.failedSummary'),
|
||||
detail: e.message || t('settings.domainsDns.toast.failedDetail'),
|
||||
life: 5000,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
adding.value = false;
|
||||
const domainName = normalizeDomainInput(newDomain.value);
|
||||
if (!domainName || !domainName.includes('.') || /[/\s]/.test(domainName)) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('settings.domainsDns.toast.invalidSummary'),
|
||||
detail: t('settings.domainsDns.toast.invalidDetail'),
|
||||
life: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const exists = domains.value.some(domain => domain.name === domainName);
|
||||
if (exists) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('settings.domainsDns.toast.duplicateSummary'),
|
||||
detail: t('settings.domainsDns.toast.duplicateDetail'),
|
||||
life: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
adding.value = true;
|
||||
try {
|
||||
await rpcClient.createDomain({
|
||||
name: domainName,
|
||||
});
|
||||
|
||||
await refetch();
|
||||
closeAddDialog();
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('settings.domainsDns.toast.addedSummary'),
|
||||
detail: t('settings.domainsDns.toast.addedDetail', { domain: domainName }),
|
||||
life: 3000,
|
||||
});
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
const message = String(e?.message || '').toLowerCase();
|
||||
|
||||
if (message.includes('already exists')) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('settings.domainsDns.toast.duplicateSummary'),
|
||||
detail: t('settings.domainsDns.toast.duplicateDetail'),
|
||||
life: 3000,
|
||||
});
|
||||
} else if (message.includes('invalid domain')) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('settings.domainsDns.toast.invalidSummary'),
|
||||
detail: t('settings.domainsDns.toast.invalidDetail'),
|
||||
life: 3000,
|
||||
});
|
||||
} else {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('settings.domainsDns.toast.failedSummary'),
|
||||
detail: e.message || t('settings.domainsDns.toast.failedDetail'),
|
||||
life: 5000,
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
adding.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveDomain = (domain: DomainItem) => {
|
||||
confirm.require({
|
||||
message: t('settings.domainsDns.confirm.removeMessage', { domain: domain.name }),
|
||||
header: t('settings.domainsDns.confirm.removeHeader'),
|
||||
acceptLabel: t('settings.domainsDns.confirm.removeAccept'),
|
||||
rejectLabel: t('settings.domainsDns.confirm.removeReject'),
|
||||
accept: async () => {
|
||||
removingId.value = domain.id;
|
||||
try {
|
||||
await rpcClient.deleteDomain({ id: domain.id });
|
||||
await refetchDomains();
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
summary: t('settings.domainsDns.toast.removedSummary'),
|
||||
detail: t('settings.domainsDns.toast.removedDetail', { domain: domain.name }),
|
||||
life: 3000,
|
||||
});
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('settings.domainsDns.toast.failedSummary'),
|
||||
detail: e.message || t('settings.domainsDns.toast.failedDetail'),
|
||||
life: 5000,
|
||||
});
|
||||
} finally {
|
||||
removingId.value = null;
|
||||
}
|
||||
},
|
||||
});
|
||||
confirm.require({
|
||||
message: t('settings.domainsDns.confirm.removeMessage', { domain: domain.name }),
|
||||
header: t('settings.domainsDns.confirm.removeHeader'),
|
||||
acceptLabel: t('settings.domainsDns.confirm.removeAccept'),
|
||||
rejectLabel: t('settings.domainsDns.confirm.removeReject'),
|
||||
accept: async () => {
|
||||
removingId.value = domain.id;
|
||||
try {
|
||||
await rpcClient.deleteDomain({ id: domain.id });
|
||||
await refetch();
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
summary: t('settings.domainsDns.toast.removedSummary'),
|
||||
detail: t('settings.domainsDns.toast.removedDetail', { domain: domain.name }),
|
||||
life: 3000,
|
||||
});
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('settings.domainsDns.toast.failedSummary'),
|
||||
detail: e.message || t('settings.domainsDns.toast.failedDetail'),
|
||||
life: 5000,
|
||||
});
|
||||
} finally {
|
||||
removingId.value = null;
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const copyIframeCode = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(iframeCode.value);
|
||||
} catch {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = iframeCode.value;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(iframeCode.value);
|
||||
} catch {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = iframeCode.value;
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('settings.domainsDns.toast.copiedSummary'),
|
||||
detail: t('settings.domainsDns.toast.copiedDetail'),
|
||||
life: 2000,
|
||||
});
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('settings.domainsDns.toast.copiedSummary'),
|
||||
detail: t('settings.domainsDns.toast.copiedDetail'),
|
||||
life: 2000,
|
||||
});
|
||||
};
|
||||
|
||||
const columns = computed<ColumnDef<DomainItem>[]>(() => [
|
||||
{
|
||||
id: 'domain',
|
||||
header: t('settings.domainsDns.table.domain'),
|
||||
accessorFn: row => row.name,
|
||||
cell: ({ row }) => h('div', { class: 'flex items-center gap-2' }, [
|
||||
h(LinkIcon, { class: 'h-4 w-4 text-foreground/40' }),
|
||||
h('span', { class: 'text-sm font-medium text-foreground' }, row.original.name),
|
||||
]),
|
||||
meta: {
|
||||
headerClass: 'px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50',
|
||||
cellClass: 'px-6 py-3',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'addedAt',
|
||||
header: t('settings.domainsDns.table.addedDate'),
|
||||
accessorFn: row => row.addedAt,
|
||||
cell: ({ row }) => h('span', { class: 'text-sm text-foreground/60' }, row.original.addedAt),
|
||||
meta: {
|
||||
headerClass: 'px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50',
|
||||
cellClass: 'px-6 py-3',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('common.actions'),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => h(AppButton, {
|
||||
variant: 'ghost',
|
||||
size: 'sm',
|
||||
disabled: adding.value || removingId.value !== null,
|
||||
onClick: () => handleRemoveDomain(row.original),
|
||||
}, {
|
||||
icon: () => h(TrashIcon, { class: 'h-4 w-4 text-danger' }),
|
||||
}),
|
||||
meta: {
|
||||
headerClass: 'px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-foreground/50',
|
||||
cellClass: 'px-6 py-3 text-right',
|
||||
},
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsSectionCard
|
||||
:title="t('settings.content.domains.title')"
|
||||
:description="t('settings.content.domains.subtitle')"
|
||||
bodyClass=""
|
||||
>
|
||||
<template #header-actions>
|
||||
<AppButton size="sm" :loading="adding" :disabled="isInitialLoading || removingId !== null" @click="openAddDialog">
|
||||
<template #icon>
|
||||
<PlusIcon class="w-4 h-4" />
|
||||
</template>
|
||||
{{ t('settings.domainsDns.addDomain') }}
|
||||
</AppButton>
|
||||
</template>
|
||||
<SettingsSectionCard
|
||||
:title="t('settings.content.domains.title')"
|
||||
:description="t('settings.content.domains.subtitle')"
|
||||
bodyClass=""
|
||||
>
|
||||
<template #header-actions>
|
||||
<DomainsDnsToolbar
|
||||
:loading="adding"
|
||||
:disabled="isInitialLoading || removingId !== null"
|
||||
@create="openAddDialog"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<SettingsNotice class="rounded-none border-x-0 border-t-0 p-3" contentClass="text-xs text-foreground/70">
|
||||
{{ t('settings.domainsDns.infoBanner') }}
|
||||
</SettingsNotice>
|
||||
<DomainsDnsNotices />
|
||||
|
||||
<SettingsTableSkeleton v-if="isInitialLoading" :columns="3" :rows="4" />
|
||||
<DomainsDnsTable
|
||||
:domains="domains"
|
||||
:is-initial-loading="isInitialLoading"
|
||||
:adding="adding"
|
||||
:removing-id="removingId"
|
||||
@remove="handleRemoveDomain"
|
||||
/>
|
||||
|
||||
<BaseTable
|
||||
v-else
|
||||
:data="domains"
|
||||
:columns="columns"
|
||||
:get-row-id="(row) => row.id"
|
||||
wrapperClass="mt-4 border-b border-border rounded-none border-x-0 border-t-0 bg-transparent"
|
||||
tableClass="w-full"
|
||||
headerRowClass="bg-muted/30"
|
||||
bodyRowClass="border-b border-border hover:bg-muted/30"
|
||||
>
|
||||
<template #empty>
|
||||
<div class="px-6 py-12 text-center">
|
||||
<LinkIcon class="mx-auto mb-3 block h-10 w-10 text-foreground/30" />
|
||||
<p class="mb-1 text-sm text-foreground/60">{{ t('settings.domainsDns.emptyTitle') }}</p>
|
||||
<p class="text-xs text-foreground/40">{{ t('settings.domainsDns.emptySubtitle') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</BaseTable>
|
||||
<DomainsDnsEmbedCode :code="iframeCode" @copy="copyIframeCode" />
|
||||
|
||||
<div class="px-6 py-4 bg-muted/30">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h4 class="text-sm font-medium text-foreground">{{ t('settings.domainsDns.embedCodeTitle') }}</h4>
|
||||
<AppButton variant="secondary" size="sm" @click="copyIframeCode">
|
||||
<template #icon>
|
||||
<CheckIcon class="w-4 h-4" />
|
||||
</template>
|
||||
{{ t('settings.domainsDns.copyCode') }}
|
||||
</AppButton>
|
||||
</div>
|
||||
<p class="text-xs text-foreground/60 mb-2">
|
||||
{{ t('settings.domainsDns.embedCodeHint') }}
|
||||
</p>
|
||||
<pre class="bg-header border border-border rounded-md p-3 text-xs text-foreground/70 overflow-x-auto"><code>{{ iframeCode }}</code></pre>
|
||||
</div>
|
||||
|
||||
<AppDialog
|
||||
:visible="showAddDialog"
|
||||
:title="t('settings.domainsDns.dialog.title')"
|
||||
maxWidthClass="max-w-md"
|
||||
@update:visible="showAddDialog = $event"
|
||||
@close="closeAddDialog"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div class="grid gap-2">
|
||||
<label for="domain" class="text-sm font-medium text-foreground">{{ t('settings.domainsDns.dialog.domainLabel') }}</label>
|
||||
<AppInput
|
||||
id="domain"
|
||||
v-model="newDomain"
|
||||
:placeholder="t('settings.domainsDns.dialog.domainPlaceholder')"
|
||||
@enter="handleAddDomain"
|
||||
/>
|
||||
<p class="text-xs text-foreground/50">{{ t('settings.domainsDns.dialog.domainHint') }}</p>
|
||||
</div>
|
||||
|
||||
<SettingsNotice
|
||||
tone="warning"
|
||||
:title="t('settings.domainsDns.dialog.importantTitle')"
|
||||
class="p-3"
|
||||
>
|
||||
<p>{{ t('settings.domainsDns.dialog.importantDetail') }}</p>
|
||||
</SettingsNotice>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" :disabled="adding" @click="closeAddDialog">
|
||||
{{ t('common.cancel') }}
|
||||
</AppButton>
|
||||
<AppButton size="sm" :loading="adding" @click="handleAddDomain">
|
||||
<template #icon>
|
||||
<CheckIcon class="w-4 h-4" />
|
||||
</template>
|
||||
{{ t('settings.domainsDns.addDomain') }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
</SettingsSectionCard>
|
||||
<DomainsDnsDialog
|
||||
:visible="showAddDialog"
|
||||
:domain="newDomain"
|
||||
:adding="adding"
|
||||
@update:visible="showAddDialog = $event"
|
||||
@update:domain="newDomain = $event"
|
||||
@submit="handleAddDomain"
|
||||
@close="closeAddDialog"
|
||||
/>
|
||||
</SettingsSectionCard>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import CheckIcon from '@/components/icons/CheckIcon.vue';
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import AppDialog from '@/components/ui/AppDialog.vue';
|
||||
import AppInput from '@/components/ui/AppInput.vue';
|
||||
import SettingsNotice from '@/routes/settings/components/SettingsNotice.vue';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
domain: string;
|
||||
adding: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', value: boolean): void;
|
||||
(e: 'update:domain', value: string): void;
|
||||
(e: 'submit'): void;
|
||||
(e: 'close'): void;
|
||||
}>();
|
||||
|
||||
const { t } = useTranslation();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog
|
||||
:visible="visible"
|
||||
:title="t('settings.domainsDns.dialog.title')"
|
||||
maxWidthClass="max-w-md"
|
||||
@update:visible="emit('update:visible', $event)"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div class="grid gap-2">
|
||||
<label for="domain" class="text-sm font-medium text-foreground">{{ t('settings.domainsDns.dialog.domainLabel') }}</label>
|
||||
<AppInput
|
||||
id="domain"
|
||||
:model-value="domain"
|
||||
:placeholder="t('settings.domainsDns.dialog.domainPlaceholder')"
|
||||
@update:model-value="emit('update:domain', String($event ?? ''))"
|
||||
@enter="emit('submit')"
|
||||
/>
|
||||
<p class="text-xs text-foreground/50">{{ t('settings.domainsDns.dialog.domainHint') }}</p>
|
||||
</div>
|
||||
|
||||
<SettingsNotice
|
||||
tone="warning"
|
||||
:title="t('settings.domainsDns.dialog.importantTitle')"
|
||||
class="p-3"
|
||||
>
|
||||
<p>{{ t('settings.domainsDns.dialog.importantDetail') }}</p>
|
||||
</SettingsNotice>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" :disabled="adding" @click="emit('close')">
|
||||
{{ t('common.cancel') }}
|
||||
</AppButton>
|
||||
<AppButton size="sm" :loading="adding" @click="emit('submit')">
|
||||
<template #icon>
|
||||
<CheckIcon class="w-4 h-4" />
|
||||
</template>
|
||||
{{ t('settings.domainsDns.addDomain') }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import CheckIcon from '@/components/icons/CheckIcon.vue';
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
|
||||
defineProps<{
|
||||
code: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'copy'): void;
|
||||
}>();
|
||||
|
||||
const { t } = useTranslation();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-6 py-4 bg-muted/30">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h4 class="text-sm font-medium text-foreground">{{ t('settings.domainsDns.embedCodeTitle') }}</h4>
|
||||
<AppButton variant="secondary" size="sm" @click="emit('copy')">
|
||||
<template #icon>
|
||||
<CheckIcon class="w-4 h-4" />
|
||||
</template>
|
||||
{{ t('settings.domainsDns.copyCode') }}
|
||||
</AppButton>
|
||||
</div>
|
||||
|
||||
<p class="mb-2 text-xs text-foreground/60">
|
||||
{{ t('settings.domainsDns.embedCodeHint') }}
|
||||
</p>
|
||||
|
||||
<pre class="overflow-x-auto rounded-md border border-border bg-header p-3 text-xs text-foreground/70"><code>{{ code }}</code></pre>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import SettingsNotice from '@/routes/settings/components/SettingsNotice.vue';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
|
||||
const { t } = useTranslation();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsNotice class="rounded-none border-x-0 border-t-0 p-3" contentClass="text-xs text-foreground/70">
|
||||
{{ t('settings.domainsDns.infoBanner') }}
|
||||
</SettingsNotice>
|
||||
</template>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
import LinkIcon from '@/components/icons/LinkIcon.vue';
|
||||
import TrashIcon from '@/components/icons/TrashIcon.vue';
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import BaseTable from '@/components/ui/BaseTable.vue';
|
||||
import SettingsTableSkeleton from '@/routes/settings/components/SettingsTableSkeleton.vue';
|
||||
import type { ColumnDef } from '@tanstack/vue-table';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
import { computed, h } from 'vue';
|
||||
import type { DomainItem } from '../types';
|
||||
|
||||
const props = defineProps<{
|
||||
domains: DomainItem[];
|
||||
isInitialLoading: boolean;
|
||||
adding: boolean;
|
||||
removingId: string | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'remove', domain: DomainItem): void;
|
||||
}>();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const columns = computed<ColumnDef<DomainItem>[]>(() => [
|
||||
{
|
||||
id: 'domain',
|
||||
header: t('settings.domainsDns.table.domain'),
|
||||
accessorFn: row => row.name,
|
||||
cell: ({ row }) => h('div', { class: 'flex items-center gap-2' }, [
|
||||
h(LinkIcon, { class: 'h-4 w-4 text-foreground/40' }),
|
||||
h('span', { class: 'text-sm font-medium text-foreground' }, row.original.name),
|
||||
]),
|
||||
meta: {
|
||||
headerClass: 'px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50',
|
||||
cellClass: 'px-6 py-3',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'addedAt',
|
||||
header: t('settings.domainsDns.table.addedDate'),
|
||||
accessorFn: row => row.addedAt,
|
||||
cell: ({ row }) => h('span', { class: 'text-sm text-foreground/60' }, row.original.addedAt),
|
||||
meta: {
|
||||
headerClass: 'px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50',
|
||||
cellClass: 'px-6 py-3',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('common.actions'),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => h(AppButton, {
|
||||
variant: 'ghost',
|
||||
size: 'sm',
|
||||
disabled: props.adding || props.removingId !== null,
|
||||
onClick: () => emit('remove', row.original),
|
||||
}, {
|
||||
icon: () => h(TrashIcon, { class: 'h-4 w-4 text-danger' }),
|
||||
}),
|
||||
meta: {
|
||||
headerClass: 'px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-foreground/50',
|
||||
cellClass: 'px-6 py-3 text-right',
|
||||
},
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsTableSkeleton v-if="isInitialLoading" :columns="3" :rows="4" />
|
||||
|
||||
<BaseTable
|
||||
v-else
|
||||
:data="domains"
|
||||
:columns="columns"
|
||||
:get-row-id="(row) => row.id"
|
||||
wrapperClass="mt-4 border-b border-border rounded-none border-x-0 border-t-0 bg-transparent"
|
||||
tableClass="w-full"
|
||||
headerRowClass="bg-muted/30"
|
||||
bodyRowClass="border-b border-border hover:bg-muted/30"
|
||||
>
|
||||
<template #empty>
|
||||
<div class="px-6 py-12 text-center">
|
||||
<LinkIcon class="mx-auto mb-3 block h-10 w-10 text-foreground/30" />
|
||||
<p class="mb-1 text-sm text-foreground/60">{{ t('settings.domainsDns.emptyTitle') }}</p>
|
||||
<p class="text-xs text-foreground/40">{{ t('settings.domainsDns.emptySubtitle') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</BaseTable>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import PlusIcon from '@/components/icons/PlusIcon.vue';
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
|
||||
defineProps<{
|
||||
disabled: boolean;
|
||||
loading: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'create'): void;
|
||||
}>();
|
||||
|
||||
const { t } = useTranslation();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppButton size="sm" :loading="loading" :disabled="disabled" @click="emit('create')">
|
||||
<template #icon>
|
||||
<PlusIcon class="w-4 h-4" />
|
||||
</template>
|
||||
{{ t('settings.domainsDns.addDomain') }}
|
||||
</AppButton>
|
||||
</template>
|
||||
25
src/routes/settings/DomainsDns/helpers.ts
Normal file
25
src/routes/settings/DomainsDns/helpers.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { DomainApiItem, DomainItem } from './types';
|
||||
|
||||
export const normalizeDomainInput = (value: string) => value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^https?:\/\//, '')
|
||||
.replace(/^www\./, '')
|
||||
.replace(/\/$/, '');
|
||||
|
||||
export const formatDate = (value?: string) => {
|
||||
if (!value) return '-';
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value.split('T')[0] || value;
|
||||
}
|
||||
|
||||
return date.toISOString().split('T')[0];
|
||||
};
|
||||
|
||||
export const mapDomainItem = (item: DomainApiItem): DomainItem => ({
|
||||
id: item.id || `${item.name || 'domain'}:${item.created_at || ''}`,
|
||||
name: item.name || '',
|
||||
addedAt: formatDate(item.created_at),
|
||||
});
|
||||
11
src/routes/settings/DomainsDns/types.ts
Normal file
11
src/routes/settings/DomainsDns/types.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export type DomainApiItem = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
created_at?: string;
|
||||
};
|
||||
|
||||
export type DomainItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
addedAt: string;
|
||||
};
|
||||
Reference in New Issue
Block a user