Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions public/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,13 @@
"AvatarSettings": "Avatar Settings",
"PersonalIntro": "Personal Introduction",
"NoFileSelected": "No file selected for ",
"MaidataHashChecking": "Checking whether this chart already exists...",
"MaidataHashNotFound": "No existing chart found",
"MaidataAlreadyExists": "This chart already exists on the site",
"MaidataAlreadyExistsWithTitle": "This chart already exists on the site",
"MaidataWillInheritScores": "This upload will inherit previous scores",
"MaidataHashLookupFailed": "Failed to query maidata.txt hash",
"View": "View",
"ManageYourCharts": "Manage your uploaded charts",
"ModifyPersonalInfo": "Modify personal information and settings",
"ViewYourHomePage": "View your personal homepage",
Expand Down
7 changes: 7 additions & 0 deletions public/i18n/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,13 @@
"AvatarSettings": "アバター設定",
"PersonalIntro": "自己紹介",
"NoFileSelected": "ファイルが選択されていません: ",
"MaidataHashChecking": "この譜面が既に存在するか確認しています...",
"MaidataHashNotFound": "既存の譜面は見つかりませんでした",
"MaidataAlreadyExists": "この譜面はすでにサイト上に存在します",
"MaidataAlreadyExistsWithTitle": "この譜面はすでにサイト上に存在します",
"MaidataWillInheritScores": "今回のアップロードは以前のスコアを引き継ぎます",
"MaidataHashLookupFailed": "maidata.txt のハッシュ照会に失敗しました",
"View": "表示",
"ManageYourCharts": "アップロードした譜面を管理",
"ModifyPersonalInfo": "個人情報と設定を変更",
"ViewYourHomePage": "個人ホームページを表示",
Expand Down
7 changes: 7 additions & 0 deletions public/i18n/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,13 @@
"AvatarSettings": "아바타 설정",
"PersonalIntro": "자기소개",
"NoFileSelected": "파일이 선택되지 않았습니다: ",
"MaidataHashChecking": "이 채보가 이미 존재하는지 확인하는 중...",
"MaidataHashNotFound": "기존 채보를 찾지 못했습니다",
"MaidataAlreadyExists": "이 채보는 이미 사이트에 존재합니다",
"MaidataAlreadyExistsWithTitle": "이 채보는 이미 사이트에 존재합니다",
"MaidataWillInheritScores": "이번 업로드는 이전 점수를 이어받습니다",
"MaidataHashLookupFailed": "maidata.txt 해시 조회에 실패했습니다",
"View": "보기",
"ManageYourCharts": "내가 올린 채보를 관리하세요",
"ModifyPersonalInfo": "개인 정보와 설정을 수정하세요",
"ViewYourHomePage": "내 개인 홈을 확인하세요",
Expand Down
7 changes: 7 additions & 0 deletions public/i18n/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,13 @@
"AvatarSettings": "头像设置",
"PersonalIntro": "个人简介",
"NoFileSelected": "没有选中",
"MaidataHashChecking": "正在查询这个谱面是否已存在...",
"MaidataHashNotFound": "未找到已存在的谱面",
"MaidataAlreadyExists": "这个谱面已经存在于网站上",
"MaidataAlreadyExistsWithTitle": "这个谱面已经存在于网站上",
"MaidataWillInheritScores": "这次上传会继承之前的分数",
"MaidataHashLookupFailed": "查询 maidata.txt 哈希失败",
"View": "查看",
"ManageYourCharts": "管理您上传的谱面",
"ModifyPersonalInfo": "修改个人信息和设置",
"ViewYourHomePage": "查看您的个人主页",
Expand Down
227 changes: 224 additions & 3 deletions src/components/user/ChartUploader.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,197 @@
import { useState } from 'react';
import { useRef, useState } from 'react';
import { toast } from 'react-toastify';
import axios, { AxiosError } from 'axios';
Comment thread
HakureiYoru marked this conversation as resolved.
import { md5 } from 'js-md5';
import { endpoints } from '@/config/api';
import { useLoc } from '@/hooks';
import { getDisplayMessage, sleep } from '@/utils';
import { motion } from 'framer-motion';
import { MdOutlineAudioFile, MdOutlineDescription, MdOutlineImage, MdOutlineVideoFile, MdCloudUpload } from 'react-icons/md';
import { LoadingSpinner } from '@/components';
import type { Song } from '@/types';

type HashLookupStatus = 'idle' | 'checking' | 'notFound' | 'exists' | 'inheritsScores' | 'error';

interface HashLookupState {
Comment thread
HakureiYoru marked this conversation as resolved.
status: HashLookupStatus;
fileKey?: string;
hash?: string;
chart?: Song;
message?: string;
}

interface HashStatusResponse {
hash?: string;
Hash?: string;
exists?: boolean;
Exists?: boolean;
hasHistoricalScores?: boolean;
HasHistoricalScores?: boolean;
willInheritScores?: boolean;
WillInheritScores?: boolean;
chart?: Song | null;
Chart?: Song | null;
}

function getFileKey(file: File) {
return `${file.name}:${file.size}:${file.lastModified}`;
}

function addUnique<T>(items: T[], item: T) {
if (!items.includes(item)) {
items.push(item);
}
}

function normalizeLines(text: string) {
return text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
}

function encodeUtf8(text: string) {
return new TextEncoder().encode(text);
}

function hashCandidatesFromBytes(bytes: Uint8Array) {
const candidates: string[] = [];
const add = (value: Uint8Array | ArrayBuffer | number[]) => {
addUnique(candidates, md5.base64(value));
};

add(bytes);

if (bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) {
add(bytes.slice(3));
}

const decoder = new TextDecoder('utf-8', { fatal: false });
const rawText = decoder.decode(bytes);
const text = rawText.charCodeAt(0) === 0xfeff ? rawText.slice(1) : rawText;
const normalized = normalizeLines(text);

add(encodeUtf8(normalized));
add(encodeUtf8(normalized.split('\n').filter((line) => line.trim() !== '').join('\n')));

const nonBlank = normalized.split('\n').filter((line) => line.trim() !== '');
const firstLevelIndex = nonBlank.findIndex((line) => line.trimStart().toLowerCase().startsWith('&lv_'));
if (firstLevelIndex !== -1) {
const normalizedLevelGap = [
...nonBlank.slice(0, firstLevelIndex),
'',
...nonBlank.slice(firstLevelIndex),
].join('\n');
add(encodeUtf8(normalizedLevelGap));
}

add(encodeUtf8(rawText.replace(/\n/g, '\r\n')));

return candidates;
}
Comment thread
HakureiYoru marked this conversation as resolved.
Outdated

export default function ChartUploader() {
const loc = useLoc();
const [isUploading, setIsUploading] = useState(false);
const [hashLookup, setHashLookup] = useState<HashLookupState>({ status: 'idle' });
const hashLookupSeq = useRef(0);
const hashLookupAbort = useRef<AbortController | null>(null);
Comment thread
HakureiYoru marked this conversation as resolved.

async function lookupMaidataHash(file: File) {
const fileKey = getFileKey(file);
const seq = hashLookupSeq.current + 1;
hashLookupSeq.current = seq;
hashLookupAbort.current?.abort();
const abortController = new AbortController();
hashLookupAbort.current = abortController;

setHashLookup({ status: 'checking', fileKey });

try {
const bytes = new Uint8Array(await file.arrayBuffer());
const hashes = hashCandidatesFromBytes(bytes);
let inheritedState: HashLookupState | null = null;

for (const hash of hashes) {
const response = await axios.get<HashStatusResponse>(endpoints.maichart.hashStatus(hash), {
withCredentials: true,
signal: abortController.signal,
});
const exists = response.data.exists ?? response.data.Exists;
const hasHistoricalScores = response.data.hasHistoricalScores ?? response.data.HasHistoricalScores;
const willInheritScores = response.data.willInheritScores ?? response.data.WillInheritScores;
const chart = response.data.chart ?? response.data.Chart ?? undefined;
const result: Pick<HashLookupState, 'status' | 'chart'> | null = exists
? { status: 'exists', chart }
: willInheritScores || hasHistoricalScores
? { status: 'inheritsScores' }
: null;

if (result) {
const nextState: HashLookupState = {
status: result.status,
fileKey,
hash,
chart: result.chart,
};
if (nextState.status === 'inheritsScores') {
inheritedState ??= nextState;
continue;
}

if (hashLookupSeq.current === seq) {
setHashLookup(nextState);
}
return nextState;
}
}

if (inheritedState) {
if (hashLookupSeq.current === seq) {
setHashLookup(inheritedState);
}
return inheritedState;
}

const nextState: HashLookupState = { status: 'notFound', fileKey };
if (hashLookupSeq.current === seq) {
setHashLookup(nextState);
}
return nextState;
} catch (e: unknown) {
if (axios.isCancel(e) || (e instanceof AxiosError && e.code === 'ERR_CANCELED')) {
return { status: 'checking', fileKey } satisfies HashLookupState;
}

const error = e as { response?: { data?: unknown }; message?: string };
const nextState: HashLookupState = {
status: 'error',
fileKey,
message: getDisplayMessage(error.response?.data ?? error.message, loc('MaidataHashLookupFailed', 'Failed to query maidata hash')),
};
if (hashLookupSeq.current === seq) {
setHashLookup(nextState);
}
return nextState;
}
}

function resetHashLookup() {
hashLookupSeq.current += 1;
hashLookupAbort.current?.abort();
setHashLookup({ status: 'idle' });
}

function onFileChange(index: number, event: React.ChangeEvent<HTMLInputElement>) {
if (index !== 0) {
return;
}

const file = event.currentTarget.files?.[0];
if (!file) {
resetHashLookup();
return;
}

void lookupMaidataHash(file);
}

async function onSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
Expand Down Expand Up @@ -40,6 +221,11 @@ export default function ChartUploader() {
return;
}

if (hashLookup.status === 'exists') {
toast.error(loc('MaidataAlreadyExists', 'This chart already exists on the site'), { autoClose: false });
return;
}
Comment thread
HakureiYoru marked this conversation as resolved.

const uploading = toast.loading(loc('Uploading'), {
hideProgressBar: false,
});
Expand Down Expand Up @@ -82,6 +268,25 @@ export default function ChartUploader() {
{ label: 'track.mp3', icon: <MdOutlineAudioFile className="text-gray-400 group-hover:text-white text-2xl transition-colors" /> },
{ label: loc('BGVideoHint'), icon: <MdOutlineVideoFile className="text-gray-400 group-hover:text-white text-2xl transition-colors" /> },
];
const hashLookupStatusText: Record<HashLookupStatus, string> = {
idle: '',
checking: loc('MaidataHashChecking', 'Checking whether this chart already exists...'),
notFound: loc('MaidataHashNotFound', 'No existing chart found'),
exists: hashLookup.chart
? loc('MaidataAlreadyExistsWithTitle', 'This chart already exists on the site') + `: ${hashLookup.chart.title}`
: loc('MaidataAlreadyExists', 'This chart already exists on the site'),
inheritsScores: loc('MaidataWillInheritScores', 'This upload will inherit previous scores'),
error: hashLookup.message || loc('MaidataHashLookupFailed', 'Failed to query maidata hash'),
};
const hashLookupStatusClass: Record<HashLookupStatus, string> = {
idle: '',
checking: 'text-blue-300',
notFound: 'text-emerald-300',
exists: 'text-red-300',
inheritsScores: 'text-amber-300',
error: 'text-red-300',
};
const cannotUploadByHash = hashLookup.status === 'checking' || hashLookup.status === 'exists';

return (
<motion.div
Expand Down Expand Up @@ -115,22 +320,38 @@ export default function ChartUploader() {
type="file"
name="formfiles"
disabled={isUploading}
onChange={(event) => onFileChange(index, event)}
/>
</div>
</div>
))}
</div>

{hashLookup.status !== 'idle' && (
<div className={`flex items-center gap-2 text-sm ${hashLookupStatusClass[hashLookup.status]}`}>
{hashLookup.status === 'checking' && <LoadingSpinner className="w-4 h-4" />}
<span>{hashLookupStatusText[hashLookup.status]}</span>
{hashLookup.status === 'exists' && hashLookup.chart && (
<a
className="text-blue-300 hover:text-blue-200 underline"
href={`/song?id=${encodeURIComponent(hashLookup.chart.id)}`}
>
{loc('View', 'View')}
</a>
)}
Comment thread
Copilot marked this conversation as resolved.
Outdated
</div>
)}

<motion.button
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
className={`mt-4 w-full py-3 rounded-xl font-bold text-gray-200 shadow-lg transition-all duration-200 flex items-center justify-center gap-2 border border-white/5
${isUploading
${isUploading || cannotUploadByHash
? 'bg-red-900/50 cursor-not-allowed opacity-70'
: 'bg-[#2e2e2e] hover:bg-[#3a3a3a] hover:border-white/20 hover:shadow-[0_0_15px_rgba(255,255,255,0.1)]'
}`}
type="submit"
disabled={isUploading}
disabled={isUploading || cannotUploadByHash}
>
{isUploading ? (
<>
Expand Down
1 change: 1 addition & 0 deletions src/config/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export const endpoints = {
listSearch: (searchKeyword: string) => `${apiroot3}/maichart/list?sort=&search=${encodeURIComponent(searchKeyword)}`,
listRanking: (sortType: string) => `${apiroot3}/maichart/list?&isRanking=true&sort=${encodeURIComponent(sortType)}`,
upload: `${apiroot3}/maichart/upload`,
hashStatus: (hash: string) => `${apiroot3}/maichart/hash-status?hash=${encodeURIComponent(hash)}`,
delete: (chartId: string) => `${apiroot3}/maichart/delete?chartId=${chartId}`,
summary: (id: string | number) => `${apiroot3}/maichart/${id}/summary`,
image: (id: string | number) => `${apiroot3}/maichart/${id}/image`,
Expand Down