Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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 on lines +1 to 3
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 {
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;
}

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 on lines +93 to +95

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 on lines +224 to +227

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 on lines +335 to +341
</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