feat : able to rotate img for mat card
CI / Verify (push) Successful in 1m46s
CI / Build immutable images and deploy (push) Successful in 2m3s

This commit is contained in:
2026-09-19 20:20:02 +07:00 Unverified
parent 26517ea5fd
commit 7c10b2c995
12 changed files with 2962 additions and 41 deletions
+14 -1
View File
@@ -156,6 +156,7 @@ export function GuideOverviewForm({
posterArtOffsetX: number;
posterArtOffsetY: number;
posterArtScale: number;
posterArtRotation: number;
posterTopColors: [string, string];
posterBottomColors: [string, string];
posterBleeding: Array<{ id: string; mediaId: string; imageUrl: string | null; amount: number; scale: number; blendMode: PosterBlendMode; sortOrder: number }>;
@@ -177,6 +178,7 @@ export function GuideOverviewForm({
y: guide.posterArtOffsetY,
});
const [posterArtScale, setPosterArtScale] = useState(guide.posterArtScale);
const [posterArtRotation, setPosterArtRotation] = useState(guide.posterArtRotation);
const [posterTopColors, setPosterTopColors] = useState(guide.posterTopColors);
const [posterBottomColors, setPosterBottomColors] = useState(guide.posterBottomColors);
const [bleeding, setBleeding] = useState<BleedingLayer[]>(guide.posterBleeding.map(({ id, mediaId, imageUrl, amount, blendMode }) => ({ id, mediaId, imageUrl, amount, blendMode })));
@@ -190,6 +192,7 @@ export function GuideOverviewForm({
posterArtOffsetX: posterArtOffset.x,
posterArtOffsetY: posterArtOffset.y,
posterArtScale,
posterArtRotation,
posterTopColors,
posterBottomColors,
posterBleeding: bleeding.map(({ mediaId, amount, blendMode }) => ({ mediaId, amount, scale: 100, blendMode })),
@@ -201,7 +204,7 @@ export function GuideOverviewForm({
].filter((issue): issue is string => Boolean(issue));
const invalidCover = !cover.id;
const invalidOverview = !overview.trim();
const localDraft = { overview, youtubeLinks, cover, posterArt, posterArtOffset, posterArtScale, posterTopColors, posterBottomColors, bleeding };
const localDraft = { overview, youtubeLinks, cover, posterArt, posterArtOffset, posterArtScale, posterArtRotation, posterTopColors, posterBottomColors, bleeding };
const autosave = useAutosave({
guideId: guide.id,
scope: "overview",
@@ -257,6 +260,7 @@ export function GuideOverviewForm({
}
: { x: guide.posterArtOffsetX, y: guide.posterArtOffsetY },
posterArtScale: typeof draft.posterArtScale === "number" ? Math.max(50, Math.min(500, draft.posterArtScale)) : guide.posterArtScale,
posterArtRotation: typeof draft.posterArtRotation === "number" ? Math.max(-180, Math.min(180, draft.posterArtRotation)) : guide.posterArtRotation,
posterTopColors: Array.isArray(draft.posterTopColors) && draft.posterTopColors.length === 2
? draft.posterTopColors as [string, string]
: guide.posterTopColors,
@@ -281,6 +285,7 @@ export function GuideOverviewForm({
setPosterArt(draft.posterArt);
setPosterArtOffset(draft.posterArtOffset);
setPosterArtScale(draft.posterArtScale);
setPosterArtRotation(draft.posterArtRotation);
setPosterTopColors(draft.posterTopColors);
setPosterBottomColors(draft.posterBottomColors);
setBleeding(draft.bleeding);
@@ -318,6 +323,7 @@ export function GuideOverviewForm({
function setUploadedPoster(asset: { id: string; url: string }) {
setPosterArt(asset);
setPosterArtOffset({ x: 0, y: 0 });
setPosterArtRotation(0);
setBleeding((current) => current.length
? current.map((layer) => ({ ...layer, mediaId: asset.id, imageUrl: asset.url }))
: [{ id: createBleedingLayerId(), mediaId: asset.id, imageUrl: asset.url, amount: 65, blendMode: "multiply" }]);
@@ -327,6 +333,7 @@ export function GuideOverviewForm({
setPosterArt({ id: null, url: null });
setPosterArtOffset({ x: 0, y: 0 });
setPosterArtScale(100);
setPosterArtRotation(0);
setPosterTopColors(["#000000", "#000000"]);
setPosterBottomColors(["#000000", "#000000"]);
setBleeding([]);
@@ -441,6 +448,7 @@ export function GuideOverviewForm({
artworkOffsetX={posterArtOffset.x}
artworkOffsetY={posterArtOffset.y}
artworkScale={posterArtScale}
artworkRotation={posterArtRotation}
topColors={posterTopColors}
bottomColors={posterBottomColors}
bleeding={bleeding}
@@ -475,6 +483,7 @@ export function GuideOverviewForm({
artworkOffsetX={posterArtOffset.x}
artworkOffsetY={posterArtOffset.y}
artworkScale={posterArtScale}
artworkRotation={posterArtRotation}
topColors={posterTopColors}
bottomColors={posterBottomColors}
bleeding={bleeding}
@@ -503,6 +512,10 @@ export function GuideOverviewForm({
<div className="flex items-center justify-between gap-2"><FieldLabel htmlFor="poster-art-scale">Character image zoom</FieldLabel><span className="text-sm text-muted-foreground">{posterArtScale}%</span></div>
<Slider id="poster-art-scale" min={50} max={500} step={5} value={[posterArtScale]} onValueChange={(value) => setPosterArtScale(typeof value === "number" ? value : value[0] ?? 100)} />
</Field>
<Field className="rounded-lg border p-3">
<div className="flex items-center justify-between gap-2"><FieldLabel htmlFor="poster-art-rotation">Character image rotation</FieldLabel><span className="text-sm text-muted-foreground">{posterArtRotation}°</span></div>
<Slider id="poster-art-rotation" min={-180} max={180} step={1} value={[posterArtRotation]} onValueChange={(value) => setPosterArtRotation(typeof value === "number" ? value : value[0] ?? 0)} />
</Field>
<FieldDescription>
อัปโหลดภาพตัวละคร แล้วลากบนตัวอย่างเพื่อจัดตำแหน่ง สีด้านบนและล่างจะตรวจให้อัตโนมัติ
</FieldDescription>
@@ -173,6 +173,7 @@ export function ArtifactImageExport({
height={exportHeight}
disabled={sections.size === 0}
captureClassName="p-0"
modeSelection="inline"
>
<style>{hiddenSections + targetRule + recommendedRule + tierRule + hideEmptyRightColumn + layoutRules}</style>
{children}
+81 -27
View File
@@ -6,6 +6,13 @@ import { DownloadIcon } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ExportLanguageDialog } from "@/components/public/export-language-dialog";
import {
ExportLanguageToggle,
@@ -57,6 +64,7 @@ export function GuideImageExport({
showLanguageSelector = true,
promptForLanguage = false,
localizeFileName = true,
modeSelection = "dialog",
}: {
children: ReactNode;
fileName: string;
@@ -72,6 +80,7 @@ export function GuideImageExport({
showLanguageSelector?: boolean;
promptForLanguage?: boolean;
localizeFileName?: boolean;
modeSelection?: "dialog" | "inline";
}) {
const captureRef = useRef<HTMLDivElement>(null);
const [captureMode, setCaptureMode] = useState<
@@ -83,6 +92,13 @@ export function GuideImageExport({
const [pendingCaptureMode, setPendingCaptureMode] = useState<
"with-notes" | "without-notes" | null
>(null);
const [modeDialogOpen, setModeDialogOpen] = useState(false);
function requestCapture(mode: "with-notes" | "without-notes") {
setModeDialogOpen(false);
if (promptForLanguage) setPendingCaptureMode(mode);
else setCaptureMode(mode);
}
function selectLanguage(nextLanguage: ExportLanguage) {
if (!pendingCaptureMode) return;
@@ -91,6 +107,37 @@ export function GuideImageExport({
setPendingCaptureMode(null);
}
function exportButton(
mode: "with-notes" | "without-notes" | null,
label: string,
) {
const loading = capturing && (mode === null || captureMode === mode);
return (
<Button
key={mode ?? "export"}
type="button"
variant="outline"
size="sm"
className={mobileIconOnly ? "max-sm:size-7 max-sm:px-0 max-sm:has-data-[icon=inline-start]:pl-0" : undefined}
disabled={capturing || disabled}
onClick={() => {
if (mode) requestCapture(mode);
else if (allowWithoutNotes) setModeDialogOpen(true);
else requestCapture("with-notes");
}}
>
{loading ? (
<Spinner data-icon="inline-start" />
) : (
<DownloadIcon data-icon="inline-start" />
)}
<span className={mobileIconOnly ? "sr-only sm:not-sr-only" : undefined}>
{loading ? "กำลังส่งออก…" : label}
</span>
</Button>
);
}
useEffect(() => {
if (!capturing) return;
@@ -161,34 +208,41 @@ export function GuideImageExport({
<ExportLanguageToggle language={language} onChange={setStoredLanguage} />
) : null}
<div className="flex flex-wrap gap-2">
{(["with-notes", "without-notes"] as const).filter((mode) => allowWithoutNotes || mode === "with-notes").map((mode) => (
<Button
key={mode}
type="button"
variant="outline"
size="sm"
className={mobileIconOnly ? "max-sm:size-7 max-sm:px-0 max-sm:has-data-[icon=inline-start]:pl-0" : undefined}
disabled={capturing || disabled}
onClick={() => {
if (promptForLanguage) setPendingCaptureMode(mode);
else setCaptureMode(mode);
}}
>
{captureMode === mode ? (
<Spinner data-icon="inline-start" />
) : (
<DownloadIcon data-icon="inline-start" />
)}
<span className={mobileIconOnly ? "sr-only sm:not-sr-only" : undefined}>
{captureMode === mode
? "กำลังส่งออก…"
: mode === "without-notes"
? "ส่งออกภาพโดยไม่รวมโน้ต"
: buttonLabel}
</span>
</Button>
))}
{allowWithoutNotes && modeSelection === "inline"
? [
exportButton("with-notes", buttonLabel),
exportButton("without-notes", "ส่งออกภาพโดยไม่รวมโน้ต"),
]
: exportButton(null, buttonLabel)}
</div>
{allowWithoutNotes && modeSelection === "dialog" ? (
<Dialog open={modeDialogOpen} onOpenChange={setModeDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>เลือกรูปแบบการส่งออก</DialogTitle>
<DialogDescription>เลือกว่าจะรวมโน้ตไว้ในภาพหรือไม่</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2 sm:flex-row">
<Button
type="button"
variant="secondary"
className="flex-1"
onClick={() => requestCapture("with-notes")}
>
ส่งออกพร้อมโน้ต
</Button>
<Button
type="button"
variant="secondary"
className="flex-1"
onClick={() => requestCapture("without-notes")}
>
ส่งออกโดยไม่รวมโน้ต
</Button>
</div>
</DialogContent>
</Dialog>
) : null}
<ExportLanguageDialog
open={pendingCaptureMode !== null}
onOpenChange={(open) => {
+3 -1
View File
@@ -59,6 +59,7 @@ export function MaterialPoster({
artworkOffsetX,
artworkOffsetY,
artworkScale,
artworkRotation,
topColors,
bottomColors,
interactive = false,
@@ -78,6 +79,7 @@ export function MaterialPoster({
onArtworkMove?: (offset: { x: number; y: number }) => void;
className?: string;
artworkScale: number;
artworkRotation: number;
bleeding?: Array<{ id: string; imageUrl: string | null; amount: number; blendMode: PosterBlendMode }>;
}) {
const dragStart = useRef<{ pointerId: number; x: number; y: number; offsetX: number; offsetY: number } | null>(null);
@@ -142,7 +144,7 @@ export function MaterialPoster({
{characterArtUrl ? (
<div
className="pointer-events-none absolute inset-0"
style={{ transform: `translate(${artworkOffsetX}%, ${artworkOffsetY}%) scale(${artworkScale / 100})` }}
style={{ transform: `translate(${artworkOffsetX}%, ${artworkOffsetY}%) scale(${artworkScale / 100}) rotate(${artworkRotation}deg)` }}
>
<Image
src={characterArtUrl}
+15 -6
View File
@@ -235,6 +235,7 @@ function GuideOverview({
artworkOffsetX={guide.posterArtOffsetX}
artworkOffsetY={guide.posterArtOffsetY}
artworkScale={guide.posterArtScale}
artworkRotation={guide.posterArtRotation}
topColors={guide.posterTopColors}
bottomColors={guide.posterBottomColors}
bleeding={guide.posterBleeding}
@@ -256,6 +257,7 @@ function GuideOverview({
artworkOffsetX={guide.posterArtOffsetX}
artworkOffsetY={guide.posterArtOffsetY}
artworkScale={guide.posterArtScale}
artworkRotation={guide.posterArtRotation}
topColors={guide.posterTopColors}
bottomColors={guide.posterBottomColors}
bleeding={guide.posterBleeding}
@@ -1000,12 +1002,12 @@ const thaiStats: Record<string, string> = {
DEF: "พลังป้องกัน",
EM: "ความชำนาญธาตุ",
ER: "ฟื้นฟูพลังงาน",
"CRIT RATE": "อัตราคริ",
"CRIT DAMAGE": "ความแรงคริ",
"CRIT RATE": "คริเรท",
"CRIT DAMAGE": "คริดาเมท",
"ATK%": "พลังโจมตี%",
"HP%": "พลังชีวิต%",
"DEF%": "พลังป้องกัน%",
"CRIT Rate/CRIT DMG": "อัตราคริ/ความแรงคริ",
"CRIT Rate/CRIT DMG": "คริเรท/คริดาเมท",
"Pyro DMG%": "โบนัสความเสียหายธาตุไฟ",
"Hydro DMG%": "โบนัสความเสียหายธาตุน้ำ",
"Cryo DMG%": "โบนัสความเสียหายธาตุน้ำแข็ง",
@@ -1028,12 +1030,19 @@ const englishStats: Record<string, string> = {
"CRIT Rate/CRIT DMG": "CRIT Rate / CRIT DMG",
};
const statLabelAliases: Record<string, string> = {
"CRIT Rate%": "CRIT RATE",
"CRIT DMG%": "CRIT DAMAGE",
"CRIT Rate%/CRIT DMG%": "CRIT Rate/CRIT DMG",
};
function statLabel(value: string, exporting = false): ReactNode {
if (!exporting) return thaiStats[value] ?? value;
const labelKey = statLabelAliases[value] ?? value;
if (!exporting) return thaiStats[labelKey] ?? value;
return (
<ExportText
th={thaiStats[value] ?? value}
en={englishStats[value] ?? value}
th={thaiStats[labelKey] ?? value}
en={englishStats[labelKey] ?? value}
/>
);
}