first commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { createSupabaseServiceRoleClient } from "@/lib/supabase/service-role";
|
||||
import type { AppUser, AppUserStatus } from "@/types/app/user";
|
||||
|
||||
export type AdminAppUser = AppUser & {
|
||||
favorite_count: number;
|
||||
};
|
||||
|
||||
const adminUserSelect =
|
||||
"id,email,display_name,avatar_url,auth_provider,status,phone,wechat_openid,wechat_unionid,last_login_at,disabled_at,created_at,updated_at";
|
||||
|
||||
export async function listAdminAppUsers() {
|
||||
const supabase = createSupabaseServiceRoleClient();
|
||||
const { data, error } = await supabase
|
||||
.from("wg_app_users")
|
||||
.select(adminUserSelect)
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(100);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const users = (data ?? []) as AppUser[];
|
||||
|
||||
if (users.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { data: favorites, error: favoritesError } = await supabase
|
||||
.from("wg_user_favorites")
|
||||
.select("user_id")
|
||||
.in(
|
||||
"user_id",
|
||||
users.map((user) => user.id),
|
||||
);
|
||||
|
||||
if (favoritesError) {
|
||||
throw favoritesError;
|
||||
}
|
||||
|
||||
const favoriteCountByUserId = (favorites ?? []).reduce(
|
||||
(counts, favorite) => {
|
||||
counts[favorite.user_id] = (counts[favorite.user_id] ?? 0) + 1;
|
||||
return counts;
|
||||
},
|
||||
{} as Record<string, number>,
|
||||
);
|
||||
|
||||
return users.map((user) => ({
|
||||
...user,
|
||||
favorite_count: favoriteCountByUserId[user.id] ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function updateAdminAppUserStatus({
|
||||
status,
|
||||
userId,
|
||||
}: {
|
||||
status: AppUserStatus;
|
||||
userId: string;
|
||||
}) {
|
||||
const supabase = createSupabaseServiceRoleClient();
|
||||
const { data, error } = await supabase
|
||||
.from("wg_app_users")
|
||||
.update({
|
||||
status,
|
||||
disabled_at:
|
||||
status === "disabled" ? new Date().toISOString() : null,
|
||||
})
|
||||
.eq("id", userId)
|
||||
.select(adminUserSelect)
|
||||
.single<AppUser>();
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const { data: favorites, error: favoritesError } = await supabase
|
||||
.from("wg_user_favorites")
|
||||
.select("user_id")
|
||||
.eq("user_id", userId);
|
||||
|
||||
if (favoritesError) {
|
||||
throw favoritesError;
|
||||
}
|
||||
|
||||
return {
|
||||
...data,
|
||||
favorite_count: favorites?.length ?? 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { createSupabaseServiceRoleClient } from "@/lib/supabase/service-role";
|
||||
import { uploadRemoteImageToAliyunOss } from "@/server/storage/aliyun-oss";
|
||||
|
||||
type GalleryPhotoPickRow = {
|
||||
id: string;
|
||||
unsplash_id: string;
|
||||
urls: Record<string, unknown>;
|
||||
};
|
||||
|
||||
function getMirrorSourceUrl(urls: Record<string, unknown>) {
|
||||
const candidates = [
|
||||
urls.full,
|
||||
urls.regular,
|
||||
urls.raw,
|
||||
urls.small,
|
||||
urls.thumb,
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (typeof candidate === "string" && candidate.length > 0) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function updateGalleryPhotoPickStatus({
|
||||
isPicked,
|
||||
pickedBy,
|
||||
unsplashId,
|
||||
}: {
|
||||
isPicked: boolean;
|
||||
pickedBy: string;
|
||||
unsplashId: string;
|
||||
}) {
|
||||
const supabase = createSupabaseServiceRoleClient();
|
||||
const { data: photo, error: readError } = await supabase
|
||||
.from("wg_gallery_photos")
|
||||
.select("id,unsplash_id,urls")
|
||||
.eq("unsplash_id", unsplashId)
|
||||
.maybeSingle<GalleryPhotoPickRow>();
|
||||
|
||||
if (readError) {
|
||||
throw readError;
|
||||
}
|
||||
|
||||
if (!photo) {
|
||||
throw new Error("Gallery photo was not found.");
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const update: Record<string, unknown> = {
|
||||
is_picked: isPicked,
|
||||
picked_at: isPicked ? now : null,
|
||||
picked_by: isPicked ? pickedBy : null,
|
||||
oss_sync_error: null,
|
||||
};
|
||||
|
||||
if (isPicked) {
|
||||
const imageUrl = getMirrorSourceUrl(photo.urls);
|
||||
|
||||
if (!imageUrl) {
|
||||
update.oss_sync_error = "No usable source image URL was found.";
|
||||
} else {
|
||||
const upload = await uploadRemoteImageToAliyunOss({
|
||||
imageUrl,
|
||||
objectKeyPrefix: `gallery/unsplash/${photo.unsplash_id}`,
|
||||
});
|
||||
|
||||
if (upload.ok) {
|
||||
update.oss_bucket = upload.bucket;
|
||||
update.oss_object_key = upload.objectKey;
|
||||
update.oss_url = upload.url;
|
||||
update.oss_synced_at = now;
|
||||
} else {
|
||||
update.oss_sync_error = upload.error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from("wg_gallery_photos")
|
||||
.update(update)
|
||||
.eq("unsplash_id", unsplashId)
|
||||
.select(
|
||||
"id,unsplash_id,slug,description,alt_description,width,height,color,color_family,likes,urls,user_info,photo_created_at,is_picked,picked_at,picked_by,oss_url,oss_object_key,oss_synced_at,oss_sync_error",
|
||||
)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error("[gallery-pick] Supabase update failed", error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { createSupabaseServiceRoleClient } from "@/lib/supabase/service-role";
|
||||
import {
|
||||
fetchUnsplashWallpaperPhotos,
|
||||
type UnsplashPhoto,
|
||||
} from "@/server/api/unsplash";
|
||||
import { getGalleryColorFamily } from "@/server/gallery/color";
|
||||
|
||||
const SOURCE = "unsplash-wallpapers";
|
||||
const DEFAULT_PAGE = 1;
|
||||
const DEFAULT_PER_PAGE = 20;
|
||||
|
||||
type GallerySyncState = {
|
||||
source: string;
|
||||
next_page: number;
|
||||
per_page: number;
|
||||
};
|
||||
|
||||
type GalleryPhotoRow = {
|
||||
unsplash_id: string;
|
||||
slug: string | null;
|
||||
description: string | null;
|
||||
alt_description: string | null;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
color: string | null;
|
||||
color_family: string | null;
|
||||
blur_hash: string | null;
|
||||
likes: number | null;
|
||||
photo_created_at: string | null;
|
||||
photo_updated_at: string | null;
|
||||
urls: Record<string, unknown>;
|
||||
links: Record<string, unknown>;
|
||||
user_info: Record<string, unknown>;
|
||||
raw: Record<string, unknown>;
|
||||
source: string;
|
||||
};
|
||||
|
||||
function normalizeDate(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
||||
}
|
||||
|
||||
function mapPhotoToRow(photo: UnsplashPhoto): GalleryPhotoRow {
|
||||
const raw = photo as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
unsplash_id: photo.id,
|
||||
slug: photo.slug ?? null,
|
||||
description: photo.description ?? null,
|
||||
alt_description: photo.alt_description ?? null,
|
||||
width: photo.width ?? null,
|
||||
height: photo.height ?? null,
|
||||
color: photo.color ?? null,
|
||||
color_family: getGalleryColorFamily(photo.color),
|
||||
blur_hash: photo.blur_hash ?? null,
|
||||
likes: photo.likes ?? null,
|
||||
photo_created_at: normalizeDate(photo.created_at),
|
||||
photo_updated_at: normalizeDate(photo.updated_at),
|
||||
urls: photo.urls ?? {},
|
||||
links: photo.links ?? {},
|
||||
user_info: photo.user ?? {},
|
||||
raw,
|
||||
source: SOURCE,
|
||||
};
|
||||
}
|
||||
|
||||
async function getSyncState() {
|
||||
const supabase = createSupabaseServiceRoleClient();
|
||||
const { data, error } = await supabase
|
||||
.from("wg_gallery_sync_state")
|
||||
.select("source,next_page,per_page")
|
||||
.eq("source", SOURCE)
|
||||
.maybeSingle<GallerySyncState>();
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (data) {
|
||||
return {
|
||||
supabase,
|
||||
state: data,
|
||||
};
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
source: SOURCE,
|
||||
next_page: DEFAULT_PAGE,
|
||||
per_page: DEFAULT_PER_PAGE,
|
||||
};
|
||||
|
||||
const { error: insertError } = await supabase
|
||||
.from("wg_gallery_sync_state")
|
||||
.insert(initialState);
|
||||
|
||||
if (insertError) {
|
||||
throw insertError;
|
||||
}
|
||||
|
||||
return {
|
||||
supabase,
|
||||
state: initialState,
|
||||
};
|
||||
}
|
||||
|
||||
export async function syncUnsplashWallpapersToGallery() {
|
||||
const { supabase, state } = await getSyncState();
|
||||
const page = state.next_page || DEFAULT_PAGE;
|
||||
const perPage = state.per_page || DEFAULT_PER_PAGE;
|
||||
|
||||
try {
|
||||
const photos = await fetchUnsplashWallpaperPhotos({ page, perPage });
|
||||
const rows = photos.map(mapPhotoToRow);
|
||||
let insertedCount = 0;
|
||||
|
||||
if (rows.length > 0) {
|
||||
const { data, error } = await supabase
|
||||
.from("wg_gallery_photos")
|
||||
.upsert(rows, {
|
||||
onConflict: "unsplash_id",
|
||||
ignoreDuplicates: true,
|
||||
})
|
||||
.select("unsplash_id");
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
insertedCount = data?.length ?? 0;
|
||||
}
|
||||
|
||||
const nextPage = rows.length === 0 ? DEFAULT_PAGE : page + 1;
|
||||
const { error: updateError } = await supabase
|
||||
.from("wg_gallery_sync_state")
|
||||
.upsert(
|
||||
{
|
||||
source: SOURCE,
|
||||
next_page: nextPage,
|
||||
per_page: perPage,
|
||||
last_synced_at: new Date().toISOString(),
|
||||
last_inserted_count: insertedCount,
|
||||
last_seen_count: photos.length,
|
||||
last_error: null,
|
||||
},
|
||||
{ onConflict: "source" },
|
||||
);
|
||||
|
||||
if (updateError) {
|
||||
throw updateError;
|
||||
}
|
||||
|
||||
return {
|
||||
source: SOURCE,
|
||||
page,
|
||||
nextPage,
|
||||
seenCount: photos.length,
|
||||
insertedCount,
|
||||
};
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Unknown sync error.";
|
||||
|
||||
await supabase.from("wg_gallery_sync_state").upsert(
|
||||
{
|
||||
source: SOURCE,
|
||||
next_page: page,
|
||||
per_page: perPage,
|
||||
last_synced_at: new Date().toISOString(),
|
||||
last_inserted_count: 0,
|
||||
last_seen_count: 0,
|
||||
last_error: message,
|
||||
},
|
||||
{ onConflict: "source" },
|
||||
);
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
import { createSupabaseServiceRoleClient } from "@/lib/supabase/service-role";
|
||||
import type {
|
||||
CollectionPhoto,
|
||||
PhotoCollection,
|
||||
PhotoCollectionInput,
|
||||
} from "@/types/admin/collection";
|
||||
|
||||
type CollectionRow = {
|
||||
id: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
description: string | null;
|
||||
cover_photo_id: string | null;
|
||||
is_published: boolean;
|
||||
published_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
type CollectionItemRow = {
|
||||
collection_id: string;
|
||||
photo_id: string;
|
||||
sort_order: number;
|
||||
};
|
||||
|
||||
const collectionSelect =
|
||||
"id,title,slug,description,cover_photo_id,is_published,published_at,created_at,updated_at";
|
||||
|
||||
const photoSelect =
|
||||
"id,unsplash_id,slug,description,alt_description,width,height,color,color_family,likes,urls,user_info,photo_created_at,picked_at,oss_url";
|
||||
|
||||
function slugify(value: string) {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/['"]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 80);
|
||||
}
|
||||
|
||||
function normalizeInput(input: PhotoCollectionInput) {
|
||||
const title = input.title.trim();
|
||||
const slug = slugify(input.slug?.trim() || title);
|
||||
const photoIds = Array.from(new Set(input.photoIds.filter(Boolean)));
|
||||
const coverPhotoId =
|
||||
input.coverPhotoId && photoIds.includes(input.coverPhotoId)
|
||||
? input.coverPhotoId
|
||||
: (photoIds[0] ?? null);
|
||||
|
||||
if (!title) {
|
||||
throw new Error("Collection title is required.");
|
||||
}
|
||||
|
||||
if (!slug) {
|
||||
throw new Error("Collection slug is required.");
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
slug,
|
||||
description: input.description?.trim() || null,
|
||||
coverPhotoId,
|
||||
isPublished: input.isPublished,
|
||||
photoIds,
|
||||
};
|
||||
}
|
||||
|
||||
async function assertPickedPhotos(photoIds: string[]) {
|
||||
if (photoIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const supabase = createSupabaseServiceRoleClient();
|
||||
const { data, error } = await supabase
|
||||
.from("wg_gallery_photos")
|
||||
.select("id")
|
||||
.eq("is_picked", true)
|
||||
.in("id", photoIds);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if ((data?.length ?? 0) !== photoIds.length) {
|
||||
throw new Error("Collections can only include picked photos.");
|
||||
}
|
||||
}
|
||||
|
||||
async function getPhotosById(photoIds: string[]) {
|
||||
if (photoIds.length === 0) {
|
||||
return new Map<string, CollectionPhoto>();
|
||||
}
|
||||
|
||||
const supabase = createSupabaseServiceRoleClient();
|
||||
const { data, error } = await supabase
|
||||
.from("wg_gallery_photos")
|
||||
.select(photoSelect)
|
||||
.in("id", photoIds);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return new Map(
|
||||
((data ?? []) as CollectionPhoto[]).map((photo) => [photo.id, photo]),
|
||||
);
|
||||
}
|
||||
|
||||
async function attachPhotos(
|
||||
collections: CollectionRow[],
|
||||
): Promise<PhotoCollection[]> {
|
||||
if (collections.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const supabase = createSupabaseServiceRoleClient();
|
||||
const collectionIds = collections.map((collection) => collection.id);
|
||||
const { data: items, error } = await supabase
|
||||
.from("wg_photo_collection_items")
|
||||
.select("collection_id,photo_id,sort_order")
|
||||
.in("collection_id", collectionIds)
|
||||
.order("sort_order", { ascending: true })
|
||||
.order("created_at", { ascending: true });
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const itemRows = (items ?? []) as CollectionItemRow[];
|
||||
const photoIds = Array.from(
|
||||
new Set([
|
||||
...itemRows.map((item) => item.photo_id),
|
||||
...collections
|
||||
.map((collection) => collection.cover_photo_id)
|
||||
.filter((id): id is string => Boolean(id)),
|
||||
]),
|
||||
);
|
||||
const photosById = await getPhotosById(photoIds);
|
||||
const itemsByCollection = new Map<string, CollectionItemRow[]>();
|
||||
|
||||
for (const item of itemRows) {
|
||||
const current = itemsByCollection.get(item.collection_id) ?? [];
|
||||
current.push(item);
|
||||
itemsByCollection.set(item.collection_id, current);
|
||||
}
|
||||
|
||||
return collections.map((collection) => ({
|
||||
...collection,
|
||||
cover_photo: collection.cover_photo_id
|
||||
? (photosById.get(collection.cover_photo_id) ?? null)
|
||||
: null,
|
||||
photos: (itemsByCollection.get(collection.id) ?? [])
|
||||
.map((item) => photosById.get(item.photo_id))
|
||||
.filter((photo): photo is CollectionPhoto => Boolean(photo)),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function listAdminPhotoCollections() {
|
||||
const supabase = createSupabaseServiceRoleClient();
|
||||
const { data, error } = await supabase
|
||||
.from("wg_photo_collections")
|
||||
.select(collectionSelect)
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(100);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return attachPhotos((data ?? []) as CollectionRow[]);
|
||||
}
|
||||
|
||||
export async function getAdminPhotoCollection(id: string) {
|
||||
const supabase = createSupabaseServiceRoleClient();
|
||||
const { data, error } = await supabase
|
||||
.from("wg_photo_collections")
|
||||
.select(collectionSelect)
|
||||
.eq("id", id)
|
||||
.maybeSingle<CollectionRow>();
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [collection] = await attachPhotos([data]);
|
||||
return collection ?? null;
|
||||
}
|
||||
|
||||
export async function listPickedPhotosForCollections() {
|
||||
const supabase = createSupabaseServiceRoleClient();
|
||||
const { data, error } = await supabase
|
||||
.from("wg_gallery_photos")
|
||||
.select(photoSelect)
|
||||
.eq("is_picked", true)
|
||||
.order("picked_at", { ascending: false, nullsFirst: false })
|
||||
.order("photo_created_at", { ascending: false })
|
||||
.limit(120);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return (data ?? []) as CollectionPhoto[];
|
||||
}
|
||||
|
||||
async function replaceCollectionItems(
|
||||
collectionId: string,
|
||||
photoIds: string[],
|
||||
) {
|
||||
const supabase = createSupabaseServiceRoleClient();
|
||||
const { error: deleteError } = await supabase
|
||||
.from("wg_photo_collection_items")
|
||||
.delete()
|
||||
.eq("collection_id", collectionId);
|
||||
|
||||
if (deleteError) {
|
||||
throw deleteError;
|
||||
}
|
||||
|
||||
if (photoIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { error: insertError } = await supabase
|
||||
.from("wg_photo_collection_items")
|
||||
.insert(
|
||||
photoIds.map((photoId, index) => ({
|
||||
collection_id: collectionId,
|
||||
photo_id: photoId,
|
||||
sort_order: index,
|
||||
})),
|
||||
);
|
||||
|
||||
if (insertError) {
|
||||
throw insertError;
|
||||
}
|
||||
}
|
||||
|
||||
export async function createPhotoCollection(input: PhotoCollectionInput) {
|
||||
const normalized = normalizeInput(input);
|
||||
await assertPickedPhotos(normalized.photoIds);
|
||||
|
||||
const supabase = createSupabaseServiceRoleClient();
|
||||
const now = new Date().toISOString();
|
||||
const { data, error } = await supabase
|
||||
.from("wg_photo_collections")
|
||||
.insert({
|
||||
title: normalized.title,
|
||||
slug: normalized.slug,
|
||||
description: normalized.description,
|
||||
cover_photo_id: normalized.coverPhotoId,
|
||||
is_published: normalized.isPublished,
|
||||
published_at: normalized.isPublished ? now : null,
|
||||
})
|
||||
.select(collectionSelect)
|
||||
.single<CollectionRow>();
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await replaceCollectionItems(data.id, normalized.photoIds);
|
||||
return getAdminPhotoCollection(data.id);
|
||||
}
|
||||
|
||||
export async function updatePhotoCollection(
|
||||
id: string,
|
||||
input: PhotoCollectionInput,
|
||||
) {
|
||||
const normalized = normalizeInput(input);
|
||||
await assertPickedPhotos(normalized.photoIds);
|
||||
|
||||
const current = await getAdminPhotoCollection(id);
|
||||
|
||||
if (!current) {
|
||||
throw new Error("Collection was not found.");
|
||||
}
|
||||
|
||||
const supabase = createSupabaseServiceRoleClient();
|
||||
const now = new Date().toISOString();
|
||||
const { error } = await supabase
|
||||
.from("wg_photo_collections")
|
||||
.update({
|
||||
title: normalized.title,
|
||||
slug: normalized.slug,
|
||||
description: normalized.description,
|
||||
cover_photo_id: normalized.coverPhotoId,
|
||||
is_published: normalized.isPublished,
|
||||
published_at:
|
||||
normalized.isPublished && !current.published_at
|
||||
? now
|
||||
: normalized.isPublished
|
||||
? current.published_at
|
||||
: null,
|
||||
})
|
||||
.eq("id", id);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await replaceCollectionItems(id, normalized.photoIds);
|
||||
return getAdminPhotoCollection(id);
|
||||
}
|
||||
|
||||
export async function deletePhotoCollection(id: string) {
|
||||
const supabase = createSupabaseServiceRoleClient();
|
||||
const { error } = await supabase
|
||||
.from("wg_photo_collections")
|
||||
.delete()
|
||||
.eq("id", id);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user