feat: require cover for first project submission
This commit is contained in:
@@ -23,6 +23,7 @@ import {
|
||||
import {
|
||||
fetchCurrentWorksProjectStatus,
|
||||
publishWorksProjectSource,
|
||||
type WorksProjectCoverUpload,
|
||||
type WorksProjectMetadataInput,
|
||||
} from '@/lib/works-square';
|
||||
import type { OpencodeProject } from '@/types/opencode';
|
||||
@@ -55,6 +56,30 @@ type ProjectPublishActionProps = {
|
||||
|
||||
const BUILD_POLL_INTERVAL_MS = 2_000;
|
||||
const BUILD_POLL_ATTEMPTS = 300;
|
||||
const MAX_PROJECT_COVER_BYTES = 10 * 1024 * 1024;
|
||||
const PROJECT_COVER_MIME_TYPES = new Set(['image/png', 'image/jpeg', 'image/webp']);
|
||||
|
||||
function matchesCoverSignature(bytes: Uint8Array, mimeType: string): boolean {
|
||||
if (mimeType === 'image/png') {
|
||||
return bytes.length >= 8
|
||||
&& [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a].every((value, index) => bytes[index] === value);
|
||||
}
|
||||
if (mimeType === 'image/jpeg') {
|
||||
return bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
|
||||
}
|
||||
return bytes.length >= 12
|
||||
&& String.fromCharCode(...bytes.slice(0, 4)) === 'RIFF'
|
||||
&& String.fromCharCode(...bytes.slice(8, 12)) === 'WEBP';
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
let binary = '';
|
||||
const chunkSize = 0x8000;
|
||||
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
|
||||
binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize));
|
||||
}
|
||||
return window.btoa(binary);
|
||||
}
|
||||
|
||||
function delay(milliseconds: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
@@ -106,6 +131,8 @@ export function ProjectPublishAction({ project, projectType, buttonVariant = 'de
|
||||
const [difficulty, setDifficulty] = useState('');
|
||||
const [checkingProjectStatus, setCheckingProjectStatus] = useState(false);
|
||||
const [existingProject, setExistingProject] = useState<ExistingProjectMetadata | null>(null);
|
||||
const [cover, setCover] = useState<WorksProjectCoverUpload | null>(null);
|
||||
const [coverPreview, setCoverPreview] = useState<string | null>(null);
|
||||
const pollGeneration = useRef(0);
|
||||
|
||||
const authUser = useAuthStore((state) => state.user);
|
||||
@@ -164,7 +191,10 @@ export function ProjectPublishAction({ project, projectType, buttonVariant = 'de
|
||||
}
|
||||
}
|
||||
|
||||
async function submitProject(metadata: WorksProjectMetadataInput): Promise<void> {
|
||||
async function submitProject(
|
||||
metadata: WorksProjectMetadataInput,
|
||||
projectCover?: WorksProjectCoverUpload,
|
||||
): Promise<void> {
|
||||
pollGeneration.current += 1;
|
||||
const generation = pollGeneration.current;
|
||||
setFailure(null);
|
||||
@@ -175,6 +205,7 @@ export function ProjectPublishAction({ project, projectType, buttonVariant = 'de
|
||||
const result = await publishWorksProjectSource({
|
||||
projectId: project.id,
|
||||
project: metadata,
|
||||
...(projectCover ? { cover: projectCover } : {}),
|
||||
});
|
||||
if (pollGeneration.current !== generation) return;
|
||||
setBindingWarning(result.bindingWarning?.message ?? null);
|
||||
@@ -191,6 +222,34 @@ export function ProjectPublishAction({ project, projectType, buttonVariant = 'de
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCoverChange(file: File | undefined): Promise<void> {
|
||||
setCover(null);
|
||||
setCoverPreview(null);
|
||||
if (!file) return;
|
||||
const mimeType = file.type.toLowerCase();
|
||||
if (!PROJECT_COVER_MIME_TYPES.has(mimeType)) {
|
||||
setFormError('项目封面仅支持 PNG、JPEG 或 WebP 图片。');
|
||||
return;
|
||||
}
|
||||
if (file.size === 0) {
|
||||
setFormError('项目封面不能为空,请重新选择图片。');
|
||||
return;
|
||||
}
|
||||
if (file.size > MAX_PROJECT_COVER_BYTES) {
|
||||
setFormError('项目封面不能超过 10 MiB,请选择更小的图片。');
|
||||
return;
|
||||
}
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
if (!matchesCoverSignature(bytes, mimeType)) {
|
||||
setFormError('项目封面内容与图片格式不符,请重新选择 PNG、JPEG 或 WebP 图片。');
|
||||
return;
|
||||
}
|
||||
const dataBase64 = bytesToBase64(bytes);
|
||||
setCover({ fileName: file.name, mimeType, dataBase64 });
|
||||
setCoverPreview(`data:${mimeType};base64,${dataBase64}`);
|
||||
setFormError(null);
|
||||
}
|
||||
|
||||
async function openPublishDialog(): Promise<void> {
|
||||
setFailure(null);
|
||||
setCheckingProjectStatus(true);
|
||||
@@ -249,6 +308,10 @@ export function ProjectPublishAction({ project, projectType, buttonVariant = 'de
|
||||
setFormError('请填写项目简介。');
|
||||
return;
|
||||
}
|
||||
if (!cover) {
|
||||
setFormError('请选择项目封面。首次提交必须上传 PNG、JPEG 或 WebP 图片。');
|
||||
return;
|
||||
}
|
||||
if (!trimmedCreatorName) {
|
||||
setFormError('请填写发布者姓名。');
|
||||
return;
|
||||
@@ -271,7 +334,7 @@ export function ProjectPublishAction({ project, projectType, buttonVariant = 'de
|
||||
difficulty: difficulty.trim() || null,
|
||||
};
|
||||
setDialogOpen(false);
|
||||
await submitProject(metadata);
|
||||
await submitProject(metadata, cover);
|
||||
}
|
||||
|
||||
const locked = checkingProjectStatus
|
||||
@@ -326,7 +389,7 @@ export function ProjectPublishAction({ project, projectType, buttonVariant = 'de
|
||||
<DialogDescription>
|
||||
{existingProject
|
||||
? '当前发布契约不支持安全修改已有作品资料。本次只会提交新的构建版本,现有名称、简介、作者信息和封面都会保持不变。'
|
||||
: '首次创建会保存这些作品信息并提交构建结果。当前发布契约暂不支持上传封面,因此本次作品将不设置封面。发布者年龄指作者本人年龄。'}
|
||||
: '首次创建会原子保存作品信息与项目封面,再提交构建结果。发布者年龄指作者本人年龄。'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -400,6 +463,40 @@ export function ProjectPublishAction({ project, projectType, buttonVariant = 'de
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="project-publish-cover">项目封面 <span className="text-destructive">*</span></Label>
|
||||
<Input
|
||||
id="project-publish-cover"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
className="sr-only"
|
||||
onChange={(event) => void handleCoverChange(event.target.files?.[0])}
|
||||
/>
|
||||
<div className="flex min-w-0 items-center gap-3 rounded-xl border border-border/80 bg-muted/30 p-3">
|
||||
{coverPreview ? (
|
||||
<img
|
||||
src={coverPreview}
|
||||
alt="项目封面预览"
|
||||
className="h-20 w-20 shrink-0 rounded-xl object-cover outline outline-1 -outline-offset-1 outline-black/10"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-20 w-20 shrink-0 items-center justify-center rounded-xl bg-muted text-xs text-muted-foreground [box-shadow:inset_0_0_0_1px_rgba(0,0,0,0.1)]">
|
||||
暂无封面
|
||||
</div>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium">{cover?.fileName ?? '请选择一张图片'}</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">PNG、JPEG 或 WebP,最大 10 MiB</p>
|
||||
<label
|
||||
htmlFor="project-publish-cover"
|
||||
className="mt-2 inline-flex min-h-10 cursor-pointer items-center rounded-md border border-input bg-background px-4 text-sm font-medium hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
{cover ? '重新选择' : '选择封面'}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="project-publish-age-band">适龄范围(选填)</Label>
|
||||
@@ -424,7 +521,7 @@ export function ProjectPublishAction({ project, projectType, buttonVariant = 'de
|
||||
</div>
|
||||
|
||||
<p className="rounded-xl bg-muted/50 px-3 py-2 text-xs text-muted-foreground">
|
||||
作品类型:{projectType}。名称、简介和作者信息会交给运营端统一审核;本次不上传封面。
|
||||
作品类型:{projectType}。名称、简介、作者信息和封面会交给运营端统一审核。
|
||||
</p>
|
||||
</>}
|
||||
|
||||
@@ -451,14 +548,14 @@ export function ProjectPublishAction({ project, projectType, buttonVariant = 'de
|
||||
{phase === 'submitting'
|
||||
? existingProject
|
||||
? '作品资料与封面保持不变;Makelore 正在生成并提交新版本。'
|
||||
: 'Makelore 正在创建无封面作品、生成本次构建结果并提交,不需要准备 ZIP。'
|
||||
: 'Makelore 正在创建作品并上传封面、生成本次构建结果,不需要准备 ZIP。'
|
||||
: phase === 'polling'
|
||||
? existingProject
|
||||
? '新版本已上传,现有作品资料与封面保持不变,正在等待平台校验。'
|
||||
: '无封面作品与本次构建结果已提交,正在等待平台校验。'
|
||||
: '作品封面与本次构建结果已提交,正在等待平台校验。'
|
||||
: existingProject
|
||||
? '新版本已提交,现有作品资料与封面保持不变。'
|
||||
: '无封面作品已提交,等待运营审核。审核通过后会直接发布。'}
|
||||
: '作品与封面已提交,等待运营审核。审核通过后会直接发布。'}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user