Match Figma profile, blocks, auth, and archive backend

This commit is contained in:
boutmoun123
2026-06-28 18:46:01 +03:00
parent 3298338e70
commit 36ac36e333
44 changed files with 1245 additions and 39 deletions
@@ -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">
+134 -7
View File
@@ -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"