Match Figma profile, blocks, auth, and archive backend
This commit is contained in:
@@ -319,6 +319,7 @@ export default function ContentPage() {
|
||||
<SelectItem value="public">public</SelectItem>
|
||||
<SelectItem value="followers">followers</SelectItem>
|
||||
<SelectItem value="private">private</SelectItem>
|
||||
<SelectItem value="archived">archived</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={moderationStatus} onValueChange={setModerationStatus}>
|
||||
@@ -455,7 +456,9 @@ export default function ContentPage() {
|
||||
/>
|
||||
</TableCell>
|
||||
) : null}
|
||||
<TableCell className="max-w-[240px] truncate">{post.content || "-"}</TableCell>
|
||||
<TableCell className="max-w-[240px] truncate">
|
||||
{post.contentBottom?.trim() || post.content || post.contentTop || "-"}
|
||||
</TableCell>
|
||||
<TableCell>{getUserLabel(getPostAuthor(post))}</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
@@ -604,7 +607,27 @@ export default function ContentPage() {
|
||||
>
|
||||
{selectedPost ? (
|
||||
<div className="space-y-4">
|
||||
{selectedPost.contentTop?.trim() ? (
|
||||
<Card className="border-border/70 bg-secondary/20">
|
||||
<CardHeader>
|
||||
<CardTitle>Top text</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="whitespace-pre-wrap text-sm leading-6 text-foreground">
|
||||
{selectedPost.contentTop.trim()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
<MediaInspector post={selectedPost} />
|
||||
{(selectedPost.contentBottom?.trim() || selectedPost.content?.trim()) ? (
|
||||
<Card className="border-border/70 bg-secondary/20">
|
||||
<CardHeader>
|
||||
<CardTitle>Bottom text</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="whitespace-pre-wrap text-sm leading-6 text-foreground">
|
||||
{selectedPost.contentBottom?.trim() || selectedPost.content.trim()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
<Card className="border-border/70 bg-secondary/20">
|
||||
<CardHeader>
|
||||
<CardTitle>Post metadata</CardTitle>
|
||||
|
||||
@@ -73,6 +73,13 @@ function buildEditPayload(user: ApiUser): Partial<ApiUser> {
|
||||
stageName: user.stageName ?? "",
|
||||
bio: user.bio ?? "",
|
||||
location: user.location ?? "",
|
||||
avatar: user.avatar ?? "",
|
||||
coverImage: user.coverImage ?? "",
|
||||
musicRoles: user.musicRoles ?? [],
|
||||
favoriteMaqamat: user.favoriteMaqamat ?? [],
|
||||
preferredMood: user.preferredMood ?? "",
|
||||
experienceLevel: user.experienceLevel ?? "",
|
||||
favoriteInstruments: user.favoriteInstruments ?? [],
|
||||
isPrivate: user.isPrivate ?? false,
|
||||
isVerified: user.isVerified ?? false,
|
||||
};
|
||||
@@ -82,6 +89,20 @@ function normalizeComparableString(value: string | null | undefined) {
|
||||
return (value ?? "").trim();
|
||||
}
|
||||
|
||||
function normalizeStringList(value: unknown): string[] {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => String(item).trim()).filter(Boolean);
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value.split(",").map((item) => item.trim()).filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function areStringListsEqual(left: string[], right: string[]) {
|
||||
return left.length === right.length && left.every((item, index) => item === right[index]);
|
||||
}
|
||||
|
||||
function buildUserUpdatePayload(
|
||||
selectedUser: ApiUser,
|
||||
editPayload: Partial<ApiUser>,
|
||||
@@ -145,6 +166,58 @@ function buildUserUpdatePayload(
|
||||
payload.location = nextLocation;
|
||||
}
|
||||
|
||||
const nextAvatar = normalizeComparableString(
|
||||
typeof editPayload.avatar === "string" ? editPayload.avatar : selectedUser.avatar,
|
||||
);
|
||||
const currentAvatar = normalizeComparableString(selectedUser.avatar);
|
||||
if (nextAvatar !== currentAvatar) {
|
||||
payload.avatar = nextAvatar;
|
||||
}
|
||||
|
||||
const nextCoverImage = normalizeComparableString(
|
||||
typeof editPayload.coverImage === "string" ? editPayload.coverImage : selectedUser.coverImage,
|
||||
);
|
||||
const currentCoverImage = normalizeComparableString(selectedUser.coverImage);
|
||||
if (nextCoverImage !== currentCoverImage) {
|
||||
payload.coverImage = nextCoverImage;
|
||||
}
|
||||
|
||||
const nextMusicRoles = normalizeStringList(editPayload.musicRoles ?? selectedUser.musicRoles);
|
||||
const currentMusicRoles = normalizeStringList(selectedUser.musicRoles);
|
||||
if (!areStringListsEqual(nextMusicRoles, currentMusicRoles)) {
|
||||
payload.musicRoles = nextMusicRoles;
|
||||
}
|
||||
|
||||
const nextFavoriteMaqamat = normalizeStringList(editPayload.favoriteMaqamat ?? selectedUser.favoriteMaqamat);
|
||||
const currentFavoriteMaqamat = normalizeStringList(selectedUser.favoriteMaqamat);
|
||||
if (!areStringListsEqual(nextFavoriteMaqamat, currentFavoriteMaqamat)) {
|
||||
payload.favoriteMaqamat = nextFavoriteMaqamat;
|
||||
}
|
||||
|
||||
const nextFavoriteInstruments = normalizeStringList(
|
||||
editPayload.favoriteInstruments ?? selectedUser.favoriteInstruments,
|
||||
);
|
||||
const currentFavoriteInstruments = normalizeStringList(selectedUser.favoriteInstruments);
|
||||
if (!areStringListsEqual(nextFavoriteInstruments, currentFavoriteInstruments)) {
|
||||
payload.favoriteInstruments = nextFavoriteInstruments;
|
||||
}
|
||||
|
||||
const nextPreferredMood = normalizeComparableString(
|
||||
typeof editPayload.preferredMood === "string" ? editPayload.preferredMood : selectedUser.preferredMood,
|
||||
);
|
||||
const currentPreferredMood = normalizeComparableString(selectedUser.preferredMood);
|
||||
if (nextPreferredMood !== currentPreferredMood) {
|
||||
payload.preferredMood = nextPreferredMood;
|
||||
}
|
||||
|
||||
const nextExperienceLevel = normalizeComparableString(
|
||||
typeof editPayload.experienceLevel === "string" ? editPayload.experienceLevel : selectedUser.experienceLevel,
|
||||
);
|
||||
const currentExperienceLevel = normalizeComparableString(selectedUser.experienceLevel);
|
||||
if (nextExperienceLevel !== currentExperienceLevel) {
|
||||
payload.experienceLevel = nextExperienceLevel;
|
||||
}
|
||||
|
||||
const nextIsPrivate = Boolean(editPayload.isPrivate ?? selectedUser.isPrivate ?? false);
|
||||
const currentIsPrivate = Boolean(selectedUser.isPrivate ?? false);
|
||||
if (nextIsPrivate !== currentIsPrivate) {
|
||||
@@ -167,13 +240,17 @@ function FieldRow({
|
||||
valueClassName,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | number | boolean | null | undefined;
|
||||
value: string | string[] | number | boolean | null | undefined;
|
||||
valueDir?: "auto" | "rtl" | "ltr";
|
||||
valueClassName?: string;
|
||||
}) {
|
||||
const normalized =
|
||||
value === null || value === undefined || value === ""
|
||||
? "-"
|
||||
: Array.isArray(value)
|
||||
? value.length
|
||||
? value.join(", ")
|
||||
: "-"
|
||||
: typeof value === "boolean"
|
||||
? value
|
||||
? "نعم"
|
||||
@@ -786,6 +863,67 @@ export default function UsersPage() {
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Input
|
||||
placeholder="Avatar URL"
|
||||
value={String(editPayload.avatar ?? "")}
|
||||
onChange={(event) =>
|
||||
setEditPayload((prev) => ({ ...prev, avatar: event.target.value }))
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Cover image URL"
|
||||
value={String(editPayload.coverImage ?? "")}
|
||||
onChange={(event) =>
|
||||
setEditPayload((prev) => ({ ...prev, coverImage: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Input
|
||||
placeholder="Music roles, comma separated"
|
||||
value={normalizeStringList(editPayload.musicRoles).join(", ")}
|
||||
onChange={(event) =>
|
||||
setEditPayload((prev) => ({ ...prev, musicRoles: normalizeStringList(event.target.value) }))
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Experience level"
|
||||
value={String(editPayload.experienceLevel ?? "")}
|
||||
onChange={(event) =>
|
||||
setEditPayload((prev) => ({ ...prev, experienceLevel: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Input
|
||||
placeholder="Favorite maqamat, comma separated"
|
||||
value={normalizeStringList(editPayload.favoriteMaqamat).join(", ")}
|
||||
onChange={(event) =>
|
||||
setEditPayload((prev) => ({
|
||||
...prev,
|
||||
favoriteMaqamat: normalizeStringList(event.target.value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
placeholder="Favorite instruments, comma separated"
|
||||
value={normalizeStringList(editPayload.favoriteInstruments).join(", ")}
|
||||
onChange={(event) =>
|
||||
setEditPayload((prev) => ({
|
||||
...prev,
|
||||
favoriteInstruments: normalizeStringList(event.target.value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
placeholder="Preferred mood"
|
||||
value={String(editPayload.preferredMood ?? "")}
|
||||
onChange={(event) =>
|
||||
setEditPayload((prev) => ({ ...prev, preferredMood: event.target.value }))
|
||||
}
|
||||
/>
|
||||
<Select
|
||||
disabled={!canManageUsers}
|
||||
value={String(editPayload.role ?? selectedUser.role ?? "user")}
|
||||
@@ -859,6 +997,13 @@ export default function UsersPage() {
|
||||
<FieldRow label="Role" value={selectedUser.role} />
|
||||
<FieldRow label="Email" value={selectedUser.email} valueDir="ltr" />
|
||||
<FieldRow label="Location" value={selectedUser.location} />
|
||||
<FieldRow label="Avatar" value={selectedUser.avatar} valueDir="ltr" />
|
||||
<FieldRow label="Cover" value={selectedUser.coverImage} valueDir="ltr" />
|
||||
<FieldRow label="Music roles" value={selectedUser.musicRoles} />
|
||||
<FieldRow label="Favorite maqamat" value={selectedUser.favoriteMaqamat} />
|
||||
<FieldRow label="Preferred mood" value={selectedUser.preferredMood} />
|
||||
<FieldRow label="Experience" value={selectedUser.experienceLevel} />
|
||||
<FieldRow label="Instruments" value={selectedUser.favoriteInstruments} />
|
||||
<FieldRow label="Verified" value={selectedUser.isVerified} />
|
||||
<FieldRow label="Disabled" value={selectedUser.isDisabled} />
|
||||
<FieldRow label="Created at" value={formatDateTime(selectedUser.createdAt)} />
|
||||
|
||||
@@ -44,10 +44,18 @@ export function PostPreviewCard({ post }: { post: ApiPost }) {
|
||||
const showImagePreview = !!media.url && !imageFailed && !(media.kind === "video" && isVideoPlaying);
|
||||
const showMediaShell = media.kind !== "text";
|
||||
const showUnavailable = !showImagePreview && !showVideoPreview && !showAudioPreview;
|
||||
const topText = post.contentTop?.trim() ?? "";
|
||||
const bottomText = post.contentBottom?.trim() || post.content?.trim() || "";
|
||||
|
||||
return (
|
||||
<Card className="overflow-hidden border-border/70">
|
||||
<CardContent className="p-0">
|
||||
{topText ? (
|
||||
<div className="border-b border-border/70 p-4">
|
||||
<p className="line-clamp-4 text-sm leading-6 text-foreground">{topText}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showMediaShell ? (
|
||||
<div className="relative aspect-[16/9] border-b border-border/70 bg-secondary/30">
|
||||
{showImagePreview ? (
|
||||
@@ -157,7 +165,7 @@ export function PostPreviewCard({ post }: { post: ApiPost }) {
|
||||
</div>
|
||||
|
||||
<p className="line-clamp-4 text-sm leading-6 text-foreground">
|
||||
{post.content?.trim() || "Media post without caption."}
|
||||
{bottomText || "Media post without caption."}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
|
||||
|
||||
@@ -56,6 +56,7 @@ export type ApiUser = {
|
||||
musicGenres?: string[];
|
||||
favoriteInstruments?: string[];
|
||||
favoriteMaqamat?: string[];
|
||||
preferredMood?: string;
|
||||
experienceLevel?: string;
|
||||
isPrivate?: boolean;
|
||||
isDisabled?: boolean;
|
||||
@@ -87,7 +88,21 @@ export type AdminCreatePayload = {
|
||||
export type AdminUpdatePayload = Partial<
|
||||
Pick<
|
||||
ApiUser,
|
||||
"name" | "username" | "email" | "stageName" | "bio" | "location" | "isPrivate" | "isVerified"
|
||||
| "name"
|
||||
| "username"
|
||||
| "email"
|
||||
| "stageName"
|
||||
| "bio"
|
||||
| "location"
|
||||
| "avatar"
|
||||
| "coverImage"
|
||||
| "musicRoles"
|
||||
| "favoriteMaqamat"
|
||||
| "preferredMood"
|
||||
| "experienceLevel"
|
||||
| "favoriteInstruments"
|
||||
| "isPrivate"
|
||||
| "isVerified"
|
||||
>
|
||||
>;
|
||||
|
||||
@@ -130,10 +145,26 @@ export type SuperAdminSessionResponse = {
|
||||
export type ApiPostType = "text" | "image" | "video" | "audio";
|
||||
export type ApiPostVisibility = "public" | "followers" | "private";
|
||||
export type ModerationStatus = "active" | "hidden" | "flagged";
|
||||
export type MediaProcessingStatus = "pending" | "processing" | "ready" | "failed";
|
||||
|
||||
export type ApiPostMedia = {
|
||||
mediaType?: ApiPostType;
|
||||
displayUrl?: string;
|
||||
thumbnailUrl?: string;
|
||||
preferredPlaybackUrl?: string;
|
||||
hlsUrl?: string;
|
||||
videoUrl?: string;
|
||||
audioUrl?: string;
|
||||
durationSeconds?: number | null;
|
||||
isPlayable?: boolean;
|
||||
};
|
||||
|
||||
export type ApiPost = {
|
||||
_id: string;
|
||||
id?: string;
|
||||
content: string;
|
||||
contentTop?: string;
|
||||
contentBottom?: string;
|
||||
visibility?: ApiPostVisibility;
|
||||
postType?: ApiPostType;
|
||||
imageUrls?: string[];
|
||||
@@ -151,7 +182,8 @@ export type ApiPost = {
|
||||
thumbnailUrl?: string;
|
||||
thumbnailVariants?: Record<string, string | undefined>;
|
||||
durationSeconds?: number | null;
|
||||
processingStatus?: "pending" | "processing" | "ready" | "failed" | string;
|
||||
processingStatus?: MediaProcessingStatus;
|
||||
media?: ApiPostMedia;
|
||||
style?: string;
|
||||
maqam?: string;
|
||||
rhythmSignature?: string;
|
||||
@@ -270,20 +302,115 @@ export type DeviceItem = {
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type CollaborationRequestStatus = "pending" | "approved" | "rejected";
|
||||
export type CollaborationRequestStatus = "pending" | "approved" | "rejected" | "cancelled";
|
||||
export type CollaborationType = "duet" | "arrangement" | "composition";
|
||||
export type CollaborationAttachmentType = "audio" | "demo" | "file";
|
||||
|
||||
export type CollaborationRequestItem = {
|
||||
_id: string;
|
||||
postId: ApiPost | string;
|
||||
requesterId: ApiUser | string;
|
||||
targetUserId: ApiUser | string;
|
||||
_id?: string;
|
||||
id?: string;
|
||||
postId?: ApiPost | string | null;
|
||||
requesterId?: ApiUser | string;
|
||||
targetUserId?: ApiUser | string;
|
||||
requester?: ApiUser | null;
|
||||
targetUser?: ApiUser | null;
|
||||
post?: ApiPost | null;
|
||||
status: CollaborationRequestStatus;
|
||||
collaborationType?: CollaborationType | null;
|
||||
message?: string;
|
||||
attachmentUrl?: string;
|
||||
attachmentType?: CollaborationAttachmentType | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
export type CollaborationRequestsResponse = PaginatedResponse<CollaborationRequestItem>;
|
||||
|
||||
export type CollaborationRequestActionResponse = {
|
||||
approved?: boolean;
|
||||
rejected?: boolean;
|
||||
cancelled?: boolean;
|
||||
request: CollaborationRequestItem;
|
||||
};
|
||||
|
||||
export type CollaborationRequestDetailResponse = {
|
||||
request: CollaborationRequestItem;
|
||||
};
|
||||
|
||||
export type SuperAdminCollaborationRequestStatusPayload = {
|
||||
status: Exclude<CollaborationRequestStatus, "cancelled">;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export type MediaHealthStatus = "ok" | "warning" | "error";
|
||||
|
||||
export type MediaHealthResponse = {
|
||||
status: MediaHealthStatus;
|
||||
storage: {
|
||||
provider?: "local" | "s3";
|
||||
storageProvider?: "local" | "s3";
|
||||
basePath?: string;
|
||||
storageBasePath?: string;
|
||||
publicPath?: string;
|
||||
uploadsPublicPath?: string;
|
||||
publicBaseUrlConfigured?: boolean;
|
||||
publicBaseUrl?: string;
|
||||
storagePublicBaseUrlConfigured?: boolean;
|
||||
storagePublicBaseUrl?: string;
|
||||
isLocalStorage?: boolean;
|
||||
isS3Configured?: boolean;
|
||||
s3Configured?: boolean;
|
||||
uploadPathExists?: boolean;
|
||||
uploadPathReadable?: boolean;
|
||||
uploadPathWritable?: boolean;
|
||||
local?: Record<string, unknown>;
|
||||
s3?: {
|
||||
reachable?: boolean;
|
||||
bucket?: string;
|
||||
endpoint?: string;
|
||||
region?: string;
|
||||
forcePathStyle?: boolean;
|
||||
error?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
processing: {
|
||||
imageProcessingEnabled: boolean;
|
||||
videoProcessingEnabled: boolean;
|
||||
videoHlsGenerationEnabled: boolean;
|
||||
videoThumbnailGenerationEnabled: boolean;
|
||||
maxVideoWidth: number;
|
||||
maxVideoFps: number;
|
||||
videoCrf: number;
|
||||
audioBitrateKbps: number;
|
||||
audioProcessingEnabled: boolean;
|
||||
ffmpegPath?: string;
|
||||
ffmpegAvailable: boolean;
|
||||
ffmpegVersion?: string;
|
||||
ffprobePath?: string;
|
||||
ffprobeAvailable: boolean;
|
||||
ffprobeVersion?: string;
|
||||
ffmpeg?: Record<string, unknown>;
|
||||
ffprobe?: Record<string, unknown>;
|
||||
};
|
||||
serving: {
|
||||
mediaAccessMode: "direct" | "signed" | string;
|
||||
signedUrlsEnabled: boolean;
|
||||
rangeRequests: boolean;
|
||||
immutableCacheSeconds: number;
|
||||
hlsManifestCacheSeconds: number;
|
||||
s3ImmutableCacheControl?: string;
|
||||
s3HlsManifestCacheControl?: string;
|
||||
};
|
||||
staticServing: {
|
||||
uploadsPublicPath: string;
|
||||
rangeRequestsExpected: boolean;
|
||||
cacheHeadersExpected: boolean;
|
||||
hlsMimeExpected: string;
|
||||
};
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
export type NotificationType =
|
||||
| "like"
|
||||
| "comment"
|
||||
|
||||
Reference in New Issue
Block a user