feat: add content media dam uploads
All checks were successful
deploy-socialize / image (push) Successful in 1m15s
deploy-socialize / deploy (push) Successful in 19s

This commit is contained in:
2026-05-09 12:42:20 -04:00
parent ca68132546
commit 5a798d6650
8 changed files with 801 additions and 31 deletions

View File

@@ -7,9 +7,25 @@ internal static class ContentTypes
private const string ImagePng = "image/png";
private const string ImageJpeg = "image/jpeg";
private const string ImageJpg = "image/jpg";
private const string ImageGif = "image/gif";
private const string ImageWebp = "image/webp";
private const string VideoMp4 = "video/mp4";
private const string VideoWebm = "video/webm";
private const string VideoQuickTime = "video/quicktime";
private const string TextHtml = "text/html";
private static readonly HashSet<string> AllowedContentTypes = [ImagePng, ImageJpeg, ImageJpg, TextHtml];
private static readonly HashSet<string> AllowedContentTypes =
[
ImagePng,
ImageJpeg,
ImageJpg,
ImageGif,
ImageWebp,
VideoMp4,
VideoWebm,
VideoQuickTime,
TextHtml,
];
public static bool IsAllowed(
string contentType,
@@ -37,6 +53,32 @@ internal static class ContentTypes
return true;
}
// GIF file signatures: GIF87a and GIF89a
if (buffer[0] == 0x47 && buffer[1] == 0x49 && buffer[2] == 0x46 &&
buffer[3] == 0x38 && (buffer[4] == 0x37 || buffer[4] == 0x39) && buffer[5] == 0x61)
{
return true;
}
// WebP files are RIFF containers with a WEBP marker.
if (buffer[0] == 0x52 && buffer[1] == 0x49 && buffer[2] == 0x46 && buffer[3] == 0x46 &&
buffer[8] == 0x57 && buffer[9] == 0x45 && buffer[10] == 0x42 && buffer[11] == 0x50)
{
return true;
}
// MP4/MOV containers expose an ftyp box near the beginning.
if (buffer[4] == 0x66 && buffer[5] == 0x74 && buffer[6] == 0x79 && buffer[7] == 0x70)
{
return true;
}
// WebM files use the EBML header.
if (buffer[0] == 0x1A && buffer[1] == 0x45 && buffer[2] == 0xDF && buffer[3] == 0xA3)
{
return true;
}
// Check for HTML content by looking for "<!DOCTYPE html>" or "<html>" tags
string content = Encoding.UTF8.GetString(buffer);
return content.Contains("<!DOCTYPE html>", StringComparison.OrdinalIgnoreCase);

View File

@@ -2,10 +2,11 @@ using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Assets.Data;
namespace Socialize.Api.Modules.Assets.Handlers;
internal record GetAssetsRequest(Guid ContentItemId);
internal record GetAssetsRequest(Guid? ContentItemId, Guid? WorkspaceId);
internal record AssetRevisionDto(
Guid Id,
@@ -43,9 +44,13 @@ internal class GetAssetsHandler(
}
public override async Task HandleAsync(GetAssetsRequest request, CancellationToken ct)
{
IQueryable<Asset> query = dbContext.Assets;
if (request.ContentItemId.HasValue)
{
var item = await dbContext.ContentItems
.SingleOrDefaultAsync(candidate => candidate.Id == request.ContentItemId, ct);
.SingleOrDefaultAsync(candidate => candidate.Id == request.ContentItemId.Value, ct);
if (item is null)
{
await SendNotFoundAsync(ct);
@@ -58,9 +63,28 @@ internal class GetAssetsHandler(
return;
}
List<AssetDto> assets = await dbContext.Assets
.Where(asset => asset.ContentItemId == request.ContentItemId)
.OrderBy(asset => asset.DisplayName)
query = query.Where(asset => asset.ContentItemId == request.ContentItemId.Value);
}
else if (request.WorkspaceId.HasValue)
{
if (!await accessScopeService.CanAccessWorkspaceAsync(User, request.WorkspaceId.Value, ct))
{
await SendForbiddenAsync(ct);
return;
}
query = query.Where(asset => asset.WorkspaceId == request.WorkspaceId.Value);
}
else
{
AddError(request => request.WorkspaceId, "A workspace or content item is required.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
List<AssetDto> assets = await query
.OrderByDescending(asset => asset.CreatedAt)
.ThenBy(asset => asset.DisplayName)
.Select(asset => new AssetDto(
asset.Id,
asset.WorkspaceId,

View File

@@ -0,0 +1,266 @@
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Socialize.Api.Data;
using Socialize.Api.Infrastructure.BlobStorage.Contracts;
using Socialize.Api.Infrastructure.Security;
using Socialize.Api.Modules.Assets.Data;
using Socialize.Api.Modules.ContentItems.Contracts;
using Socialize.Api.Modules.ContentItems.Data;
using Socialize.Api.Modules.Notifications.Contracts;
using System.Text.Json;
namespace Socialize.Api.Modules.Assets.Handlers;
internal record UploadAssetRequest(
Guid WorkspaceId,
Guid ContentItemId,
string AssetType,
string? DisplayName,
IFormFile File);
internal class UploadAssetRequestValidator
: Validator<UploadAssetRequest>
{
public UploadAssetRequestValidator()
{
RuleFor(x => x.WorkspaceId).NotEmpty();
RuleFor(x => x.ContentItemId).NotEmpty();
RuleFor(x => x.AssetType).NotEmpty().MaximumLength(64);
RuleFor(x => x.DisplayName).MaximumLength(256);
RuleFor(x => x.File).NotNull();
}
}
internal class UploadAssetHandler(
AppDbContext dbContext,
AccessScopeService accessScopeService,
IBlobStorage blobStorage,
IContentItemActivityWriter activityWriter,
INotificationEventWriter notificationEventWriter)
: Endpoint<UploadAssetRequest, AssetDto>
{
public override void Configure()
{
Post("/api/assets/upload");
Options(o => o.WithTags("Assets"));
AllowFileUploads();
}
public override async Task HandleAsync(UploadAssetRequest request, CancellationToken ct)
{
ContentItem? contentItem = await dbContext.ContentItems
.SingleOrDefaultAsync(
candidate => candidate.Id == request.ContentItemId && candidate.WorkspaceId == request.WorkspaceId,
ct);
if (contentItem is null)
{
AddError(request => request.ContentItemId, "The selected content item does not exist in the active workspace.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
if (!await accessScopeService.CanContributeToCampaignAsync(User, contentItem.WorkspaceId, contentItem.ClientId, contentItem.CampaignId, ct))
{
await SendForbiddenAsync(ct);
return;
}
if (request.File.Length <= 0)
{
AddError(request => request.File, "The media file must not be empty.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
string normalizedContentType = request.File.ContentType.Trim();
if (!IsSupportedMediaContentType(normalizedContentType))
{
AddError(request => request.File, "The media file must be an image or video.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
Guid assetId = Guid.NewGuid();
string fileName = NormalizeFileName(request.File.FileName, normalizedContentType);
string displayName = string.IsNullOrWhiteSpace(request.DisplayName)
? Path.GetFileNameWithoutExtension(fileName)
: request.DisplayName.Trim();
string blobName =
$"{contentItem.WorkspaceId}/{SubDirectoryNames.Contents}/{contentItem.Id}/assets/{assetId}/{fileName}";
string blobUrl;
try
{
blobUrl = await blobStorage.UploadFileAsync(
ContainerNames.Workspaces,
blobName,
request.File.OpenReadStream(),
normalizedContentType,
ct);
}
catch (InvalidOperationException)
{
AddError(request => request.File, "The media file is invalid or unsupported.");
await SendErrorsAsync(StatusCodes.Status400BadRequest, ct);
return;
}
Asset asset = new()
{
Id = assetId,
WorkspaceId = request.WorkspaceId,
ContentItemId = request.ContentItemId,
AssetType = NormalizeAssetType(request.AssetType, normalizedContentType),
SourceType = "Uploaded",
DisplayName = displayName,
GoogleDriveFileId = null,
GoogleDriveLink = null,
PreviewUrl = blobUrl,
CurrentRevisionNumber = 1,
CreatedAt = DateTimeOffset.UtcNow,
};
AssetRevision revision = new()
{
Id = Guid.NewGuid(),
AssetId = asset.Id,
RevisionNumber = 1,
SourceReference = blobName,
PreviewUrl = blobUrl,
CreatedByUserId = User.GetUserId(),
CreatedAt = DateTimeOffset.UtcNow,
};
dbContext.Assets.Add(asset);
dbContext.AssetRevisions.Add(revision);
await dbContext.SaveChangesAsync(ct);
await activityWriter.WriteAsync(
new ContentItemActivityWriteModel(
asset.WorkspaceId,
asset.ContentItemId,
"asset.uploaded",
"Asset",
asset.Id,
$"Asset {asset.DisplayName} was uploaded to {contentItem.Title}.",
User.GetUserId(),
User.GetEmail(),
JsonSerializer.Serialize(new
{
assetType = asset.AssetType,
sourceType = asset.SourceType,
currentRevisionNumber = asset.CurrentRevisionNumber,
})),
ct);
await notificationEventWriter.WriteAsync(
new NotificationEventWriteModel(
asset.WorkspaceId,
asset.ContentItemId,
"asset.uploaded",
"Asset",
asset.Id,
$"Asset {asset.DisplayName} was uploaded to {contentItem.Title}.",
null,
null,
$$"""{"assetId":"{{asset.Id}}"}"""),
ct);
AssetDto dto = new(
asset.Id,
asset.WorkspaceId,
asset.ContentItemId,
asset.AssetType,
asset.SourceType,
asset.DisplayName,
asset.GoogleDriveFileId,
asset.GoogleDriveLink,
asset.PreviewUrl,
asset.CurrentRevisionNumber,
asset.CreatedAt,
[
new AssetRevisionDto(
revision.Id,
revision.AssetId,
revision.RevisionNumber,
revision.SourceReference,
revision.PreviewUrl,
revision.Notes,
revision.CreatedByUserId,
revision.CreatedAt)
]);
await SendAsync(dto, StatusCodes.Status201Created, ct);
}
private static bool IsSupportedMediaContentType(string contentType)
{
string normalized = contentType.Trim();
return normalized.Equals("image/png", StringComparison.OrdinalIgnoreCase) ||
normalized.Equals("image/jpeg", StringComparison.OrdinalIgnoreCase) ||
normalized.Equals("image/jpg", StringComparison.OrdinalIgnoreCase) ||
normalized.Equals("image/gif", StringComparison.OrdinalIgnoreCase) ||
normalized.Equals("image/webp", StringComparison.OrdinalIgnoreCase) ||
normalized.Equals("video/mp4", StringComparison.OrdinalIgnoreCase) ||
normalized.Equals("video/webm", StringComparison.OrdinalIgnoreCase) ||
normalized.Equals("video/quicktime", StringComparison.OrdinalIgnoreCase);
}
private static string NormalizeAssetType(string assetType, string contentType)
{
string normalized = assetType.Trim();
if (!normalized.Equals("None", StringComparison.OrdinalIgnoreCase))
{
return normalized.Length > 64 ? normalized[..64] : normalized;
}
return contentType.StartsWith("video/", StringComparison.OrdinalIgnoreCase) ? "Video" : "Image";
}
private static string NormalizeFileName(string? fileName, string contentType)
{
string normalized = Path.GetFileName(fileName ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(normalized))
{
normalized = $"asset{DefaultExtension(contentType)}";
}
return normalized.Length > 256 ? normalized[..256] : normalized;
}
private static string DefaultExtension(string contentType)
{
if (contentType.Equals("image/png", StringComparison.OrdinalIgnoreCase))
{
return ".png";
}
if (contentType.Equals("image/gif", StringComparison.OrdinalIgnoreCase))
{
return ".gif";
}
if (contentType.Equals("image/webp", StringComparison.OrdinalIgnoreCase))
{
return ".webp";
}
if (contentType.Equals("video/webm", StringComparison.OrdinalIgnoreCase))
{
return ".webm";
}
if (contentType.Equals("video/quicktime", StringComparison.OrdinalIgnoreCase))
{
return ".mov";
}
if (contentType.StartsWith("video/", StringComparison.OrdinalIgnoreCase))
{
return ".mp4";
}
return ".jpg";
}
}

View File

@@ -26,13 +26,16 @@ The editor should use one shared content body for every selected target channel,
- Keep target copy/title synchronized by default, with per-target opt-out.
- Show title editing only for networks that normally need titles, such as YouTube, Reddit, Website, and newsletters.
- Let each target choose its own media requirement.
- Preserve existing save payloads and backend contracts.
- Let authors upload media from the editor; uploaded media is stored as a DAM asset.
- Let authors attach an existing workspace DAM asset to the active target preview.
- Preserve existing content item save payloads.
## Validation
```bash
cd frontend
npm run build
dotnet build backend/Socialize.slnx
```
## Acceptance Criteria
@@ -52,3 +55,5 @@ npm run build
- [x] Channel copy/title is synchronized by default and can be unsynchronized per target.
- [x] Title input only appears for title-oriented targets.
- [x] Each target can choose no media, image, video, clip, or carousel independently.
- [x] Authors can upload media from a target preview and create a DAM asset.
- [x] Authors can choose an existing workspace DAM asset for a target preview.

View File

@@ -1364,6 +1364,22 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/assets/upload": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
post: operations["SocializeApiModulesAssetsHandlersUploadAssetHandler"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/approvals": {
parameters: {
query?: never;
@@ -2307,6 +2323,16 @@ export interface components {
previewUrl?: string | null;
};
SocializeApiModulesAssetsHandlersGetAssetsRequest: Record<string, never>;
SocializeApiModulesAssetsHandlersUploadAssetRequest: {
/** Format: guid */
workspaceId: string;
/** Format: guid */
contentItemId: string;
assetType: string;
displayName?: string | null;
/** Format: binary */
file: string;
};
SocializeApiModulesApprovalsHandlersApprovalRequestDto: {
/** Format: guid */
id?: string;
@@ -5758,8 +5784,9 @@ export interface operations {
};
SocializeApiModulesAssetsHandlersGetAssetsHandler: {
parameters: {
query: {
contentItemId: string;
query?: {
contentItemId?: string | null;
workspaceId?: string | null;
};
header?: never;
path?: never;
@@ -5785,6 +5812,46 @@ export interface operations {
};
};
};
SocializeApiModulesAssetsHandlersUploadAssetHandler: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"multipart/form-data": components["schemas"]["SocializeApiModulesAssetsHandlersUploadAssetRequest"];
};
};
responses: {
/** @description Success */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["SocializeApiModulesAssetsHandlersAssetDto"];
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["FastEndpointsErrorResponse"];
};
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
SocializeApiModulesApprovalsHandlersGetApprovalsHandler: {
parameters: {
query: {

View File

@@ -11,6 +11,7 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
const revisions = ref([]);
const comments = ref([]);
const approvals = ref([]);
const assets = ref([]);
const notifications = ref([]);
const activity = ref([]);
const isLoading = ref(false);
@@ -20,6 +21,7 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
comment: false,
decision: false,
status: false,
asset: false,
});
function currentItemWorkspaceId() {
@@ -31,6 +33,7 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
revisions.value = [];
comments.value = [];
approvals.value = [];
assets.value = [];
notifications.value = [];
activity.value = [];
error.value = null;
@@ -60,6 +63,7 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
comments.value = commentsResponse.data ?? [];
approvals.value = approvalsResponse.data ?? [];
activity.value = activityResponse.data ?? [];
await fetchAssets({ workspaceId: item.value?.workspaceId });
} catch (fetchError) {
console.error('Failed to load content item detail:', fetchError);
reset();
@@ -168,11 +172,45 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
return activity.value;
}
async function fetchAssets({ contentItemId = null, workspaceId = null } = {}) {
const response = await client.get('/api/assets', {
params: {
contentItemId: contentItemId ?? undefined,
workspaceId: contentItemId ? undefined : (workspaceId ?? currentItemWorkspaceId() ?? undefined),
},
});
assets.value = response.data ?? [];
return assets.value;
}
async function uploadAsset(contentItemId, payload) {
actions.asset = true;
try {
const formData = new FormData();
formData.append('workspaceId', payload.workspaceId ?? currentItemWorkspaceId());
formData.append('contentItemId', contentItemId);
formData.append('assetType', payload.assetType ?? 'Image');
formData.append('displayName', payload.displayName ?? payload.file?.name ?? 'Uploaded media');
formData.append('file', payload.file, payload.file?.name || 'uploaded-media');
const response = await client.post('/api/assets/upload', formData);
if (response.data) {
assets.value = [response.data, ...assets.value.filter(asset => asset.id !== response.data.id)];
await fetchActivity(contentItemId);
}
return response.data;
} finally {
actions.asset = false;
}
}
return {
item,
revisions,
comments,
approvals,
assets,
notifications,
activity,
isLoading,
@@ -185,5 +223,7 @@ export const useContentItemDetailStore = defineStore('content-item-detail', () =
submitDecision,
updateStatus,
fetchActivity,
fetchAssets,
uploadAsset,
};
});

View File

@@ -10,10 +10,12 @@
mdiClose,
mdiFacebook,
mdiInstagram,
mdiImageMultiple,
mdiLinkedin,
mdiMusicNote,
mdiReddit,
mdiTwitter,
mdiUpload,
mdiWeb,
mdiYoutube,
} from '@mdi/js';
@@ -135,6 +137,9 @@
return selectedPlacements.value.find(placement => placement.id === activePlacementId.value)
?? selectedPlacements.value[0];
});
const damAssets = computed(() =>
detailStore.assets.filter(asset => !contentWorkspaceId.value || asset.workspaceId === contentWorkspaceId.value)
);
const sharedPreviewTags = computed(() => parseHashtags(form.hashtags));
const workspaceHashtagFeed = computed(() => {
const selected = new Set(sharedPreviewTags.value.map(tag => normalizeHashtag(tag).toLowerCase()));
@@ -253,6 +258,10 @@
hashtags: form.hashtags,
mediaKind: defaultMediaKind(channel?.network ?? ''),
mediaItems: [],
assetId: '',
assetName: '',
assetPreviewUrl: '',
assetSourceType: '',
};
}
@@ -380,6 +389,103 @@
return 'None';
}
function mediaKindFromAsset(asset) {
const type = (asset?.assetType ?? '').toLowerCase();
const previewUrl = (asset?.previewUrl ?? '').toLowerCase();
if (type.includes('clip')) {
return 'Clip';
}
if (type.includes('video') || /\.(mp4|webm|mov)(\?|$)/.test(previewUrl)) {
return 'Video';
}
if (type.includes('carousel')) {
return 'Carousel';
}
return 'Image';
}
function selectedPlacementAsset(placement) {
if (!placement?.assetId) {
return null;
}
return damAssets.value.find(asset => asset.id === placement.assetId) ?? null;
}
function placementAssetPreviewUrl(placement) {
return placement?.assetPreviewUrl || selectedPlacementAsset(placement)?.previewUrl || '';
}
function isPlacementVideo(placement) {
return ['Video', 'Clip'].includes(mediaKindFromAsset({
assetType: placement?.mediaKind,
previewUrl: placementAssetPreviewUrl(placement),
}));
}
function applyAssetToPlacement(placement, asset) {
if (!placement || !asset) {
return;
}
placement.assetId = asset.id;
placement.assetName = asset.displayName;
placement.assetPreviewUrl = asset.previewUrl ?? '';
placement.assetSourceType = asset.sourceType ?? '';
placement.mediaKind = mediaKindFromAsset(asset);
}
function selectDamAsset(placement, assetId) {
const asset = damAssets.value.find(candidate => candidate.id === assetId);
if (!asset) {
placement.assetId = '';
placement.assetName = '';
placement.assetPreviewUrl = '';
placement.assetSourceType = '';
return;
}
applyAssetToPlacement(placement, asset);
}
async function uploadPlacementMedia(placement, event) {
const [file] = Array.from(event.target.files ?? []);
event.target.value = '';
if (!file || !placement) {
return;
}
let targetContentItemId = contentItemId.value;
if (!targetContentItemId) {
targetContentItemId = await saveContent();
}
if (!targetContentItemId || !contentWorkspaceId.value) {
saveError.message = 'The content needs a workspace and campaign before media can be uploaded.';
return;
}
try {
const asset = await detailStore.uploadAsset(targetContentItemId, {
workspaceId: contentWorkspaceId.value,
assetType: placement.mediaKind === 'None' ? (file.type.startsWith('video/') ? 'Video' : 'Image') : placement.mediaKind,
displayName: file.name,
file,
});
const currentPlacement = form.placements.find(candidate => candidate.id === placement.id) ?? placement;
applyAssetToPlacement(currentPlacement, asset);
persistDraft();
} catch (error) {
saveError.message = 'The media file could not be uploaded to the DAM.';
}
}
function removeHashtag(tagToRemove) {
form.hashtags = parseHashtags(form.hashtags)
.filter(tag => normalizeHashtag(tag).toLowerCase() !== normalizeHashtag(tagToRemove).toLowerCase())
@@ -527,6 +633,10 @@
label: media.label ?? '',
url: media.url ?? '',
})),
assetId: placement.assetId ?? '',
assetName: placement.assetName ?? '',
assetPreviewUrl: placement.assetPreviewUrl ?? '',
assetSourceType: placement.assetSourceType ?? '',
});
}
@@ -573,6 +683,10 @@
hashtags: item.value?.hashtags ?? '',
mediaKind: defaultMediaKind(channel?.network ?? ''),
mediaItems: [],
assetId: '',
assetName: '',
assetPreviewUrl: '',
assetSourceType: '',
};
});
@@ -653,12 +767,12 @@
if (!primaryPublicationMessage.value || !form.campaignId || !form.placements.length) {
saveError.message = 'Post text, campaign, and at least one channel are required.';
return;
return null;
}
if (isCreateMode.value && !operationalClient.value?.id) {
saveError.message = 'This workspace needs an operational account before content can be created.';
return;
return null;
}
const payload = {
@@ -683,10 +797,11 @@
};
clearDraft();
await router.replace({ name: 'content-item-detail', params: { id: created.id } });
return created.id;
} catch (error) {
saveError.message = 'The content item could not be created.';
return null;
}
return;
}
try {
@@ -697,8 +812,10 @@
persistDraft();
form.changeSummary = '';
return contentItemId.value;
} catch (error) {
saveError.message = 'The content revision could not be saved.';
return null;
}
}
@@ -932,6 +1049,7 @@
calendarStore.fetchSources(workspaceId),
calendarStore.fetchEvents({ workspaceId, startDate, endDate }),
workspaceStore.fetchMembers(workspaceId),
detailStore.fetchAssets({ workspaceId }),
]);
},
{ immediate: true }
@@ -1169,6 +1287,33 @@
<option value="Carousel">Carousel</option>
</select>
<select
class="dam-select"
:value="activePlacement.assetId"
aria-label="Use media from DAM"
@change="selectDamAsset(activePlacement, $event.target.value)"
>
<option value="">DAM media</option>
<option
v-for="asset in damAssets"
:key="asset.id"
:value="asset.id"
>
{{ asset.displayName }}
</option>
</select>
<label class="media-upload-button">
<v-icon :icon="mdiUpload" />
<span>{{ detailStore.actions.asset ? 'Uploading' : 'Upload' }}</span>
<input
type="file"
accept="image/png,image/jpeg,image/jpg,image/gif,image/webp,video/mp4,video/webm,video/quicktime"
:disabled="detailStore.actions.asset"
@change="uploadPlacementMedia(activePlacement, $event)"
/>
</label>
<v-btn variant="text" :ripple="false"
class="preview-remove"
type="button"
@@ -1188,8 +1333,20 @@
v-if="activePlacement.mediaKind !== 'None'"
class="youtube-player"
>
<video
v-if="placementAssetPreviewUrl(activePlacement) && isPlacementVideo(activePlacement)"
:src="placementAssetPreviewUrl(activePlacement)"
controls
/>
<img
v-else-if="placementAssetPreviewUrl(activePlacement)"
:src="placementAssetPreviewUrl(activePlacement)"
:alt="activePlacement.assetName || 'Selected media'"
/>
<template v-else>
<v-icon :icon="mdiYoutube" />
<span>{{ activePlacement.mediaKind }}</span>
</template>
</div>
<div class="youtube-meta">
<input
@@ -1224,6 +1381,25 @@
data-placeholder="Write the post..."
@input="updatePlacementMessage(activePlacement, $event.currentTarget.innerText)"
>{{ placementMessage(activePlacement) }}</div>
<div
v-if="activePlacement.mediaKind !== 'None'"
class="feed-media is-x-media"
>
<video
v-if="placementAssetPreviewUrl(activePlacement) && isPlacementVideo(activePlacement)"
:src="placementAssetPreviewUrl(activePlacement)"
controls
/>
<img
v-else-if="placementAssetPreviewUrl(activePlacement)"
:src="placementAssetPreviewUrl(activePlacement)"
:alt="activePlacement.assetName || 'Selected media'"
/>
<template v-else>
<v-icon :icon="mdiImageMultiple" />
<span>{{ activePlacement.mediaKind }}</span>
</template>
</div>
<div
v-if="sharedPreviewTags.length"
class="preview-tags"
@@ -1251,8 +1427,20 @@
v-if="activePlacement.mediaKind !== 'None'"
class="feed-media"
>
<video
v-if="placementAssetPreviewUrl(activePlacement) && isPlacementVideo(activePlacement)"
:src="placementAssetPreviewUrl(activePlacement)"
controls
/>
<img
v-else-if="placementAssetPreviewUrl(activePlacement)"
:src="placementAssetPreviewUrl(activePlacement)"
:alt="activePlacement.assetName || 'Selected media'"
/>
<template v-else>
<v-icon :icon="channelIcon(activePlacement.network)" />
<span>{{ activePlacement.mediaKind }}</span>
</template>
</div>
<input
v-if="activePlacementUsesTitle"
@@ -1812,7 +2000,8 @@
@apply h-4 w-4 accent-teal-700;
}
.media-kind-select {
.media-kind-select,
.dam-select {
@apply h-8 rounded-full border px-3 text-xs font-bold;
background: var(--app-color-on-primary);
border-color: var(--app-border-subtle);
@@ -1820,6 +2009,25 @@
outline: none;
}
.dam-select {
@apply max-w-40;
}
.media-upload-button {
@apply inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-full border px-3 text-xs font-bold;
background: var(--app-color-on-surface);
border-color: var(--app-color-on-surface);
color: var(--app-color-on-primary);
}
.media-upload-button input {
@apply sr-only;
}
.media-upload-button:has(input:disabled) {
@apply cursor-wait opacity-70;
}
.preview-profile div:last-child {
@apply flex min-w-0 flex-col;
}
@@ -1881,11 +2089,23 @@
}
.youtube-player {
@apply grid aspect-video w-full place-items-center rounded-[1rem];
@apply grid aspect-video w-full overflow-hidden rounded-[1rem];
background: #111827;
color: #ef4444;
}
.youtube-player > :not(img):not(video),
.feed-media > :not(img):not(video) {
@apply self-center justify-self-center;
}
.youtube-player img,
.youtube-player video,
.feed-media img,
.feed-media video {
@apply h-full w-full object-cover;
}
.youtube-player span,
.feed-media span {
@apply rounded-full px-3 py-1 text-xs font-black uppercase tracking-[0.14em];
@@ -1917,11 +2137,15 @@
}
.feed-media {
@apply grid aspect-[4/5] w-full place-items-center gap-3 rounded-[1rem];
@apply grid aspect-[4/5] w-full overflow-hidden rounded-[1rem];
background: linear-gradient(135deg, #0f766e, #f97316);
color: white;
}
.feed-media.is-x-media {
@apply aspect-[16/9];
}
.feed-media :deep(.v-icon) {
@apply text-5xl;
}

View File

@@ -4564,10 +4564,19 @@
{
"name": "contentItemId",
"in": "query",
"required": true,
"schema": {
"type": "string",
"format": "guid"
"format": "guid",
"nullable": true
}
},
{
"name": "workspaceId",
"in": "query",
"schema": {
"type": "string",
"format": "guid",
"nullable": true
}
}
],
@@ -4596,6 +4605,58 @@
]
}
},
"/api/assets/upload": {
"post": {
"tags": [
"Assets",
"Api"
],
"operationId": "SocializeApiModulesAssetsHandlersUploadAssetHandler",
"requestBody": {
"x-name": "UploadAssetRequest",
"description": "",
"content": {
"multipart/form-data": {
"schema": {
"$ref": "#/components/schemas/SocializeApiModulesAssetsHandlersUploadAssetRequest"
}
}
},
"required": true,
"x-position": 1
},
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SocializeApiModulesAssetsHandlersAssetDto"
}
}
}
},
"400": {
"description": "Bad Request",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/FastEndpointsErrorResponse"
}
}
}
},
"401": {
"description": "Unauthorized"
}
},
"security": [
{
"JWTBearerAuth": []
}
]
}
},
"/api/approvals": {
"get": {
"tags": [
@@ -7651,6 +7712,47 @@
"type": "object",
"additionalProperties": false
},
"SocializeApiModulesAssetsHandlersUploadAssetRequest": {
"type": "object",
"additionalProperties": false,
"required": [
"workspaceId",
"contentItemId",
"assetType",
"file"
],
"properties": {
"workspaceId": {
"type": "string",
"format": "guid",
"minLength": 1,
"nullable": false
},
"contentItemId": {
"type": "string",
"format": "guid",
"minLength": 1,
"nullable": false
},
"assetType": {
"type": "string",
"maxLength": 64,
"minLength": 0,
"nullable": false
},
"displayName": {
"type": "string",
"maxLength": 256,
"minLength": 0,
"nullable": true
},
"file": {
"type": "string",
"format": "binary",
"nullable": false
}
}
},
"SocializeApiModulesApprovalsHandlersApprovalRequestDto": {
"type": "object",
"additionalProperties": false,