feat: update workspace settings
This commit is contained in:
@@ -50,7 +50,7 @@ public static class DependencyInjection
|
||||
{
|
||||
using IServiceScope scope = app.ApplicationServices.CreateScope();
|
||||
await using AppDbContext context = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
await context.Database.EnsureCreatedAsync(cancellationToken);
|
||||
await context.Database.MigrateAsync(cancellationToken);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -4,5 +4,6 @@ internal static class ContainerNames
|
||||
{
|
||||
public const string Users = "users";
|
||||
public const string Clients = "clients";
|
||||
public const string Workspaces = "workspaces";
|
||||
public const string Creators = "creators";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Socialize.Api.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Socialize.Api.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260430054500_AddWorkspaceLogo")]
|
||||
public partial class AddWorkspaceLogo : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "LogoUrl",
|
||||
table: "Workspaces",
|
||||
type: "character varying(2048)",
|
||||
maxLength: 2048,
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LogoUrl",
|
||||
table: "Workspaces");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -819,6 +819,10 @@ namespace Socialize.Api.Migrations
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("character varying(256)");
|
||||
|
||||
b.Property<string>("LogoUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<Guid>("OwnerUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ public class Workspace
|
||||
public Guid Id { get; init; }
|
||||
public required string Name { get; set; }
|
||||
public required string Slug { get; set; }
|
||||
public string? LogoUrl { get; set; }
|
||||
public Guid OwnerUserId { get; set; }
|
||||
public required string TimeZone { get; set; }
|
||||
public DateTimeOffset CreatedAt { get; init; }
|
||||
|
||||
@@ -12,6 +12,7 @@ public static class WorkspaceModelConfiguration
|
||||
workspace.HasKey(x => x.Id);
|
||||
workspace.Property(x => x.Name).HasMaxLength(256).IsRequired();
|
||||
workspace.Property(x => x.Slug).HasMaxLength(128).IsRequired();
|
||||
workspace.Property(x => x.LogoUrl).HasMaxLength(2048);
|
||||
workspace.Property(x => x.TimeZone).HasMaxLength(128).IsRequired();
|
||||
workspace.Property(x => x.CreatedAt)
|
||||
.ValueGeneratedOnAdd()
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.BlobStorage.Contracts;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
using Socialize.Api.Modules.Workspaces.Data;
|
||||
|
||||
namespace Socialize.Api.Modules.Workspaces.Handlers;
|
||||
|
||||
public record ChangeWorkspaceLogoRequest(
|
||||
IFormFile File);
|
||||
|
||||
public record ChangeWorkspaceLogoResponse(
|
||||
string BlobUrl);
|
||||
|
||||
public sealed class ChangeWorkspaceLogoRequestValidator : Validator<ChangeWorkspaceLogoRequest>
|
||||
{
|
||||
public ChangeWorkspaceLogoRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.File)
|
||||
.NotNull()
|
||||
.NotEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
public class ChangeWorkspaceLogoHandler(
|
||||
AppDbContext dbContext,
|
||||
IBlobStorage blobStorage,
|
||||
AccessScopeService accessScopeService)
|
||||
: Endpoint<ChangeWorkspaceLogoRequest, ChangeWorkspaceLogoResponse>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/workspaces/{id}/logo");
|
||||
Options(o => o.WithTags("Workspaces"));
|
||||
AllowFileUploads();
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(ChangeWorkspaceLogoRequest request, CancellationToken ct)
|
||||
{
|
||||
Guid id = Route<Guid>("id");
|
||||
|
||||
Workspace? workspace = await dbContext.Workspaces.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
|
||||
if (workspace is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessScopeService.CanManageWorkspace(User, workspace.Id))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
string blobUrl = await blobStorage.UploadFileAsync(
|
||||
ContainerNames.Workspaces,
|
||||
$"{workspace.Id}/{SubDirectoryNames.Profile}/{CommonFileNames.LogoPicture}",
|
||||
request.File.OpenReadStream(),
|
||||
request.File.ContentType,
|
||||
ct);
|
||||
|
||||
workspace.LogoUrl = blobUrl;
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
await SendOkAsync(new ChangeWorkspaceLogoResponse(blobUrl), ct);
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,7 @@ public class CreateWorkspaceHandler(
|
||||
workspace.Id,
|
||||
workspace.Name,
|
||||
workspace.Slug,
|
||||
workspace.LogoUrl,
|
||||
workspace.TimeZone,
|
||||
workspace.CreatedAt);
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ public record WorkspaceDto(
|
||||
Guid Id,
|
||||
string Name,
|
||||
string Slug,
|
||||
string? LogoUrl,
|
||||
string TimeZone,
|
||||
DateTimeOffset CreatedAt);
|
||||
|
||||
@@ -40,6 +41,7 @@ public class GetWorkspacesHandler(
|
||||
workspace.Id,
|
||||
workspace.Name,
|
||||
workspace.Slug,
|
||||
workspace.LogoUrl,
|
||||
workspace.TimeZone,
|
||||
workspace.CreatedAt))
|
||||
.ToListAsync(ct);
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
using FastEndpoints;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Socialize.Api.Data;
|
||||
using Socialize.Api.Infrastructure.Security;
|
||||
using Socialize.Api.Modules.Workspaces.Data;
|
||||
|
||||
namespace Socialize.Api.Modules.Workspaces.Handlers;
|
||||
|
||||
public record UpdateWorkspaceRequest(
|
||||
string Name,
|
||||
string TimeZone);
|
||||
|
||||
public class UpdateWorkspaceRequestValidator
|
||||
: Validator<UpdateWorkspaceRequest>
|
||||
{
|
||||
public UpdateWorkspaceRequestValidator()
|
||||
{
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(256);
|
||||
RuleFor(x => x.TimeZone).NotEmpty().MaximumLength(128);
|
||||
}
|
||||
}
|
||||
|
||||
public class UpdateWorkspaceHandler(
|
||||
AppDbContext dbContext,
|
||||
AccessScopeService accessScopeService)
|
||||
: Endpoint<UpdateWorkspaceRequest, WorkspaceDto>
|
||||
{
|
||||
public override void Configure()
|
||||
{
|
||||
Put("/api/workspaces/{id}");
|
||||
Options(o => o.WithTags("Workspaces"));
|
||||
}
|
||||
|
||||
public override async Task HandleAsync(UpdateWorkspaceRequest request, CancellationToken ct)
|
||||
{
|
||||
Guid id = Route<Guid>("id");
|
||||
|
||||
Workspace? workspace = await dbContext.Workspaces.SingleOrDefaultAsync(candidate => candidate.Id == id, ct);
|
||||
if (workspace is null)
|
||||
{
|
||||
await SendNotFoundAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessScopeService.CanManageWorkspace(User, workspace.Id))
|
||||
{
|
||||
await SendForbiddenAsync(ct);
|
||||
return;
|
||||
}
|
||||
|
||||
workspace.Name = request.Name.Trim();
|
||||
workspace.TimeZone = request.TimeZone.Trim();
|
||||
|
||||
await dbContext.SaveChangesAsync(ct);
|
||||
|
||||
WorkspaceDto dto = new(
|
||||
workspace.Id,
|
||||
workspace.Name,
|
||||
workspace.Slug,
|
||||
workspace.LogoUrl,
|
||||
workspace.TimeZone,
|
||||
workspace.CreatedAt);
|
||||
|
||||
await SendOkAsync(dto, ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
# Task: Edit workspace settings
|
||||
|
||||
## Goal
|
||||
|
||||
Allow managers to update the active workspace name and time zone from the workspace settings page.
|
||||
|
||||
## Feature Spec
|
||||
|
||||
- `docs/FEATURES/workspace-review-workflow.md`
|
||||
|
||||
## Scope
|
||||
|
||||
- Add a backend workspace update endpoint for `name` and `timeZone`.
|
||||
- Add a backend workspace logo upload endpoint.
|
||||
- Add a frontend workspace store update action.
|
||||
- Replace the workspace settings general summary with editable details and logo controls.
|
||||
- Do not display workspace slug or workspace creation date on the workspace settings page.
|
||||
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
dotnet build backend/Socialize.slnx
|
||||
cd frontend && npm run build
|
||||
```
|
||||
2717
frontend/src/api/schema.d.ts
vendored
2717
frontend/src/api/schema.d.ts
vendored
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { getTimeZoneOptions } from '@/features/workspaces/timeZones.js';
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
const timeZoneOptions = computed(() => getTimeZoneOptions(props.modelValue));
|
||||
|
||||
function updateValue(event) {
|
||||
emit('update:modelValue', event.target.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<select
|
||||
class="time-zone-select"
|
||||
:value="modelValue"
|
||||
:disabled="disabled"
|
||||
@change="updateValue"
|
||||
>
|
||||
<option
|
||||
v-for="timeZone in timeZoneOptions"
|
||||
:key="timeZone.value"
|
||||
:value="timeZone.value"
|
||||
>
|
||||
{{ timeZone.label }}
|
||||
</option>
|
||||
</select>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.time-zone-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;
|
||||
}
|
||||
</style>
|
||||
@@ -11,6 +11,8 @@ export const useWorkspaceStore = defineStore('workspace', () => {
|
||||
const activeWorkspaceId = ref(null);
|
||||
const isLoading = ref(false);
|
||||
const isCreating = ref(false);
|
||||
const isUpdating = ref(false);
|
||||
const isUploadingLogo = ref(false);
|
||||
const invitesByWorkspace = ref({});
|
||||
const membersByWorkspace = ref({});
|
||||
const isInvitesLoading = ref(false);
|
||||
@@ -90,6 +92,74 @@ export const useWorkspaceStore = defineStore('workspace', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function updateWorkspace(workspaceId, payload) {
|
||||
if (!authStore.isAuthenticated || !workspaceId) {
|
||||
throw new Error('You must be authenticated to update a workspace.');
|
||||
}
|
||||
|
||||
if (isUpdating.value) {
|
||||
throw new Error('A workspace update request is already in progress.');
|
||||
}
|
||||
|
||||
isUpdating.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const response = await client.put(`/api/workspaces/${workspaceId}`, payload);
|
||||
|
||||
if (response.data) {
|
||||
workspaces.value = workspaces.value
|
||||
.map(workspace => (workspace.id === workspaceId ? response.data : workspace))
|
||||
.sort((left, right) => left.name.localeCompare(right.name));
|
||||
}
|
||||
|
||||
return response.data;
|
||||
} catch (updateError) {
|
||||
console.error('Failed to update workspace:', updateError);
|
||||
error.value = 'Failed to update workspace.';
|
||||
throw updateError;
|
||||
} finally {
|
||||
isUpdating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadWorkspaceLogo(workspaceId, file) {
|
||||
if (!authStore.isAuthenticated || !workspaceId) {
|
||||
throw new Error('You must be authenticated to upload a workspace logo.');
|
||||
}
|
||||
|
||||
if (isUploadingLogo.value) {
|
||||
throw new Error('A workspace logo upload is already in progress.');
|
||||
}
|
||||
|
||||
isUploadingLogo.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file, file.name || 'workspace-logo.png');
|
||||
|
||||
const response = await client.post(`/api/workspaces/${workspaceId}/logo`, formData);
|
||||
const blobUrl = response.data?.blobUrl;
|
||||
|
||||
if (blobUrl) {
|
||||
workspaces.value = workspaces.value.map(workspace =>
|
||||
workspace.id === workspaceId
|
||||
? { ...workspace, logoUrl: `${blobUrl}?${Date.now()}` }
|
||||
: workspace
|
||||
);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
} catch (uploadError) {
|
||||
console.error('Failed to upload workspace logo:', uploadError);
|
||||
error.value = 'Failed to upload workspace logo.';
|
||||
throw uploadError;
|
||||
} finally {
|
||||
isUploadingLogo.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function setActiveWorkspace(workspaceId) {
|
||||
if (workspaces.value.some(workspace => workspace.id === workspaceId)) {
|
||||
activeWorkspaceId.value = workspaceId;
|
||||
@@ -192,6 +262,8 @@ export const useWorkspaceStore = defineStore('workspace', () => {
|
||||
activeWorkspace,
|
||||
isLoading,
|
||||
isCreating,
|
||||
isUpdating,
|
||||
isUploadingLogo,
|
||||
invitesByWorkspace,
|
||||
membersByWorkspace,
|
||||
isInvitesLoading,
|
||||
@@ -200,6 +272,8 @@ export const useWorkspaceStore = defineStore('workspace', () => {
|
||||
error,
|
||||
fetchWorkspaces,
|
||||
createWorkspace,
|
||||
updateWorkspace,
|
||||
uploadWorkspaceLogo,
|
||||
fetchInvites,
|
||||
fetchMembers,
|
||||
inviteMember,
|
||||
|
||||
84
frontend/src/features/workspaces/timeZones.js
Normal file
84
frontend/src/features/workspaces/timeZones.js
Normal file
@@ -0,0 +1,84 @@
|
||||
const FALLBACK_TIME_ZONES = [
|
||||
'UTC',
|
||||
'America/Los_Angeles',
|
||||
'America/Denver',
|
||||
'America/Chicago',
|
||||
'America/New_York',
|
||||
'America/Toronto',
|
||||
'America/Montreal',
|
||||
'America/Vancouver',
|
||||
'America/Mexico_City',
|
||||
'America/Sao_Paulo',
|
||||
'Europe/London',
|
||||
'Europe/Paris',
|
||||
'Europe/Berlin',
|
||||
'Europe/Madrid',
|
||||
'Europe/Rome',
|
||||
'Europe/Amsterdam',
|
||||
'Europe/Zurich',
|
||||
'Europe/Stockholm',
|
||||
'Europe/Warsaw',
|
||||
'Africa/Casablanca',
|
||||
'Africa/Johannesburg',
|
||||
'Asia/Dubai',
|
||||
'Asia/Kolkata',
|
||||
'Asia/Singapore',
|
||||
'Asia/Tokyo',
|
||||
'Asia/Seoul',
|
||||
'Asia/Shanghai',
|
||||
'Australia/Sydney',
|
||||
'Pacific/Auckland',
|
||||
];
|
||||
|
||||
export function getTimeZoneOptions(selectedTimeZone) {
|
||||
const supportedTimeZones = getSupportedTimeZones();
|
||||
const timeZones = new Set(['UTC', ...supportedTimeZones]);
|
||||
|
||||
if (selectedTimeZone) {
|
||||
timeZones.add(selectedTimeZone);
|
||||
}
|
||||
|
||||
return [...timeZones]
|
||||
.sort((left, right) => left.localeCompare(right))
|
||||
.map(timeZone => ({
|
||||
value: timeZone,
|
||||
label: formatTimeZoneLabel(timeZone),
|
||||
}));
|
||||
}
|
||||
|
||||
function getSupportedTimeZones() {
|
||||
if (typeof Intl.supportedValuesOf === 'function') {
|
||||
return Intl.supportedValuesOf('timeZone');
|
||||
}
|
||||
|
||||
return FALLBACK_TIME_ZONES;
|
||||
}
|
||||
|
||||
function formatTimeZoneLabel(timeZone) {
|
||||
const offset = formatTimeZoneOffset(timeZone);
|
||||
|
||||
if (!offset) {
|
||||
return timeZone.replaceAll('_', ' ');
|
||||
}
|
||||
|
||||
return `${timeZone.replaceAll('_', ' ')} (${offset})`;
|
||||
}
|
||||
|
||||
function formatTimeZoneOffset(timeZone) {
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat('en-US', {
|
||||
hour: '2-digit',
|
||||
timeZone,
|
||||
timeZoneName: 'shortOffset',
|
||||
}).formatToParts(new Date());
|
||||
const offset = parts.find(part => part.type === 'timeZoneName')?.value;
|
||||
|
||||
if (!offset) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return offset.replace('GMT', 'UTC');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import TimeZoneSelect from '@/features/workspaces/components/TimeZoneSelect.vue';
|
||||
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
|
||||
|
||||
const router = useRouter();
|
||||
@@ -126,9 +127,8 @@
|
||||
|
||||
<label class="field">
|
||||
<span>{{ t('workspaceCreate.fields.timeZone') }}</span>
|
||||
<input
|
||||
<TimeZoneSelect
|
||||
v-model="form.timeZone"
|
||||
type="text"
|
||||
:disabled="workspaceStore.isCreating"
|
||||
/>
|
||||
</label>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import AppAvatar from '@/components/AppAvatar.vue';
|
||||
import ImageCropperDialog from '@/components/ImageCropperDialog.vue';
|
||||
import TimeZoneSelect from '@/features/workspaces/components/TimeZoneSelect.vue';
|
||||
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
|
||||
import {
|
||||
mdiAccountGroupOutline,
|
||||
@@ -14,6 +17,15 @@
|
||||
const { t } = useI18n();
|
||||
const workspaceStore = useWorkspaceStore();
|
||||
const activeTab = ref('general');
|
||||
const settingsForm = reactive({
|
||||
name: '',
|
||||
timeZone: '',
|
||||
});
|
||||
const settingsError = ref(null);
|
||||
const settingsStatus = ref(null);
|
||||
const logoError = ref(null);
|
||||
const logoStatus = ref(null);
|
||||
const isLogoDialogOpen = ref(false);
|
||||
|
||||
const inviteForm = reactive({
|
||||
email: '',
|
||||
@@ -26,6 +38,15 @@
|
||||
const workspaceMembers = computed(() =>
|
||||
workspaceStore.membersByWorkspace[workspaceStore.activeWorkspaceId] ?? []
|
||||
);
|
||||
const isSettingsDirty = computed(() => {
|
||||
const workspace = workspaceStore.activeWorkspace;
|
||||
|
||||
if (!workspace) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return settingsForm.name.trim() !== workspace.name || settingsForm.timeZone.trim() !== workspace.timeZone;
|
||||
});
|
||||
const settingsTabs = computed(() => [
|
||||
{ key: 'general', label: t('workspaceSettings.tabs.general'), icon: mdiCogOutline },
|
||||
{ key: 'members', label: t('workspaceSettings.tabs.members'), icon: mdiAccountGroupOutline },
|
||||
@@ -50,6 +71,17 @@
|
||||
},
|
||||
]);
|
||||
|
||||
watch(
|
||||
() => workspaceStore.activeWorkspace,
|
||||
workspace => {
|
||||
settingsForm.name = workspace?.name ?? '';
|
||||
settingsForm.timeZone = workspace?.timeZone ?? '';
|
||||
settingsError.value = null;
|
||||
settingsStatus.value = null;
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => workspaceStore.activeWorkspaceId,
|
||||
async workspaceId => {
|
||||
@@ -67,6 +99,56 @@
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
async function submitWorkspaceSettings() {
|
||||
const workspace = workspaceStore.activeWorkspace;
|
||||
|
||||
if (!workspace || workspaceStore.isUpdating) {
|
||||
return;
|
||||
}
|
||||
|
||||
settingsError.value = null;
|
||||
settingsStatus.value = null;
|
||||
|
||||
const name = settingsForm.name.trim();
|
||||
const timeZone = settingsForm.timeZone.trim();
|
||||
|
||||
if (!name || !timeZone) {
|
||||
settingsError.value = t('workspaceSettings.errors.required');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await workspaceStore.updateWorkspace(workspace.id, {
|
||||
name,
|
||||
timeZone,
|
||||
});
|
||||
settingsStatus.value = t('workspaceSettings.general.saved');
|
||||
} catch (error) {
|
||||
console.error('Failed to update workspace settings:', error);
|
||||
settingsError.value = t('workspaceSettings.errors.updateFailed');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveWorkspaceLogo(result) {
|
||||
const workspace = workspaceStore.activeWorkspace;
|
||||
|
||||
if (!workspace || workspaceStore.isUploadingLogo) {
|
||||
return;
|
||||
}
|
||||
|
||||
logoError.value = null;
|
||||
logoStatus.value = null;
|
||||
|
||||
try {
|
||||
await workspaceStore.uploadWorkspaceLogo(workspace.id, result.file);
|
||||
logoStatus.value = t('workspaceSettings.logo.saved');
|
||||
isLogoDialogOpen.value = false;
|
||||
} catch (error) {
|
||||
console.error('Failed to update workspace logo:', error);
|
||||
logoError.value = t('workspaceSettings.errors.logoUploadFailed');
|
||||
}
|
||||
}
|
||||
|
||||
async function submitInvite() {
|
||||
if (!inviteForm.email.trim() || !inviteForm.role) {
|
||||
return;
|
||||
@@ -133,31 +215,93 @@
|
||||
>
|
||||
<article class="settings-card">
|
||||
<div class="section-copy">
|
||||
<span class="section-kicker">{{ t('workspaceSettings.general.summaryTitle') }}</span>
|
||||
<p>{{ t('workspaceSettings.general.summaryDescription') }}</p>
|
||||
<span class="section-kicker">{{ t('workspaceSettings.general.detailsTitle') }}</span>
|
||||
<p>{{ t('workspaceSettings.general.detailsDescription') }}</p>
|
||||
</div>
|
||||
|
||||
<dl
|
||||
v-if="workspaceStore.activeWorkspace"
|
||||
class="summary-grid"
|
||||
<div
|
||||
v-if="settingsError"
|
||||
class="page-message error"
|
||||
>
|
||||
<div>
|
||||
<dt>{{ t('workspaceSettings.summary.name') }}</dt>
|
||||
<dd>{{ workspaceStore.activeWorkspace.name }}</dd>
|
||||
{{ settingsError }}
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('workspaceSettings.summary.slug') }}</dt>
|
||||
<dd>{{ workspaceStore.activeWorkspace.slug }}</dd>
|
||||
|
||||
<div
|
||||
v-if="settingsStatus"
|
||||
class="page-message success"
|
||||
>
|
||||
{{ settingsStatus }}
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('workspaceSettings.summary.timeZone') }}</dt>
|
||||
<dd>{{ workspaceStore.activeWorkspace.timeZone }}</dd>
|
||||
|
||||
<form
|
||||
v-if="workspaceStore.activeWorkspace"
|
||||
class="form-stack"
|
||||
@submit.prevent="submitWorkspaceSettings"
|
||||
>
|
||||
<div class="logo-picker-card">
|
||||
<AppAvatar
|
||||
:name="settingsForm.name || workspaceStore.activeWorkspace.name"
|
||||
:src="workspaceStore.activeWorkspace.logoUrl"
|
||||
size="lg"
|
||||
/>
|
||||
<div class="logo-picker-copy">
|
||||
<strong>{{ t('workspaceSettings.logo.title') }}</strong>
|
||||
<small>{{ t('workspaceSettings.logo.description') }}</small>
|
||||
<small
|
||||
v-if="logoError"
|
||||
class="field-error"
|
||||
>
|
||||
{{ logoError }}
|
||||
</small>
|
||||
<small
|
||||
v-if="logoStatus"
|
||||
class="field-success"
|
||||
>
|
||||
{{ logoStatus }}
|
||||
</small>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ t('workspaceSettings.summary.created') }}</dt>
|
||||
<dd>{{ formatDate(workspaceStore.activeWorkspace.createdAt) }}</dd>
|
||||
<button
|
||||
class="secondary-button"
|
||||
type="button"
|
||||
:disabled="workspaceStore.isUploadingLogo"
|
||||
@click="isLogoDialogOpen = true"
|
||||
>
|
||||
{{ workspaceStore.isUploadingLogo ? t('common.saving') : t('workspaceSettings.logo.changeAction') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label class="field">
|
||||
<span>{{ t('workspaceSettings.fields.name') }}</span>
|
||||
<input
|
||||
v-model="settingsForm.name"
|
||||
type="text"
|
||||
:disabled="workspaceStore.isUpdating"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>{{ t('workspaceSettings.fields.timeZone') }}</span>
|
||||
<TimeZoneSelect
|
||||
v-model="settingsForm.timeZone"
|
||||
:disabled="workspaceStore.isUpdating"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
class="primary-button"
|
||||
type="submit"
|
||||
:disabled="workspaceStore.isUpdating || !isSettingsDirty"
|
||||
>
|
||||
{{ workspaceStore.isUpdating ? t('common.saving') : t('workspaceSettings.general.saveAction') }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="empty-state"
|
||||
>
|
||||
{{ t('workspaceSettings.noWorkspaceSelected') }}
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
@@ -366,6 +510,16 @@
|
||||
</router-link>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<ImageCropperDialog
|
||||
v-model="isLogoDialogOpen"
|
||||
:title="t('workspaceSettings.logo.cropperTitle')"
|
||||
:confirm-label="t('workspaceSettings.logo.saveAction')"
|
||||
:upload-label="t('workspaceSettings.logo.chooseAction')"
|
||||
:initial-url="workspaceStore.activeWorkspace?.logoUrl"
|
||||
:is-saving="workspaceStore.isUploadingLogo"
|
||||
@save="saveWorkspaceLogo"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -426,7 +580,6 @@
|
||||
}
|
||||
|
||||
.section-copy h1,
|
||||
.summary-grid dd,
|
||||
.invite-row strong,
|
||||
.connector-copy strong,
|
||||
.connector-status,
|
||||
@@ -440,7 +593,6 @@
|
||||
}
|
||||
|
||||
.section-copy p,
|
||||
.summary-grid dt,
|
||||
.invite-row span,
|
||||
.invite-row small,
|
||||
.empty-state,
|
||||
@@ -452,22 +604,32 @@
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.summary-grid {
|
||||
@apply grid gap-4 sm:grid-cols-2;
|
||||
}
|
||||
|
||||
.summary-grid div {
|
||||
@apply rounded-[1rem] border p-4;
|
||||
background: #f8fafc;
|
||||
.logo-picker-card {
|
||||
@apply flex flex-col gap-4 rounded-[1rem] border p-4 sm:flex-row sm:items-center;
|
||||
background: #fffaf2;
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
.summary-grid dt {
|
||||
@apply text-xs font-bold uppercase tracking-[0.16em];
|
||||
.logo-picker-copy {
|
||||
@apply flex min-w-0 flex-1 flex-col gap-1;
|
||||
}
|
||||
|
||||
.summary-grid dd {
|
||||
@apply mt-2 text-base font-semibold;
|
||||
.logo-picker-copy strong {
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.logo-picker-copy small,
|
||||
.field-error,
|
||||
.field-success {
|
||||
@apply text-sm leading-6;
|
||||
}
|
||||
|
||||
.field-error {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.field-success {
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.form-stack {
|
||||
@@ -498,6 +660,18 @@
|
||||
color: #fffaf2;
|
||||
}
|
||||
|
||||
.secondary-button {
|
||||
@apply inline-flex items-center justify-center rounded-full px-4 py-2 text-sm font-semibold;
|
||||
background: rgba(23, 32, 51, 0.08);
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.primary-button:disabled,
|
||||
.secondary-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.56;
|
||||
}
|
||||
|
||||
.invite-list,
|
||||
.connector-list,
|
||||
.workflow-rule-list,
|
||||
|
||||
@@ -35,7 +35,8 @@
|
||||
"website": "Website",
|
||||
"common": {
|
||||
"cancel": "Cancel",
|
||||
"creating": "Creating..."
|
||||
"creating": "Creating...",
|
||||
"saving": "Saving..."
|
||||
},
|
||||
"workspaceSelector": {
|
||||
"createAction": "Add workspace"
|
||||
@@ -334,12 +335,13 @@
|
||||
"errors": {
|
||||
"required": "All workspace fields are required.",
|
||||
"createFailed": "The workspace could not be created.",
|
||||
"updateFailed": "The workspace settings could not be saved.",
|
||||
"logoUploadFailed": "The workspace logo could not be saved.",
|
||||
"inviteRequired": "Email and role are required to invite a member.",
|
||||
"inviteFailed": "The workspace invite could not be created."
|
||||
},
|
||||
"fields": {
|
||||
"name": "Workspace name",
|
||||
"slug": "Workspace slug",
|
||||
"timeZone": "Time zone",
|
||||
"memberEmail": "Member email",
|
||||
"memberRole": "Role"
|
||||
@@ -353,9 +355,7 @@
|
||||
},
|
||||
"summary": {
|
||||
"name": "Name",
|
||||
"slug": "Slug",
|
||||
"timeZone": "Time zone",
|
||||
"created": "Created"
|
||||
"timeZone": "Time zone"
|
||||
},
|
||||
"tabs": {
|
||||
"general": "General",
|
||||
@@ -382,8 +382,19 @@
|
||||
}
|
||||
},
|
||||
"general": {
|
||||
"summaryTitle": "Workspace summary",
|
||||
"summaryDescription": "Reference details for the workspace currently in context."
|
||||
"detailsTitle": "Workspace details",
|
||||
"detailsDescription": "Update the workspace name and default time zone used across schedules and workspace views.",
|
||||
"saveAction": "Save workspace",
|
||||
"saved": "Workspace settings saved."
|
||||
},
|
||||
"logo": {
|
||||
"title": "Workspace logo",
|
||||
"description": "Use a local file or remote image, then crop it for the workspace.",
|
||||
"changeAction": "Change image",
|
||||
"cropperTitle": "Update workspace logo",
|
||||
"saveAction": "Save logo",
|
||||
"chooseAction": "Choose logo",
|
||||
"saved": "Workspace logo saved."
|
||||
},
|
||||
"approvals": {
|
||||
"flowTitle": "Approval flow",
|
||||
|
||||
@@ -35,7 +35,8 @@
|
||||
"website": "Site web",
|
||||
"common": {
|
||||
"cancel": "Annuler",
|
||||
"creating": "Création..."
|
||||
"creating": "Création...",
|
||||
"saving": "Enregistrement..."
|
||||
},
|
||||
"workspaceSelector": {
|
||||
"createAction": "Ajouter un espace"
|
||||
@@ -334,12 +335,13 @@
|
||||
"errors": {
|
||||
"required": "Tous les champs de l'espace sont requis.",
|
||||
"createFailed": "L'espace n'a pas pu être créé.",
|
||||
"updateFailed": "Les paramètres de l'espace n'ont pas pu être enregistrés.",
|
||||
"logoUploadFailed": "Le logo de l'espace n'a pas pu être enregistré.",
|
||||
"inviteRequired": "L'email et le rôle sont requis pour inviter un membre.",
|
||||
"inviteFailed": "L'invitation de l'espace n'a pas pu être créée."
|
||||
},
|
||||
"fields": {
|
||||
"name": "Nom de l'espace",
|
||||
"slug": "Slug de l'espace",
|
||||
"timeZone": "Fuseau horaire",
|
||||
"memberEmail": "Email du membre",
|
||||
"memberRole": "Rôle"
|
||||
@@ -353,9 +355,7 @@
|
||||
},
|
||||
"summary": {
|
||||
"name": "Nom",
|
||||
"slug": "Slug",
|
||||
"timeZone": "Fuseau horaire",
|
||||
"created": "Créé"
|
||||
"timeZone": "Fuseau horaire"
|
||||
},
|
||||
"tabs": {
|
||||
"general": "Général",
|
||||
@@ -382,8 +382,19 @@
|
||||
}
|
||||
},
|
||||
"general": {
|
||||
"summaryTitle": "Résumé de l'espace",
|
||||
"summaryDescription": "Détails de référence pour l'espace actuellement en contexte."
|
||||
"detailsTitle": "Détails de l'espace",
|
||||
"detailsDescription": "Mettez à jour le nom de l'espace et le fuseau horaire par défaut utilisés dans les calendriers et les vues de l'espace.",
|
||||
"saveAction": "Enregistrer l'espace",
|
||||
"saved": "Paramètres de l'espace enregistrés."
|
||||
},
|
||||
"logo": {
|
||||
"title": "Logo de l'espace",
|
||||
"description": "Utilisez un fichier local ou une image distante, puis recadrez-la pour l'espace.",
|
||||
"changeAction": "Changer l'image",
|
||||
"cropperTitle": "Mettre à jour le logo de l'espace",
|
||||
"saveAction": "Enregistrer le logo",
|
||||
"chooseAction": "Choisir un logo",
|
||||
"saved": "Logo de l'espace enregistré."
|
||||
},
|
||||
"approvals": {
|
||||
"flowTitle": "Flux d'approbation",
|
||||
|
||||
3799
shared/openapi/openapi.json
Normal file
3799
shared/openapi/openapi.json
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user