done ui
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { client } from '@/api/client';
|
||||
import AppButton from '@/components/app/AppButton.vue';
|
||||
import AppDialog from '@/components/app/AppDialog.vue';
|
||||
import AppInput from '@/components/app/AppInput.vue';
|
||||
@@ -10,23 +11,99 @@ 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 { computed, ref } from 'vue';
|
||||
import SettingsTableSkeleton from '@/routes/settings/components/SettingsTableSkeleton.vue';
|
||||
import { useQuery } from '@pinia/colada';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
|
||||
const toast = useAppToast();
|
||||
const confirm = useAppConfirm();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const domains = ref([
|
||||
{ id: '1', name: 'example.com', addedAt: '2024-01-15' },
|
||||
{ id: '2', name: 'mysite.org', addedAt: '2024-02-20' },
|
||||
]);
|
||||
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 handleAddDomain = () => {
|
||||
if (!newDomain.value.trim()) {
|
||||
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 client.domains.domainsList({ baseUrl: '/r' });
|
||||
return ((((response.data as any)?.data?.domains) || []) as DomainApiItem[]).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;
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
const closeAddDialog = () => {
|
||||
showAddDialog.value = false;
|
||||
newDomain.value = '';
|
||||
};
|
||||
|
||||
const handleAddDomain = async () => {
|
||||
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'),
|
||||
@@ -36,7 +113,7 @@ const handleAddDomain = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const exists = domains.value.some(d => d.name === newDomain.value.trim().toLowerCase());
|
||||
const exists = domains.value.some(domain => domain.name === domainName);
|
||||
if (exists) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
@@ -47,48 +124,95 @@ const handleAddDomain = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const domainName = newDomain.value.trim().toLowerCase();
|
||||
domains.value.push({
|
||||
id: Math.random().toString(36).substring(2, 9),
|
||||
name: domainName,
|
||||
addedAt: new Date().toISOString().split('T')[0],
|
||||
});
|
||||
adding.value = true;
|
||||
try {
|
||||
await client.domains.domainsCreate({
|
||||
name: domainName,
|
||||
}, { baseUrl: '/r' });
|
||||
|
||||
newDomain.value = '';
|
||||
showAddDialog.value = false;
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('settings.domainsDns.toast.addedSummary'),
|
||||
detail: t('settings.domainsDns.toast.addedDetail', { domain: domainName }),
|
||||
life: 3000,
|
||||
});
|
||||
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 handleRemoveDomain = (domain: typeof domains.value[0]) => {
|
||||
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: () => {
|
||||
const index = domains.value.findIndex(d => d.id === domain.id);
|
||||
if (index !== -1) {
|
||||
domains.value.splice(index, 1);
|
||||
accept: async () => {
|
||||
removingId.value = domain.id;
|
||||
try {
|
||||
await client.domains.domainsDelete(domain.id, { baseUrl: '/r' });
|
||||
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;
|
||||
}
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
summary: t('settings.domainsDns.toast.removedSummary'),
|
||||
detail: t('settings.domainsDns.toast.removedDetail', { domain: domain.name }),
|
||||
life: 3000,
|
||||
});
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const iframeCode = computed(() => '<iframe src="https://holistream.com/embed" width="100%" height="500" frameborder="0" allowfullscreen></iframe>');
|
||||
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);
|
||||
}
|
||||
|
||||
const copyIframeCode = () => {
|
||||
navigator.clipboard.writeText(iframeCode.value);
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('settings.domainsDns.toast.copiedSummary'),
|
||||
@@ -105,7 +229,7 @@ const copyIframeCode = () => {
|
||||
bodyClass=""
|
||||
>
|
||||
<template #header-actions>
|
||||
<AppButton size="sm" @click="showAddDialog = true">
|
||||
<AppButton size="sm" :loading="adding" :disabled="isInitialLoading || removingId !== null" @click="openAddDialog">
|
||||
<template #icon>
|
||||
<PlusIcon class="w-4 h-4" />
|
||||
</template>
|
||||
@@ -117,7 +241,9 @@ const copyIframeCode = () => {
|
||||
{{ t('settings.domainsDns.infoBanner') }}
|
||||
</SettingsNotice>
|
||||
|
||||
<div class="border-b border-border mt-4">
|
||||
<SettingsTableSkeleton v-if="isInitialLoading" :columns="3" :rows="4" />
|
||||
|
||||
<div v-else class="border-b border-border mt-4">
|
||||
<table class="w-full">
|
||||
<thead class="bg-muted/30">
|
||||
<tr>
|
||||
@@ -127,27 +253,34 @@ const copyIframeCode = () => {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-border">
|
||||
<tr
|
||||
v-for="domain in domains"
|
||||
:key="domain.id"
|
||||
class="hover:bg-muted/30 transition-all"
|
||||
>
|
||||
<td class="px-6 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<LinkIcon class="w-4 h-4 text-foreground/40" />
|
||||
<span class="text-sm font-medium text-foreground">{{ domain.name }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-3 text-sm text-foreground/60">{{ domain.addedAt }}</td>
|
||||
<td class="px-6 py-3 text-right">
|
||||
<AppButton variant="ghost" size="sm" @click="handleRemoveDomain(domain)">
|
||||
<template #icon>
|
||||
<TrashIcon class="w-4 h-4 text-danger" />
|
||||
</template>
|
||||
</AppButton>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="domains.length === 0">
|
||||
<template v-if="domains.length > 0">
|
||||
<tr
|
||||
v-for="domain in domains"
|
||||
:key="domain.id"
|
||||
class="hover:bg-muted/30 transition-all"
|
||||
>
|
||||
<td class="px-6 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<LinkIcon class="w-4 h-4 text-foreground/40" />
|
||||
<span class="text-sm font-medium text-foreground">{{ domain.name }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-3 text-sm text-foreground/60">{{ domain.addedAt }}</td>
|
||||
<td class="px-6 py-3 text-right">
|
||||
<AppButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:disabled="adding || removingId !== null"
|
||||
@click="handleRemoveDomain(domain)"
|
||||
>
|
||||
<template #icon>
|
||||
<TrashIcon class="w-4 h-4 text-danger" />
|
||||
</template>
|
||||
</AppButton>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-else>
|
||||
<td colspan="3" class="px-6 py-12 text-center">
|
||||
<LinkIcon class="w-10 h-10 text-foreground/30 mb-3 block mx-auto" />
|
||||
<p class="text-sm text-foreground/60 mb-1">{{ t('settings.domainsDns.emptyTitle') }}</p>
|
||||
@@ -176,9 +309,10 @@ const copyIframeCode = () => {
|
||||
|
||||
<AppDialog
|
||||
:visible="showAddDialog"
|
||||
@update:visible="showAddDialog = $event"
|
||||
: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">
|
||||
@@ -202,15 +336,17 @@ const copyIframeCode = () => {
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<AppButton variant="secondary" size="sm" @click="showAddDialog = false">
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" :disabled="adding" @click="closeAddDialog">
|
||||
{{ t('common.cancel') }}
|
||||
</AppButton>
|
||||
<AppButton size="sm" @click="handleAddDomain">
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user