refactor: organize frontend by feature
This commit is contained in:
559
frontend/src/features/workspaces/views/WorkspaceSettingsView.vue
Normal file
559
frontend/src/features/workspaces/views/WorkspaceSettingsView.vue
Normal file
@@ -0,0 +1,559 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
|
||||
import {
|
||||
mdiAccountGroupOutline,
|
||||
mdiCheckCircleOutline,
|
||||
mdiCogOutline,
|
||||
mdiFolderGoogleDrive,
|
||||
mdiImageMultipleOutline,
|
||||
mdiTuneVariant,
|
||||
} from '@mdi/js';
|
||||
|
||||
const { t } = useI18n();
|
||||
const workspaceStore = useWorkspaceStore();
|
||||
const activeTab = ref('general');
|
||||
|
||||
const inviteForm = reactive({
|
||||
email: '',
|
||||
role: 'workspaceMember',
|
||||
});
|
||||
|
||||
const pendingInvites = computed(() =>
|
||||
workspaceStore.invitesByWorkspace[workspaceStore.activeWorkspaceId] ?? []
|
||||
);
|
||||
const workspaceMembers = computed(() =>
|
||||
workspaceStore.membersByWorkspace[workspaceStore.activeWorkspaceId] ?? []
|
||||
);
|
||||
const settingsTabs = computed(() => [
|
||||
{ key: 'general', label: t('workspaceSettings.tabs.general'), icon: mdiCogOutline },
|
||||
{ key: 'members', label: t('workspaceSettings.tabs.members'), icon: mdiAccountGroupOutline },
|
||||
{ key: 'workflow', label: t('workspaceSettings.tabs.workflow'), icon: mdiTuneVariant },
|
||||
{ key: 'connectors', label: t('workspaceSettings.tabs.connectors'), icon: mdiFolderGoogleDrive },
|
||||
]);
|
||||
const workflowSteps = computed(() => [
|
||||
{
|
||||
key: 'internal',
|
||||
title: t('workspaceSettings.approvals.steps.internal'),
|
||||
detail: t('workspaceSettings.approvals.stepDetail.approverCount', { count: 1 }),
|
||||
},
|
||||
{
|
||||
key: 'client',
|
||||
title: t('workspaceSettings.approvals.steps.client'),
|
||||
detail: t('workspaceSettings.approvals.stepDetail.approverCount', { count: 1 }),
|
||||
},
|
||||
{
|
||||
key: 'publish',
|
||||
title: t('workspaceSettings.approvals.steps.publish'),
|
||||
detail: t('workspaceSettings.approvals.stepDetail.manualPublish'),
|
||||
},
|
||||
]);
|
||||
|
||||
watch(
|
||||
() => workspaceStore.activeWorkspaceId,
|
||||
async workspaceId => {
|
||||
if (!workspaceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await workspaceStore.fetchInvites(workspaceId);
|
||||
await workspaceStore.fetchMembers(workspaceId);
|
||||
} catch (error) {
|
||||
console.error('Failed to load workspace people data:', error);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
async function submitInvite() {
|
||||
if (!inviteForm.email.trim() || !inviteForm.role) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await workspaceStore.inviteMember({
|
||||
email: inviteForm.email.trim(),
|
||||
role: inviteForm.role,
|
||||
});
|
||||
|
||||
inviteForm.email = '';
|
||||
inviteForm.role = 'workspaceMember';
|
||||
} catch (error) {
|
||||
console.error('Failed to invite workspace member:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return new Date(value).toLocaleString();
|
||||
}
|
||||
|
||||
function translateRole(role) {
|
||||
if (!role) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const normalizedRole = role.charAt(0).toLowerCase() + role.slice(1);
|
||||
return t(`workspaceSettings.roles.${normalizedRole}`, role);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="workspace-settings-shell">
|
||||
<div class="workspace-settings-hero">
|
||||
<div class="section-copy">
|
||||
<span class="section-kicker">{{ t('workspaceSettings.currentWorkspace') }}</span>
|
||||
<h1>{{ workspaceStore.activeWorkspace?.name || t('workspaceSettings.noWorkspaceSelected') }}</h1>
|
||||
<p>{{ t('workspaceSettings.description') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="tab-strip">
|
||||
<button
|
||||
v-for="tab in settingsTabs"
|
||||
:key="tab.key"
|
||||
type="button"
|
||||
class="tab-button"
|
||||
:class="{ 'tab-button-active': activeTab === tab.key }"
|
||||
@click="activeTab = tab.key"
|
||||
>
|
||||
<v-icon :icon="tab.icon" />
|
||||
<span>{{ tab.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="activeTab === 'general'"
|
||||
class="workspace-settings-grid workspace-settings-grid-single"
|
||||
>
|
||||
<article class="settings-card">
|
||||
<div class="section-copy">
|
||||
<span class="section-kicker">{{ t('workspaceSettings.general.summaryTitle') }}</span>
|
||||
<p>{{ t('workspaceSettings.general.summaryDescription') }}</p>
|
||||
</div>
|
||||
|
||||
<dl
|
||||
v-if="workspaceStore.activeWorkspace"
|
||||
class="summary-grid"
|
||||
>
|
||||
<div>
|
||||
<dt>{{ t('workspaceSettings.summary.name') }}</dt>
|
||||
<dd>{{ workspaceStore.activeWorkspace.name }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('workspaceSettings.summary.slug') }}</dt>
|
||||
<dd>{{ workspaceStore.activeWorkspace.slug }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('workspaceSettings.summary.timeZone') }}</dt>
|
||||
<dd>{{ workspaceStore.activeWorkspace.timeZone }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('workspaceSettings.summary.created') }}</dt>
|
||||
<dd>{{ formatDate(workspaceStore.activeWorkspace.createdAt) }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="activeTab === 'members'"
|
||||
class="workspace-settings-grid workspace-settings-grid-single"
|
||||
>
|
||||
<article class="settings-card">
|
||||
<div class="section-copy">
|
||||
<span class="section-kicker">{{ t('workspaceSettings.members.inviteTitle') }}</span>
|
||||
<p>{{ t('workspaceSettings.inviteDescription') }}</p>
|
||||
</div>
|
||||
|
||||
<form
|
||||
class="form-stack"
|
||||
@submit.prevent="submitInvite"
|
||||
>
|
||||
<label class="field">
|
||||
<span>{{ t('workspaceSettings.fields.memberEmail') }}</span>
|
||||
<input
|
||||
v-model="inviteForm.email"
|
||||
type="email"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>{{ t('workspaceSettings.fields.memberRole') }}</span>
|
||||
<select v-model="inviteForm.role">
|
||||
<option value="workspaceMember">{{ t('workspaceSettings.roles.workspaceMember') }}</option>
|
||||
<option value="client">{{ t('workspaceSettings.roles.client') }}</option>
|
||||
<option value="provider">{{ t('workspaceSettings.roles.provider') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button
|
||||
class="primary-button"
|
||||
type="submit"
|
||||
>
|
||||
{{ workspaceStore.isInviting ? t('common.creating') : t('workspaceSettings.sendInvite') }}
|
||||
</button>
|
||||
</form>
|
||||
</article>
|
||||
|
||||
<article class="settings-card">
|
||||
<div class="section-copy">
|
||||
<span class="section-kicker">{{ t('workspaceSettings.members.pendingTitle') }}</span>
|
||||
<p>{{ t('workspaceSettings.members.pendingDescription') }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="workspaceStore.isInvitesLoading"
|
||||
class="empty-state"
|
||||
>
|
||||
{{ t('loading') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="pendingInvites.length"
|
||||
class="invite-list"
|
||||
>
|
||||
<div
|
||||
v-for="invite in pendingInvites"
|
||||
:key="invite.id"
|
||||
class="invite-row"
|
||||
>
|
||||
<div>
|
||||
<strong>{{ invite.email }}</strong>
|
||||
<span>{{ t(`workspaceSettings.roles.${invite.role}`) }}</span>
|
||||
</div>
|
||||
<small>{{ formatDate(invite.createdAt) }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="empty-state"
|
||||
>
|
||||
{{ t('workspaceSettings.inviteEmpty') }}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="settings-card">
|
||||
<div class="section-copy">
|
||||
<span class="section-kicker">{{ t('workspaceSettings.members.activeTitle') }}</span>
|
||||
<p>{{ t('workspaceSettings.members.activeDescription') }}</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="workspaceStore.isMembersLoading"
|
||||
class="empty-state"
|
||||
>
|
||||
{{ t('loading') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="workspaceMembers.length"
|
||||
class="invite-list"
|
||||
>
|
||||
<div
|
||||
v-for="member in workspaceMembers"
|
||||
:key="member.id"
|
||||
class="invite-row"
|
||||
>
|
||||
<div>
|
||||
<strong>{{ member.displayName }}</strong>
|
||||
<span>{{ member.email }}</span>
|
||||
<span>{{ member.roles.map(translateRole).join(' · ') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="empty-state"
|
||||
>
|
||||
{{ t('workspaceSettings.members.activeEmpty') }}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="activeTab === 'workflow'"
|
||||
class="workflow-grid"
|
||||
>
|
||||
<article class="settings-card">
|
||||
<div class="section-copy">
|
||||
<span class="section-kicker">{{ t('workspaceSettings.approvals.flowTitle') }}</span>
|
||||
<p>{{ t('workspaceSettings.approvals.flowDescription') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="workflow-rule-list">
|
||||
<div class="workflow-rule">
|
||||
<strong>{{ t('workspaceSettings.approvals.fields.requireInternalReview') }}</strong>
|
||||
<span>{{ t('workspaceSettings.approvals.fieldHelp.requireInternalReview') }}</span>
|
||||
</div>
|
||||
<div class="workflow-rule">
|
||||
<strong>{{ t('workspaceSettings.approvals.fields.requireClientReview') }}</strong>
|
||||
<span>{{ t('workspaceSettings.approvals.fieldHelp.requireClientReview') }}</span>
|
||||
</div>
|
||||
<div class="workflow-rule">
|
||||
<strong>{{ t('workspaceSettings.approvals.fields.publishBehaviour') }}</strong>
|
||||
<span>{{ t('workspaceSettings.approvals.publishBehaviour.manual') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="settings-card">
|
||||
<div class="section-copy">
|
||||
<span class="section-kicker">{{ t('workspaceSettings.approvals.previewTitle') }}</span>
|
||||
<p>{{ t('workspaceSettings.approvals.previewDescription') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="workflow-steps">
|
||||
<div
|
||||
v-for="step in workflowSteps"
|
||||
:key="step.key"
|
||||
class="workflow-step"
|
||||
>
|
||||
<div class="workflow-step-icon">
|
||||
<v-icon :icon="mdiCheckCircleOutline" />
|
||||
</div>
|
||||
<div class="workflow-step-copy">
|
||||
<strong>{{ step.title }}</strong>
|
||||
<span>{{ step.detail }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="workspace-settings-grid"
|
||||
>
|
||||
<article class="settings-card">
|
||||
<div class="section-copy">
|
||||
<span class="section-kicker">{{ t('workspaceSettings.connectors.title') }}</span>
|
||||
<p>{{ t('workspaceSettings.connectors.description') }}</p>
|
||||
</div>
|
||||
|
||||
<div class="connector-list">
|
||||
<div class="connector-row">
|
||||
<div class="connector-main">
|
||||
<div class="connector-icon">
|
||||
<v-icon :icon="mdiFolderGoogleDrive" />
|
||||
</div>
|
||||
|
||||
<div class="connector-copy">
|
||||
<strong>{{ t('workspaceSettings.connectors.googleDrive.title') }}</strong>
|
||||
<span>{{ t('workspaceSettings.connectors.googleDrive.description') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="connector-status">
|
||||
{{ t('workspaceSettings.connectors.googleDrive.status') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<router-link
|
||||
:to="{ name: 'media-library' }"
|
||||
class="connector-link"
|
||||
>
|
||||
<v-icon :icon="mdiImageMultipleOutline" />
|
||||
<span>{{ t('workspaceSettings.connectors.openMediaLibrary') }}</span>
|
||||
</router-link>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.workspace-settings-shell {
|
||||
@apply mx-auto flex w-full max-w-6xl flex-col gap-6 px-5 py-8 md:px-8;
|
||||
}
|
||||
|
||||
.workspace-settings-hero {
|
||||
@apply flex flex-col gap-5 rounded-[1.75rem] border p-5 md:p-6;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(15, 118, 110, 0.16), transparent 38%),
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.94));
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
.workspace-settings-grid {
|
||||
@apply grid gap-4 lg:grid-cols-2;
|
||||
}
|
||||
|
||||
.workflow-grid {
|
||||
@apply grid gap-4 xl:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)];
|
||||
}
|
||||
|
||||
.workspace-settings-grid-single {
|
||||
@apply lg:grid-cols-1;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
@apply flex flex-col gap-5 rounded-[1.75rem] border p-5;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
box-shadow: 0 18px 40px rgba(23, 32, 51, 0.06);
|
||||
}
|
||||
|
||||
.section-copy {
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
|
||||
.tab-strip {
|
||||
@apply flex flex-wrap gap-3;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
@apply inline-flex items-center gap-3 rounded-full px-4 py-3 text-sm font-semibold transition;
|
||||
background: rgba(23, 32, 51, 0.06);
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.tab-button-active {
|
||||
background: #172033;
|
||||
color: #fffaf2;
|
||||
}
|
||||
|
||||
.section-kicker {
|
||||
@apply text-xs font-bold uppercase tracking-[0.2em];
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.section-copy h1,
|
||||
.summary-grid dd,
|
||||
.invite-row strong,
|
||||
.connector-copy strong,
|
||||
.connector-status,
|
||||
.workflow-rule strong,
|
||||
.workflow-step-copy strong {
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.section-copy h1 {
|
||||
@apply text-3xl font-black;
|
||||
}
|
||||
|
||||
.section-copy p,
|
||||
.summary-grid dt,
|
||||
.invite-row span,
|
||||
.invite-row small,
|
||||
.empty-state,
|
||||
.connector-copy span,
|
||||
.connector-link span,
|
||||
.workflow-rule span,
|
||||
.workflow-step-copy span {
|
||||
@apply text-sm leading-6;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
@apply grid gap-4 sm:grid-cols-2;
|
||||
}
|
||||
|
||||
.summary-grid div {
|
||||
@apply rounded-[1rem] border p-4;
|
||||
background: #f8fafc;
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
.summary-grid dt {
|
||||
@apply text-xs font-bold uppercase tracking-[0.16em];
|
||||
}
|
||||
|
||||
.summary-grid dd {
|
||||
@apply mt-2 text-base font-semibold;
|
||||
}
|
||||
|
||||
.form-stack {
|
||||
@apply flex flex-col gap-4;
|
||||
}
|
||||
|
||||
.field {
|
||||
@apply flex flex-col gap-2;
|
||||
}
|
||||
|
||||
.field span {
|
||||
@apply text-sm font-semibold;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field select {
|
||||
@apply rounded-[1rem] border px-4 py-3 text-sm;
|
||||
background: #fffdf8;
|
||||
border-color: rgba(23, 32, 51, 0.1);
|
||||
color: #172033;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.primary-button {
|
||||
@apply inline-flex items-center justify-center rounded-full px-5 py-3 text-sm font-semibold;
|
||||
background: #172033;
|
||||
color: #fffaf2;
|
||||
}
|
||||
|
||||
.invite-list,
|
||||
.connector-list,
|
||||
.workflow-rule-list,
|
||||
.workflow-steps {
|
||||
@apply flex flex-col gap-3;
|
||||
}
|
||||
|
||||
.invite-row,
|
||||
.empty-state,
|
||||
.connector-row,
|
||||
.workflow-rule,
|
||||
.workflow-step {
|
||||
@apply rounded-[1rem] border px-4 py-4;
|
||||
background: #fffaf2;
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
.invite-row {
|
||||
@apply flex items-start justify-between gap-4;
|
||||
}
|
||||
|
||||
.invite-row div,
|
||||
.connector-copy,
|
||||
.workflow-rule,
|
||||
.workflow-step-copy {
|
||||
@apply flex flex-col gap-1;
|
||||
}
|
||||
|
||||
.connector-row {
|
||||
@apply flex flex-col gap-4 md:flex-row md:items-center md:justify-between;
|
||||
}
|
||||
|
||||
.connector-main,
|
||||
.workflow-step {
|
||||
@apply flex items-start gap-4;
|
||||
}
|
||||
|
||||
.connector-icon,
|
||||
.workflow-step-icon {
|
||||
@apply inline-flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-2xl;
|
||||
background: rgba(15, 118, 110, 0.1);
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.connector-status {
|
||||
@apply inline-flex w-fit items-center rounded-full px-3 py-1 text-xs font-bold uppercase tracking-[0.18em];
|
||||
background: rgba(245, 158, 11, 0.14);
|
||||
}
|
||||
|
||||
.connector-link {
|
||||
@apply inline-flex w-fit items-center gap-3 rounded-full px-5 py-3 text-sm font-semibold no-underline transition;
|
||||
background: #172033;
|
||||
color: #fffaf2;
|
||||
}
|
||||
|
||||
.connector-link:hover {
|
||||
background: #0f172a;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user