feat: add google drive dam foundation

This commit is contained in:
2026-05-08 11:36:30 -04:00
parent 2eb54b9228
commit 0fbb30bb4f
28 changed files with 3622 additions and 26 deletions

View File

@@ -0,0 +1,41 @@
import { ref } from 'vue';
import { defineStore } from 'pinia';
import { useClient } from '@/plugins/api.js';
export const useMediaLibraryStore = defineStore('media-library', () => {
const client = useClient();
const dam = ref(null);
const isLoading = ref(false);
const error = ref(null);
async function fetchWorkspaceDam(workspaceId) {
if (!workspaceId) {
dam.value = null;
return null;
}
isLoading.value = true;
error.value = null;
try {
const response = await client.get(`/api/workspaces/${workspaceId}/dam`);
dam.value = response.data ?? null;
return dam.value;
} catch (fetchError) {
console.error('Failed to load workspace DAM:', fetchError);
dam.value = null;
error.value = 'Failed to load the media library.';
return null;
} finally {
isLoading.value = false;
}
}
return {
dam,
isLoading,
error,
fetchWorkspaceDam,
};
});

View File

@@ -1,25 +1,35 @@
<script setup>
import { computed, onMounted, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMediaLibraryStore } from '@/features/content/stores/mediaLibraryStore.js';
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
import {
mdiCheckCircleOutline,
mdiCloudSyncOutline,
mdiFolderGoogleDrive,
mdiImageMultipleOutline,
mdiVideoOutline,
} from '@mdi/js';
const { t } = useI18n();
const mediaLibraryStore = useMediaLibraryStore();
const workspaceStore = useWorkspaceStore();
const mediaTypes = [
{ label: t('mediaLibrary.mediaTypes.images'), icon: mdiImageMultipleOutline },
{ label: t('mediaLibrary.mediaTypes.videos'), icon: mdiVideoOutline },
];
const activeWorkspaceId = computed(() => workspaceStore.activeWorkspaceId);
const dam = computed(() => mediaLibraryStore.dam);
const assets = computed(() => dam.value?.assets ?? []);
const folderPath = computed(() => dam.value?.folder?.path ?? t('mediaLibrary.notConfigured'));
const workflowSteps = [
t('mediaLibrary.workflow.connectDrive'),
t('mediaLibrary.workflow.syncAssets'),
t('mediaLibrary.workflow.organizeLibrary'),
];
async function loadDam() {
await mediaLibraryStore.fetchWorkspaceDam(activeWorkspaceId.value);
}
onMounted(loadDam);
watch(activeWorkspaceId, loadDam);
</script>
<template>
@@ -35,26 +45,40 @@
<div class="hero-card-icon">
<v-icon :icon="mdiFolderGoogleDrive" />
</div>
<strong>{{ t('mediaLibrary.syncCard.title') }}</strong>
<span>{{ t('mediaLibrary.syncCard.description') }}</span>
<strong>{{ dam?.backingStore?.isConfigured ? dam.backingStore.rootFolderName : t('mediaLibrary.syncCard.title') }}</strong>
<span>{{ dam?.backingStore?.isConfigured ? folderPath : t('mediaLibrary.syncCard.description') }}</span>
</div>
</div>
<div
v-if="mediaLibraryStore.isLoading"
class="page-message"
>
{{ t('mediaLibrary.loading') }}
</div>
<div
v-else-if="mediaLibraryStore.error"
class="page-message error"
>
{{ mediaLibraryStore.error }}
</div>
<div class="content-grid">
<article class="panel">
<div class="panel-header">
<strong>{{ t('mediaLibrary.mediaTypesTitle') }}</strong>
<span>{{ t('mediaLibrary.mediaTypesDescription') }}</span>
<strong>{{ t('mediaLibrary.damRootTitle') }}</strong>
<span>{{ dam?.backingStore?.isConfigured ? dam.backingStore.rootFolderUrl : t('mediaLibrary.notConfiguredDescription') }}</span>
</div>
<div class="media-type-list">
<div
v-for="type in mediaTypes"
:key="type.label"
class="media-type-item"
>
<v-icon :icon="type.icon" />
<span>{{ type.label }}</span>
<div class="media-type-item">
<v-icon :icon="mdiFolderGoogleDrive" />
<span>{{ folderPath }}</span>
</div>
<div class="media-type-item">
<v-icon :icon="mdiCheckCircleOutline" />
<span>{{ t('mediaLibrary.workspaceSlug', { slug: dam?.workspaceSlug ?? '-' }) }}</span>
</div>
</div>
</article>
@@ -87,8 +111,40 @@
<v-icon :icon="mdiCloudSyncOutline" />
<span>{{ t('mediaLibrary.statusLabel') }}</span>
</div>
<strong>{{ t('mediaLibrary.pendingTitle') }}</strong>
<p>{{ t('mediaLibrary.pendingDescription') }}</p>
<strong>{{ dam?.backingStore?.isConfigured ? t('mediaLibrary.configuredTitle') : t('mediaLibrary.pendingTitle') }}</strong>
<p>{{ dam?.backingStore?.isConfigured ? t('mediaLibrary.configuredDescription') : t('mediaLibrary.pendingDescription') }}</p>
</div>
</article>
<article class="panel">
<div class="panel-header">
<strong>{{ t('mediaLibrary.assetsTitle') }}</strong>
<span>{{ t('mediaLibrary.assetsDescription') }}</span>
</div>
<div
v-if="!assets.length"
class="empty-state"
>
{{ t('mediaLibrary.emptyAssets') }}
</div>
<div
v-for="asset in assets"
:key="asset.id"
class="asset-row"
>
<div>
<strong>{{ asset.displayName }}</strong>
<span>{{ asset.assetType }} · {{ asset.googleDriveWorkspaceFolderPath || folderPath }}</span>
</div>
<a
v-if="asset.googleDriveLink"
:href="asset.googleDriveLink"
target="_blank"
rel="noreferrer"
>
{{ t('mediaLibrary.openDrive') }}
</a>
</div>
</article>
</section>
@@ -189,7 +245,8 @@
}
.media-type-list,
.workflow-list {
.workflow-list,
.asset-row-list {
@apply flex flex-col gap-3;
}
@@ -204,6 +261,23 @@
color: #0f766e;
}
.asset-row {
@apply flex items-center justify-between gap-4 rounded-[1.1rem] border px-4 py-3;
border-color: rgba(23, 32, 51, 0.08);
background: rgba(248, 250, 252, 0.9);
}
.asset-row div {
@apply flex min-w-0 flex-col gap-1;
}
.asset-row span,
.asset-row a,
.empty-state {
@apply text-sm;
color: #526178;
}
.status-panel {
background: linear-gradient(135deg, rgba(255, 247, 237, 0.95), rgba(255, 255, 255, 0.98));
}

View File

@@ -27,6 +27,7 @@ export const useOrganizationStore = defineStore('organization', () => {
const isLoadingMembershipTiers = ref(false);
const isSaving = ref(false);
const isUpdatingMembershipTier = ref(false);
const isSavingGoogleDriveDam = ref(false);
const isAddingMember = ref(false);
const isUploadingLogo = ref(false);
const error = ref(null);
@@ -260,6 +261,45 @@ export const useOrganizationStore = defineStore('organization', () => {
}
}
async function updateGoogleDriveDam(organizationId, payload) {
if (!authStore.isAuthenticated || !organizationId) {
throw new Error('You must be authenticated to update organization connectors.');
}
isSavingGoogleDriveDam.value = true;
error.value = null;
try {
const response = await client.put(`/api/organizations/${organizationId}/google-drive-dam`, payload);
const googleDriveDam = response.data;
const currentDetails = detailsById.value[organizationId];
if (currentDetails) {
detailsById.value = {
...detailsById.value,
[organizationId]: {
...currentDetails,
googleDriveDam,
},
};
}
organizations.value = organizations.value.map(organization =>
organization.id === organizationId
? { ...organization, googleDriveDam }
: organization
);
return googleDriveDam;
} catch (updateError) {
console.error('Failed to update Google Drive DAM configuration:', updateError);
error.value = 'Failed to update Google Drive DAM configuration.';
throw updateError;
} finally {
isSavingGoogleDriveDam.value = false;
}
}
async function addMember(organizationId, payload) {
if (!authStore.isAuthenticated || !organizationId) {
throw new Error('You must be authenticated to add an organization member.');
@@ -371,6 +411,7 @@ export const useOrganizationStore = defineStore('organization', () => {
isLoadingMembershipTiers,
isSaving,
isUpdatingMembershipTier,
isSavingGoogleDriveDam,
isAddingMember,
isUploadingLogo,
error,
@@ -383,6 +424,7 @@ export const useOrganizationStore = defineStore('organization', () => {
createOrganization,
updateOrganization,
updateMembershipTier,
updateGoogleDriveDam,
addMember,
uploadLogo,
};

View File

@@ -38,6 +38,12 @@
const membershipTierForm = reactive({
membershipTierId: null,
});
const googleDriveDamForm = reactive({
isEnabled: false,
rootFolderId: '',
rootFolderName: '',
rootFolderUrl: '',
});
const memberRoleOptions = ['Member', 'Admin', 'BillingManager', 'ConnectorManager'];
const organizationId = computed(() => route.params.organizationId);
@@ -195,6 +201,34 @@
}
}
async function submitGoogleDriveDam() {
settingsError.value = null;
settingsStatus.value = null;
if (
googleDriveDamForm.isEnabled &&
(!googleDriveDamForm.rootFolderId.trim() ||
!googleDriveDamForm.rootFolderName.trim() ||
!googleDriveDamForm.rootFolderUrl.trim())
) {
settingsError.value = t('organizationSettings.sections.connections.googleDrive.required');
return;
}
try {
await organizationStore.updateGoogleDriveDam(organizationId.value, {
isEnabled: googleDriveDamForm.isEnabled,
rootFolderId: googleDriveDamForm.rootFolderId.trim() || null,
rootFolderName: googleDriveDamForm.rootFolderName.trim() || null,
rootFolderUrl: googleDriveDamForm.rootFolderUrl.trim() || null,
});
settingsStatus.value = t('organizationSettings.sections.connections.googleDrive.saved');
} catch (error) {
console.error('Failed to save Google Drive DAM configuration:', error);
settingsError.value = t('organizationSettings.sections.connections.googleDrive.saveFailed');
}
}
function formatTierSummary(tier) {
const price = tier.isCustom || tier.monthlyPriceCents === null || tier.monthlyPriceCents === undefined
? t('organizationSettings.tiers.customPrice')
@@ -232,6 +266,10 @@
currentOrganization => {
profileForm.name = currentOrganization?.name ?? '';
membershipTierForm.membershipTierId = currentOrganization?.membershipTier?.id ?? null;
googleDriveDamForm.isEnabled = Boolean(currentOrganization?.googleDriveDam?.isEnabled);
googleDriveDamForm.rootFolderId = currentOrganization?.googleDriveDam?.rootFolderId ?? '';
googleDriveDamForm.rootFolderName = currentOrganization?.googleDriveDam?.rootFolderName ?? '';
googleDriveDamForm.rootFolderUrl = currentOrganization?.googleDriveDam?.rootFolderUrl ?? '';
},
{ immediate: true }
);
@@ -457,10 +495,55 @@
<div
v-else-if="activeSection.key === 'connections'"
class="placeholder-panel"
class="settings-form"
>
<strong>{{ t('organizationSettings.sections.connections.placeholderTitle') }}</strong>
<span>{{ t('organizationSettings.sections.connections.placeholderText') }}</span>
<div class="placeholder-panel">
<strong>{{ t('organizationSettings.sections.connections.googleDrive.title') }}</strong>
<span>{{ t('organizationSettings.sections.connections.googleDrive.description') }}</span>
</div>
<v-form
class="settings-form"
@submit.prevent="submitGoogleDriveDam"
>
<v-switch
v-model="googleDriveDamForm.isEnabled"
:label="t('organizationSettings.sections.connections.googleDrive.enabled')"
color="primary"
hide-details
/>
<v-text-field
v-model="googleDriveDamForm.rootFolderName"
:label="t('organizationSettings.sections.connections.googleDrive.rootFolderName')"
:disabled="!googleDriveDamForm.isEnabled || organizationStore.isSavingGoogleDriveDam"
maxlength="256"
variant="outlined"
hide-details
/>
<v-text-field
v-model="googleDriveDamForm.rootFolderId"
:label="t('organizationSettings.sections.connections.googleDrive.rootFolderId')"
:disabled="!googleDriveDamForm.isEnabled || organizationStore.isSavingGoogleDriveDam"
maxlength="256"
variant="outlined"
hide-details
/>
<v-text-field
v-model="googleDriveDamForm.rootFolderUrl"
:label="t('organizationSettings.sections.connections.googleDrive.rootFolderUrl')"
:disabled="!googleDriveDamForm.isEnabled || organizationStore.isSavingGoogleDriveDam"
maxlength="2048"
variant="outlined"
hide-details
/>
<v-btn
color="primary"
type="submit"
:loading="organizationStore.isSavingGoogleDriveDam"
>
{{ t('organizationSettings.sections.connections.googleDrive.save') }}
</v-btn>
</v-form>
</div>
<div

View File

@@ -20,6 +20,7 @@
const activeTab = ref('general');
const settingsForm = reactive({
name: '',
slug: '',
timeZone: '',
approvalMode: 'Required',
schedulePostsAutomaticallyOnApproval: false,
@@ -56,6 +57,7 @@
const workspaceApprovalSteps = normalizeApprovalSteps(workspace.approvalSteps ?? []);
return settingsForm.name.trim() !== workspace.name ||
settingsForm.slug.trim() !== workspace.slug ||
settingsForm.timeZone.trim() !== workspace.timeZone ||
settingsForm.approvalMode !== (workspace.approvalMode ?? 'Required') ||
settingsForm.schedulePostsAutomaticallyOnApproval !== Boolean(workspace.schedulePostsAutomaticallyOnApproval) ||
@@ -196,6 +198,7 @@
() => workspaceStore.activeWorkspace,
workspace => {
settingsForm.name = workspace?.name ?? '';
settingsForm.slug = workspace?.slug ?? '';
settingsForm.timeZone = workspace?.timeZone ?? '';
settingsForm.approvalMode = workspace?.approvalMode ?? 'Required';
settingsForm.schedulePostsAutomaticallyOnApproval = Boolean(workspace?.schedulePostsAutomaticallyOnApproval);
@@ -237,9 +240,10 @@
settingsStatus.value = null;
const name = settingsForm.name.trim();
const slug = settingsForm.slug.trim();
const timeZone = settingsForm.timeZone.trim();
if (!name || !timeZone) {
if (!name || !slug || !timeZone) {
settingsError.value = t('workspaceSettings.errors.required');
return;
}
@@ -254,6 +258,7 @@
try {
await workspaceStore.updateWorkspace(workspace.id, {
name,
slug,
timeZone,
approvalMode: settingsForm.approvalMode,
schedulePostsAutomaticallyOnApproval: settingsForm.schedulePostsAutomaticallyOnApproval,
@@ -504,6 +509,15 @@
hide-details
/>
<v-text-field
v-model="settingsForm.slug"
:label="t('workspaceSettings.fields.slug')"
:hint="t('workspaceSettings.fields.slugHint')"
:disabled="workspaceStore.isUpdating"
variant="outlined"
persistent-hint
/>
<label class="field">
<span>{{ t('workspaceSettings.fields.timeZone') }}</span>
<TimeZoneSelect

View File

@@ -504,7 +504,19 @@
"title": "Connections",
"description": "Organization-level connectors and data mappings.",
"placeholderTitle": "No organization connections configured",
"placeholderText": "Connector authorization flows are intentionally out of scope for this UI shell."
"placeholderText": "Connector authorization flows are intentionally out of scope for this UI shell.",
"googleDrive": {
"title": "Google Drive DAM",
"description": "Use an organization Drive folder as the media library backing store. Workspace media is organized by workspace slug.",
"enabled": "Use Google Drive as the DAM backing store",
"rootFolderName": "Root folder name",
"rootFolderId": "Root folder ID",
"rootFolderUrl": "Root folder URL",
"save": "Save Google Drive DAM",
"saved": "Google Drive DAM configuration saved.",
"required": "Root folder name, ID, and URL are required when Google Drive DAM is enabled.",
"saveFailed": "Google Drive DAM configuration could not be saved."
}
},
"workspaces": {
"title": "Workspaces",
@@ -1120,6 +1132,8 @@
},
"fields": {
"name": "Workspace name",
"slug": "Workspace slug",
"slugHint": "Used as the folder name under the organization DAM root.",
"timeZone": "Time zone",
"memberEmail": "Member email",
"memberRole": "Role"
@@ -1295,6 +1309,17 @@
"organizeLibrary": "Review, tag, and reuse media from one workspace-level place."
},
"statusLabel": "Status",
"loading": "Loading media library...",
"notConfigured": "Google Drive DAM not configured",
"notConfiguredDescription": "Configure the organization Google Drive DAM connection before this workspace can resolve a backing folder.",
"damRootTitle": "DAM folder",
"workspaceSlug": "Workspace slug: {slug}",
"configuredTitle": "Google Drive backing store configured",
"configuredDescription": "Socialize is using the organization Drive root and this workspace slug to resolve media library metadata.",
"assetsTitle": "Workspace assets",
"assetsDescription": "Google Drive assets currently linked to content in this workspace.",
"emptyAssets": "No workspace assets have been linked yet.",
"openDrive": "Open in Drive",
"pendingTitle": "Management UI pending",
"pendingDescription": "The navigation and page entry point are in place. Next step is wiring actual Drive sync, listing, filters, and asset actions."
},

View File

@@ -504,7 +504,19 @@
"title": "Connexions",
"description": "Connecteurs et regles de donnees au niveau de l'organisation.",
"placeholderTitle": "Aucune connexion d'organisation configuree",
"placeholderText": "Les flux d'autorisation des connecteurs sont volontairement hors portee de cette interface."
"placeholderText": "Les flux d'autorisation des connecteurs sont volontairement hors portee de cette interface.",
"googleDrive": {
"title": "DAM Google Drive",
"description": "Utilisez un dossier Drive de l'organisation comme stockage de la bibliotheque media. Les medias sont organises par slug d'espace.",
"enabled": "Utiliser Google Drive comme stockage DAM",
"rootFolderName": "Nom du dossier racine",
"rootFolderId": "ID du dossier racine",
"rootFolderUrl": "URL du dossier racine",
"save": "Enregistrer le DAM Google Drive",
"saved": "Configuration DAM Google Drive enregistree.",
"required": "Le nom, l'ID et l'URL du dossier racine sont requis quand le DAM Google Drive est active.",
"saveFailed": "La configuration DAM Google Drive n'a pas pu etre enregistree."
}
},
"workspaces": {
"title": "Espaces",
@@ -1120,6 +1132,8 @@
},
"fields": {
"name": "Nom de l'espace",
"slug": "Slug de l'espace",
"slugHint": "Utilise comme nom de dossier sous la racine DAM de l'organisation.",
"timeZone": "Fuseau horaire",
"memberEmail": "Email du membre",
"memberRole": "Rôle"
@@ -1295,6 +1309,17 @@
"organizeLibrary": "Reviser, etiqueter et reutiliser les medias depuis un seul endroit au niveau de l'espace."
},
"statusLabel": "Statut",
"loading": "Chargement de la bibliotheque media...",
"notConfigured": "DAM Google Drive non configure",
"notConfiguredDescription": "Configurez la connexion DAM Google Drive de l'organisation avant de resoudre le dossier de cet espace.",
"damRootTitle": "Dossier DAM",
"workspaceSlug": "Slug de l'espace : {slug}",
"configuredTitle": "Stockage Google Drive configure",
"configuredDescription": "Socialize utilise la racine Drive de l'organisation et le slug de cet espace pour resoudre les metadonnees media.",
"assetsTitle": "Ressources de l'espace",
"assetsDescription": "Ressources Google Drive actuellement liees au contenu de cet espace.",
"emptyAssets": "Aucune ressource d'espace n'a encore ete liee.",
"openDrive": "Ouvrir dans Drive",
"pendingTitle": "Interface de gestion en attente",
"pendingDescription": "L'entree de navigation et la page sont en place. La prochaine etape est de brancher la vraie synchro Drive, le listing, les filtres et les actions sur les ressources."
},