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
9 changes: 9 additions & 0 deletions public/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,14 @@
"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",
"MaidataWillInheritHistory": "This upload will inherit previous scores or interactions",
"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 All @@ -156,6 +164,7 @@
"DislikeAction": "Dislike",
"CancelSuccess": "Cancelled successfully",
"Success": " successful",
"NotLoggedIn": "Not logged in",
"FailedLoginPrompt": " failed: Are you logged in?",
"PleaseWait": "Please wait",
"Sending": "Sending...",
Expand Down
9 changes: 9 additions & 0 deletions public/i18n/ja.json
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,14 @@
"AvatarSettings": "アバター設定",
"PersonalIntro": "自己紹介",
"NoFileSelected": "ファイルが選択されていません: ",
"MaidataHashChecking": "この譜面が既に存在するか確認しています...",
"MaidataHashNotFound": "既存の譜面は見つかりませんでした",
"MaidataAlreadyExists": "この譜面はすでにサイト上に存在します",
"MaidataAlreadyExistsWithTitle": "この譜面はすでにサイト上に存在します",
"MaidataWillInheritScores": "今回のアップロードは以前のスコアを引き継ぎます",
"MaidataWillInheritHistory": "今回のアップロードは以前のスコアまたはインタラクションを引き継ぎます",
"MaidataHashLookupFailed": "maidata.txt のハッシュ照会に失敗しました",
"View": "表示",
"ManageYourCharts": "アップロードした譜面を管理",
"ModifyPersonalInfo": "個人情報と設定を変更",
"ViewYourHomePage": "個人ホームページを表示",
Expand All @@ -156,6 +164,7 @@
"DislikeAction": "よくない",
"CancelSuccess": "キャンセルしました",
"Success": "成功",
"NotLoggedIn": "未ログイン",
"FailedLoginPrompt": "失敗:ログインしていますか?",
"PleaseWait": "お待ちください",
"Sending": "送信中...",
Expand Down
9 changes: 9 additions & 0 deletions public/i18n/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,14 @@
"AvatarSettings": "아바타 설정",
"PersonalIntro": "자기소개",
"NoFileSelected": "파일이 선택되지 않았습니다: ",
"MaidataHashChecking": "이 채보가 이미 존재하는지 확인하는 중...",
"MaidataHashNotFound": "기존 채보를 찾지 못했습니다",
"MaidataAlreadyExists": "이 채보는 이미 사이트에 존재합니다",
"MaidataAlreadyExistsWithTitle": "이 채보는 이미 사이트에 존재합니다",
"MaidataWillInheritScores": "이번 업로드는 이전 점수를 이어받습니다",
"MaidataWillInheritHistory": "이번 업로드는 이전 점수 또는 상호작용을 이어받습니다",
"MaidataHashLookupFailed": "maidata.txt 해시 조회에 실패했습니다",
"View": "보기",
"ManageYourCharts": "내가 올린 채보를 관리하세요",
"ModifyPersonalInfo": "개인 정보와 설정을 수정하세요",
"ViewYourHomePage": "내 개인 홈을 확인하세요",
Expand All @@ -156,6 +164,7 @@
"DislikeAction": "좋아요 취소",
"CancelSuccess": "취소되었습니다",
"Success": " 성공했습니다",
"NotLoggedIn": "로그인하지 않음",
"FailedLoginPrompt": " 실패했습니다: 로그인했나요?",
"PleaseWait": "잠시만 기다려 주세요",
"Sending": "전송 중...",
Expand Down
9 changes: 9 additions & 0 deletions public/i18n/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,14 @@
"AvatarSettings": "头像设置",
"PersonalIntro": "个人简介",
"NoFileSelected": "没有选中",
"MaidataHashChecking": "正在查询这个谱面是否已存在...",
"MaidataHashNotFound": "未找到已存在的谱面",
"MaidataAlreadyExists": "这个谱面已经存在于网站上",
"MaidataAlreadyExistsWithTitle": "这个谱面已经存在于网站上",
"MaidataWillInheritScores": "这次上传会继承之前的分数",
"MaidataWillInheritHistory": "这次上传会继承之前的分数或互动",
"MaidataHashLookupFailed": "查询 maidata.txt 哈希失败",
"View": "查看",
"ManageYourCharts": "管理您上传的谱面",
"ModifyPersonalInfo": "修改个人信息和设置",
"ViewYourHomePage": "查看您的个人主页",
Expand All @@ -158,6 +166,7 @@
"DislikeAction": "点踩",
"CancelSuccess": "取消成功",
"Success": "成功",
"NotLoggedIn": "未登录",
"FailedLoginPrompt": "失败:登录了吗?",
"PleaseWait": "请稍候",
"Sending": "正在发送...",
Expand Down
188 changes: 184 additions & 4 deletions src/components/user/ChartUploader.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,154 @@
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 { Link } from 'react-router-dom';
import { endpoints } from '@/config/api';
import { useLoc } from '@/hooks';
import { useLoc, useUserContext } from '@/hooks';
import { getDisplayMessage, sleep } from '@/utils';
import { getFileKey, hashCandidatesFromBytes } from '@/utils/maidataHash';
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' | 'inheritsHistory' | 'loginRequired' | 'error';

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

interface HashStatusResponse {
exists?: boolean;
hasHistoricalScores?: boolean;
hasHistoricalInteract?: boolean;
Comment thread
HakureiYoru marked this conversation as resolved.
chart?: Song | null;
}

export default function ChartUploader() {
const loc = useLoc();
const { user, isLoading: isUserLoading } = useUserContext();
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 {
if (!user) {
const nextState: HashLookupState = {
status: 'loginRequired',
fileKey,
message: isUserLoading
? loc('MaidataHashChecking', 'Checking whether this chart already exists...')
: loc('NotLoggedIn', 'Not logged in'),
};
setHashLookup(nextState);
return nextState;
}

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;
const hasHistoricalScores = response.data.hasHistoricalScores;
const hasHistoricalInteract = response.data.hasHistoricalInteract;
const chart = response.data.chart ?? undefined;
const result: Pick<HashLookupState, 'status' | 'chart'> | null = exists
? { status: 'exists', chart }
: hasHistoricalScores || hasHistoricalInteract
? { status: 'inheritsHistory' }
: null;

if (result) {
const nextState: HashLookupState = {
status: result.status,
fileKey,
hash,
chart: result.chart,
};
if (nextState.status === 'inheritsHistory') {
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 isUnauthorized = e instanceof AxiosError && e.response?.status === 401;
const nextState: HashLookupState = {
status: isUnauthorized ? 'loginRequired' : 'error',
fileKey,
message: isUnauthorized
? loc('NotLoggedIn', 'Not logged in')
: 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 +178,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 +225,27 @@ 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'),
inheritsHistory: loc('MaidataWillInheritHistory', 'This upload will inherit previous scores or interactions'),
loginRequired: hashLookup.message || loc('NotLoggedIn', 'Not logged in'),
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',
inheritsHistory: 'text-amber-300',
loginRequired: 'text-red-300',
error: 'text-red-300',
};
const cannotUploadByHash = hashLookup.status === 'checking' || hashLookup.status === 'exists' || hashLookup.status === 'loginRequired';

return (
<motion.div
Expand Down Expand Up @@ -115,22 +279,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 && (
<Link
className="text-blue-300 hover:text-blue-200 underline"
to={`/song?id=${encodeURIComponent(hashLookup.chart.id)}`}
>
{loc('View', 'View')}
</Link>
)}
</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
Loading