feat : able to rotate img for mat card
This commit is contained in:
@@ -156,6 +156,7 @@ export function GuideOverviewForm({
|
|||||||
posterArtOffsetX: number;
|
posterArtOffsetX: number;
|
||||||
posterArtOffsetY: number;
|
posterArtOffsetY: number;
|
||||||
posterArtScale: number;
|
posterArtScale: number;
|
||||||
|
posterArtRotation: number;
|
||||||
posterTopColors: [string, string];
|
posterTopColors: [string, string];
|
||||||
posterBottomColors: [string, string];
|
posterBottomColors: [string, string];
|
||||||
posterBleeding: Array<{ id: string; mediaId: string; imageUrl: string | null; amount: number; scale: number; blendMode: PosterBlendMode; sortOrder: number }>;
|
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,
|
y: guide.posterArtOffsetY,
|
||||||
});
|
});
|
||||||
const [posterArtScale, setPosterArtScale] = useState(guide.posterArtScale);
|
const [posterArtScale, setPosterArtScale] = useState(guide.posterArtScale);
|
||||||
|
const [posterArtRotation, setPosterArtRotation] = useState(guide.posterArtRotation);
|
||||||
const [posterTopColors, setPosterTopColors] = useState(guide.posterTopColors);
|
const [posterTopColors, setPosterTopColors] = useState(guide.posterTopColors);
|
||||||
const [posterBottomColors, setPosterBottomColors] = useState(guide.posterBottomColors);
|
const [posterBottomColors, setPosterBottomColors] = useState(guide.posterBottomColors);
|
||||||
const [bleeding, setBleeding] = useState<BleedingLayer[]>(guide.posterBleeding.map(({ id, mediaId, imageUrl, amount, blendMode }) => ({ id, mediaId, imageUrl, amount, blendMode })));
|
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,
|
posterArtOffsetX: posterArtOffset.x,
|
||||||
posterArtOffsetY: posterArtOffset.y,
|
posterArtOffsetY: posterArtOffset.y,
|
||||||
posterArtScale,
|
posterArtScale,
|
||||||
|
posterArtRotation,
|
||||||
posterTopColors,
|
posterTopColors,
|
||||||
posterBottomColors,
|
posterBottomColors,
|
||||||
posterBleeding: bleeding.map(({ mediaId, amount, blendMode }) => ({ mediaId, amount, scale: 100, blendMode })),
|
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));
|
].filter((issue): issue is string => Boolean(issue));
|
||||||
const invalidCover = !cover.id;
|
const invalidCover = !cover.id;
|
||||||
const invalidOverview = !overview.trim();
|
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({
|
const autosave = useAutosave({
|
||||||
guideId: guide.id,
|
guideId: guide.id,
|
||||||
scope: "overview",
|
scope: "overview",
|
||||||
@@ -257,6 +260,7 @@ export function GuideOverviewForm({
|
|||||||
}
|
}
|
||||||
: { x: guide.posterArtOffsetX, y: guide.posterArtOffsetY },
|
: { x: guide.posterArtOffsetX, y: guide.posterArtOffsetY },
|
||||||
posterArtScale: typeof draft.posterArtScale === "number" ? Math.max(50, Math.min(500, draft.posterArtScale)) : guide.posterArtScale,
|
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
|
posterTopColors: Array.isArray(draft.posterTopColors) && draft.posterTopColors.length === 2
|
||||||
? draft.posterTopColors as [string, string]
|
? draft.posterTopColors as [string, string]
|
||||||
: guide.posterTopColors,
|
: guide.posterTopColors,
|
||||||
@@ -281,6 +285,7 @@ export function GuideOverviewForm({
|
|||||||
setPosterArt(draft.posterArt);
|
setPosterArt(draft.posterArt);
|
||||||
setPosterArtOffset(draft.posterArtOffset);
|
setPosterArtOffset(draft.posterArtOffset);
|
||||||
setPosterArtScale(draft.posterArtScale);
|
setPosterArtScale(draft.posterArtScale);
|
||||||
|
setPosterArtRotation(draft.posterArtRotation);
|
||||||
setPosterTopColors(draft.posterTopColors);
|
setPosterTopColors(draft.posterTopColors);
|
||||||
setPosterBottomColors(draft.posterBottomColors);
|
setPosterBottomColors(draft.posterBottomColors);
|
||||||
setBleeding(draft.bleeding);
|
setBleeding(draft.bleeding);
|
||||||
@@ -318,6 +323,7 @@ export function GuideOverviewForm({
|
|||||||
function setUploadedPoster(asset: { id: string; url: string }) {
|
function setUploadedPoster(asset: { id: string; url: string }) {
|
||||||
setPosterArt(asset);
|
setPosterArt(asset);
|
||||||
setPosterArtOffset({ x: 0, y: 0 });
|
setPosterArtOffset({ x: 0, y: 0 });
|
||||||
|
setPosterArtRotation(0);
|
||||||
setBleeding((current) => current.length
|
setBleeding((current) => current.length
|
||||||
? current.map((layer) => ({ ...layer, mediaId: asset.id, imageUrl: asset.url }))
|
? current.map((layer) => ({ ...layer, mediaId: asset.id, imageUrl: asset.url }))
|
||||||
: [{ id: createBleedingLayerId(), mediaId: asset.id, imageUrl: asset.url, amount: 65, blendMode: "multiply" }]);
|
: [{ id: createBleedingLayerId(), mediaId: asset.id, imageUrl: asset.url, amount: 65, blendMode: "multiply" }]);
|
||||||
@@ -327,6 +333,7 @@ export function GuideOverviewForm({
|
|||||||
setPosterArt({ id: null, url: null });
|
setPosterArt({ id: null, url: null });
|
||||||
setPosterArtOffset({ x: 0, y: 0 });
|
setPosterArtOffset({ x: 0, y: 0 });
|
||||||
setPosterArtScale(100);
|
setPosterArtScale(100);
|
||||||
|
setPosterArtRotation(0);
|
||||||
setPosterTopColors(["#000000", "#000000"]);
|
setPosterTopColors(["#000000", "#000000"]);
|
||||||
setPosterBottomColors(["#000000", "#000000"]);
|
setPosterBottomColors(["#000000", "#000000"]);
|
||||||
setBleeding([]);
|
setBleeding([]);
|
||||||
@@ -441,6 +448,7 @@ export function GuideOverviewForm({
|
|||||||
artworkOffsetX={posterArtOffset.x}
|
artworkOffsetX={posterArtOffset.x}
|
||||||
artworkOffsetY={posterArtOffset.y}
|
artworkOffsetY={posterArtOffset.y}
|
||||||
artworkScale={posterArtScale}
|
artworkScale={posterArtScale}
|
||||||
|
artworkRotation={posterArtRotation}
|
||||||
topColors={posterTopColors}
|
topColors={posterTopColors}
|
||||||
bottomColors={posterBottomColors}
|
bottomColors={posterBottomColors}
|
||||||
bleeding={bleeding}
|
bleeding={bleeding}
|
||||||
@@ -475,6 +483,7 @@ export function GuideOverviewForm({
|
|||||||
artworkOffsetX={posterArtOffset.x}
|
artworkOffsetX={posterArtOffset.x}
|
||||||
artworkOffsetY={posterArtOffset.y}
|
artworkOffsetY={posterArtOffset.y}
|
||||||
artworkScale={posterArtScale}
|
artworkScale={posterArtScale}
|
||||||
|
artworkRotation={posterArtRotation}
|
||||||
topColors={posterTopColors}
|
topColors={posterTopColors}
|
||||||
bottomColors={posterBottomColors}
|
bottomColors={posterBottomColors}
|
||||||
bleeding={bleeding}
|
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>
|
<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)} />
|
<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>
|
||||||
|
<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>
|
||||||
อัปโหลดภาพตัวละคร แล้วลากบนตัวอย่างเพื่อจัดตำแหน่ง สีด้านบนและล่างจะตรวจให้อัตโนมัติ
|
อัปโหลดภาพตัวละคร แล้วลากบนตัวอย่างเพื่อจัดตำแหน่ง สีด้านบนและล่างจะตรวจให้อัตโนมัติ
|
||||||
</FieldDescription>
|
</FieldDescription>
|
||||||
|
|||||||
@@ -173,6 +173,7 @@ export function ArtifactImageExport({
|
|||||||
height={exportHeight}
|
height={exportHeight}
|
||||||
disabled={sections.size === 0}
|
disabled={sections.size === 0}
|
||||||
captureClassName="p-0"
|
captureClassName="p-0"
|
||||||
|
modeSelection="inline"
|
||||||
>
|
>
|
||||||
<style>{hiddenSections + targetRule + recommendedRule + tierRule + hideEmptyRightColumn + layoutRules}</style>
|
<style>{hiddenSections + targetRule + recommendedRule + tierRule + hideEmptyRightColumn + layoutRules}</style>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ import { DownloadIcon } from "lucide-react";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
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 { ExportLanguageDialog } from "@/components/public/export-language-dialog";
|
||||||
import {
|
import {
|
||||||
ExportLanguageToggle,
|
ExportLanguageToggle,
|
||||||
@@ -57,6 +64,7 @@ export function GuideImageExport({
|
|||||||
showLanguageSelector = true,
|
showLanguageSelector = true,
|
||||||
promptForLanguage = false,
|
promptForLanguage = false,
|
||||||
localizeFileName = true,
|
localizeFileName = true,
|
||||||
|
modeSelection = "dialog",
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
@@ -72,6 +80,7 @@ export function GuideImageExport({
|
|||||||
showLanguageSelector?: boolean;
|
showLanguageSelector?: boolean;
|
||||||
promptForLanguage?: boolean;
|
promptForLanguage?: boolean;
|
||||||
localizeFileName?: boolean;
|
localizeFileName?: boolean;
|
||||||
|
modeSelection?: "dialog" | "inline";
|
||||||
}) {
|
}) {
|
||||||
const captureRef = useRef<HTMLDivElement>(null);
|
const captureRef = useRef<HTMLDivElement>(null);
|
||||||
const [captureMode, setCaptureMode] = useState<
|
const [captureMode, setCaptureMode] = useState<
|
||||||
@@ -83,6 +92,13 @@ export function GuideImageExport({
|
|||||||
const [pendingCaptureMode, setPendingCaptureMode] = useState<
|
const [pendingCaptureMode, setPendingCaptureMode] = useState<
|
||||||
"with-notes" | "without-notes" | null
|
"with-notes" | "without-notes" | null
|
||||||
>(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) {
|
function selectLanguage(nextLanguage: ExportLanguage) {
|
||||||
if (!pendingCaptureMode) return;
|
if (!pendingCaptureMode) return;
|
||||||
@@ -91,6 +107,37 @@ export function GuideImageExport({
|
|||||||
setPendingCaptureMode(null);
|
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(() => {
|
useEffect(() => {
|
||||||
if (!capturing) return;
|
if (!capturing) return;
|
||||||
|
|
||||||
@@ -161,34 +208,41 @@ export function GuideImageExport({
|
|||||||
<ExportLanguageToggle language={language} onChange={setStoredLanguage} />
|
<ExportLanguageToggle language={language} onChange={setStoredLanguage} />
|
||||||
) : null}
|
) : null}
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{(["with-notes", "without-notes"] as const).filter((mode) => allowWithoutNotes || mode === "with-notes").map((mode) => (
|
{allowWithoutNotes && modeSelection === "inline"
|
||||||
<Button
|
? [
|
||||||
key={mode}
|
exportButton("with-notes", buttonLabel),
|
||||||
type="button"
|
exportButton("without-notes", "ส่งออกภาพโดยไม่รวมโน้ต"),
|
||||||
variant="outline"
|
]
|
||||||
size="sm"
|
: exportButton(null, buttonLabel)}
|
||||||
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>
|
|
||||||
))}
|
|
||||||
</div>
|
</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
|
<ExportLanguageDialog
|
||||||
open={pendingCaptureMode !== null}
|
open={pendingCaptureMode !== null}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export function MaterialPoster({
|
|||||||
artworkOffsetX,
|
artworkOffsetX,
|
||||||
artworkOffsetY,
|
artworkOffsetY,
|
||||||
artworkScale,
|
artworkScale,
|
||||||
|
artworkRotation,
|
||||||
topColors,
|
topColors,
|
||||||
bottomColors,
|
bottomColors,
|
||||||
interactive = false,
|
interactive = false,
|
||||||
@@ -78,6 +79,7 @@ export function MaterialPoster({
|
|||||||
onArtworkMove?: (offset: { x: number; y: number }) => void;
|
onArtworkMove?: (offset: { x: number; y: number }) => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
artworkScale: number;
|
artworkScale: number;
|
||||||
|
artworkRotation: number;
|
||||||
bleeding?: Array<{ id: string; imageUrl: string | null; amount: number; blendMode: PosterBlendMode }>;
|
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);
|
const dragStart = useRef<{ pointerId: number; x: number; y: number; offsetX: number; offsetY: number } | null>(null);
|
||||||
@@ -142,7 +144,7 @@ export function MaterialPoster({
|
|||||||
{characterArtUrl ? (
|
{characterArtUrl ? (
|
||||||
<div
|
<div
|
||||||
className="pointer-events-none absolute inset-0"
|
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
|
<Image
|
||||||
src={characterArtUrl}
|
src={characterArtUrl}
|
||||||
|
|||||||
@@ -235,6 +235,7 @@ function GuideOverview({
|
|||||||
artworkOffsetX={guide.posterArtOffsetX}
|
artworkOffsetX={guide.posterArtOffsetX}
|
||||||
artworkOffsetY={guide.posterArtOffsetY}
|
artworkOffsetY={guide.posterArtOffsetY}
|
||||||
artworkScale={guide.posterArtScale}
|
artworkScale={guide.posterArtScale}
|
||||||
|
artworkRotation={guide.posterArtRotation}
|
||||||
topColors={guide.posterTopColors}
|
topColors={guide.posterTopColors}
|
||||||
bottomColors={guide.posterBottomColors}
|
bottomColors={guide.posterBottomColors}
|
||||||
bleeding={guide.posterBleeding}
|
bleeding={guide.posterBleeding}
|
||||||
@@ -256,6 +257,7 @@ function GuideOverview({
|
|||||||
artworkOffsetX={guide.posterArtOffsetX}
|
artworkOffsetX={guide.posterArtOffsetX}
|
||||||
artworkOffsetY={guide.posterArtOffsetY}
|
artworkOffsetY={guide.posterArtOffsetY}
|
||||||
artworkScale={guide.posterArtScale}
|
artworkScale={guide.posterArtScale}
|
||||||
|
artworkRotation={guide.posterArtRotation}
|
||||||
topColors={guide.posterTopColors}
|
topColors={guide.posterTopColors}
|
||||||
bottomColors={guide.posterBottomColors}
|
bottomColors={guide.posterBottomColors}
|
||||||
bleeding={guide.posterBleeding}
|
bleeding={guide.posterBleeding}
|
||||||
@@ -1000,12 +1002,12 @@ const thaiStats: Record<string, string> = {
|
|||||||
DEF: "พลังป้องกัน",
|
DEF: "พลังป้องกัน",
|
||||||
EM: "ความชำนาญธาตุ",
|
EM: "ความชำนาญธาตุ",
|
||||||
ER: "ฟื้นฟูพลังงาน",
|
ER: "ฟื้นฟูพลังงาน",
|
||||||
"CRIT RATE": "อัตราคริ",
|
"CRIT RATE": "คริเรท",
|
||||||
"CRIT DAMAGE": "ความแรงคริ",
|
"CRIT DAMAGE": "คริดาเมท",
|
||||||
"ATK%": "พลังโจมตี%",
|
"ATK%": "พลังโจมตี%",
|
||||||
"HP%": "พลังชีวิต%",
|
"HP%": "พลังชีวิต%",
|
||||||
"DEF%": "พลังป้องกัน%",
|
"DEF%": "พลังป้องกัน%",
|
||||||
"CRIT Rate/CRIT DMG": "อัตราคริ/ความแรงคริ",
|
"CRIT Rate/CRIT DMG": "คริเรท/คริดาเมท",
|
||||||
"Pyro DMG%": "โบนัสความเสียหายธาตุไฟ",
|
"Pyro DMG%": "โบนัสความเสียหายธาตุไฟ",
|
||||||
"Hydro DMG%": "โบนัสความเสียหายธาตุน้ำ",
|
"Hydro DMG%": "โบนัสความเสียหายธาตุน้ำ",
|
||||||
"Cryo DMG%": "โบนัสความเสียหายธาตุน้ำแข็ง",
|
"Cryo DMG%": "โบนัสความเสียหายธาตุน้ำแข็ง",
|
||||||
@@ -1028,12 +1030,19 @@ const englishStats: Record<string, string> = {
|
|||||||
"CRIT Rate/CRIT DMG": "CRIT Rate / CRIT DMG",
|
"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 {
|
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 (
|
return (
|
||||||
<ExportText
|
<ExportText
|
||||||
th={thaiStats[value] ?? value}
|
th={thaiStats[labelKey] ?? value}
|
||||||
en={englishStats[value] ?? value}
|
en={englishStats[labelKey] ?? value}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -283,6 +283,7 @@ export const guides = guidesSchema.table(
|
|||||||
posterArtOffsetX: integer("poster_art_offset_x").default(0).notNull(),
|
posterArtOffsetX: integer("poster_art_offset_x").default(0).notNull(),
|
||||||
posterArtOffsetY: integer("poster_art_offset_y").default(0).notNull(),
|
posterArtOffsetY: integer("poster_art_offset_y").default(0).notNull(),
|
||||||
posterArtScale: integer("poster_art_scale").default(100).notNull(),
|
posterArtScale: integer("poster_art_scale").default(100).notNull(),
|
||||||
|
posterArtRotation: integer("poster_art_rotation").default(0).notNull(),
|
||||||
posterTopColors: jsonb("poster_top_colors")
|
posterTopColors: jsonb("poster_top_colors")
|
||||||
.$type<[string, string]>()
|
.$type<[string, string]>()
|
||||||
.default(["#000000", "#000000"])
|
.default(["#000000", "#000000"])
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "guides"."guide" ADD COLUMN "poster_art_rotation" integer DEFAULT 0 NOT NULL;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -337,6 +337,13 @@
|
|||||||
"when": 1789764017232,
|
"when": 1789764017232,
|
||||||
"tag": "0047_green_riptide",
|
"tag": "0047_green_riptide",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 48,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1789823911760,
|
||||||
|
"tag": "0048_lazy_reptil",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -31,24 +31,32 @@ describe("image export controls", () => {
|
|||||||
expect(html).not.toContain("เงื่อนไข 1");
|
expect(html).not.toContain("เงื่อนไข 1");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("always offers both export modes even without notes", () => {
|
it("shows one export action when both note modes are available", () => {
|
||||||
const html = renderToStaticMarkup(<GuideImageExport fileName="guide.png">No notes</GuideImageExport>);
|
const html = renderToStaticMarkup(<GuideImageExport fileName="guide.png">No notes</GuideImageExport>);
|
||||||
expect(html).toContain("ส่งออกภาพ");
|
expect(html).toContain("ส่งออกภาพ");
|
||||||
expect(html).toContain("ส่งออกภาพโดยไม่รวมโน้ต");
|
|
||||||
expect(html).toContain("ไทย");
|
expect(html).toContain("ไทย");
|
||||||
expect(html).toContain("English");
|
expect(html).toContain("English");
|
||||||
expect(html).toContain('data-language-flag="th"');
|
expect(html).toContain('data-language-flag="th"');
|
||||||
expect(html).toContain('data-language-flag="en"');
|
expect(html).toContain('data-language-flag="en"');
|
||||||
expect(html.match(/<button\b/gu)).toHaveLength(4);
|
expect(html.match(/<button\b/gu)).toHaveLength(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows only one export action when no-note export is disabled", () => {
|
it("shows only one export action when no-note export is disabled", () => {
|
||||||
const html = renderToStaticMarkup(<GuideImageExport fileName="guide.png" allowWithoutNotes={false}>Content</GuideImageExport>);
|
const html = renderToStaticMarkup(<GuideImageExport fileName="guide.png" allowWithoutNotes={false}>Content</GuideImageExport>);
|
||||||
expect(html).toContain("ส่งออกภาพ");
|
expect(html).toContain("ส่งออกภาพ");
|
||||||
expect(html).not.toContain("ส่งออกภาพโดยไม่รวมโน้ต");
|
|
||||||
expect(html.match(/<button\b/gu)).toHaveLength(3);
|
expect(html.match(/<button\b/gu)).toHaveLength(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("can show note modes inline inside an existing dialog", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<GuideImageExport fileName="guide.png" modeSelection="inline">
|
||||||
|
Content
|
||||||
|
</GuideImageExport>,
|
||||||
|
);
|
||||||
|
expect(html).toContain("ส่งออกภาพโดยไม่รวมโน้ต");
|
||||||
|
expect(html.match(/<button\b/gu)).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
it("hides the persistent language chooser when language is prompted on export", () => {
|
it("hides the persistent language chooser when language is prompted on export", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<GuideImageExport
|
<GuideImageExport
|
||||||
@@ -65,9 +73,9 @@ describe("image export controls", () => {
|
|||||||
expect(html.match(/<button\b/gu)).toHaveLength(1);
|
expect(html.match(/<button\b/gu)).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("disables both modes when an export selection is empty", () => {
|
it("disables export when an export selection is empty", () => {
|
||||||
const html = renderToStaticMarkup(<GuideImageExport fileName="guide.png" disabled>No sections</GuideImageExport>);
|
const html = renderToStaticMarkup(<GuideImageExport fileName="guide.png" disabled>No sections</GuideImageExport>);
|
||||||
expect(html.match(/\sdisabled=""/gu)).toHaveLength(2);
|
expect(html.match(/\sdisabled=""/gu)).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(["English note", "โน้ตภาษาไทย"])("marks constellation notes for omission regardless of language: %s", (note) => {
|
it.each(["English note", "โน้ตภาษาไทย"])("marks constellation notes for omission regardless of language: %s", (note) => {
|
||||||
@@ -102,6 +110,7 @@ describe("material poster text", () => {
|
|||||||
artworkOffsetX: 0,
|
artworkOffsetX: 0,
|
||||||
artworkOffsetY: 0,
|
artworkOffsetY: 0,
|
||||||
artworkScale: 100,
|
artworkScale: 100,
|
||||||
|
artworkRotation: 0,
|
||||||
topColors: ["#000000", "#000000"] as [string, string],
|
topColors: ["#000000", "#000000"] as [string, string],
|
||||||
bottomColors: ["#000000", "#000000"] as [string, string],
|
bottomColors: ["#000000", "#000000"] as [string, string],
|
||||||
};
|
};
|
||||||
@@ -130,4 +139,12 @@ describe("material poster text", () => {
|
|||||||
expect(html).toContain('sizes="3200px"');
|
expect(html).toContain('sizes="3200px"');
|
||||||
expect(html).not.toContain(`src="${characterArtUrl}"`);
|
expect(html).not.toContain(`src="${characterArtUrl}"`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rotates the character artwork and bleeding layers together", () => {
|
||||||
|
const html = renderToStaticMarkup(
|
||||||
|
<MaterialPoster {...props} characterArtUrl="/raiden.png" artworkRotation={45} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(html).toContain("rotate(45deg)");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ export async function updateGuideBasics(guideId: string, input: unknown, details
|
|||||||
...(data.posterArtOffsetX !== undefined ? { posterArtOffsetX: Math.round(data.posterArtOffsetX) } : {}),
|
...(data.posterArtOffsetX !== undefined ? { posterArtOffsetX: Math.round(data.posterArtOffsetX) } : {}),
|
||||||
...(data.posterArtOffsetY !== undefined ? { posterArtOffsetY: Math.round(data.posterArtOffsetY) } : {}),
|
...(data.posterArtOffsetY !== undefined ? { posterArtOffsetY: Math.round(data.posterArtOffsetY) } : {}),
|
||||||
...(data.posterArtScale !== undefined ? { posterArtScale: Math.round(data.posterArtScale) } : {}),
|
...(data.posterArtScale !== undefined ? { posterArtScale: Math.round(data.posterArtScale) } : {}),
|
||||||
|
...(data.posterArtRotation !== undefined ? { posterArtRotation: Math.round(data.posterArtRotation) } : {}),
|
||||||
...(data.posterTopColors !== undefined ? { posterTopColors: data.posterTopColors } : {}),
|
...(data.posterTopColors !== undefined ? { posterTopColors: data.posterTopColors } : {}),
|
||||||
...(data.posterBottomColors !== undefined ? { posterBottomColors: data.posterBottomColors } : {}),
|
...(data.posterBottomColors !== undefined ? { posterBottomColors: data.posterBottomColors } : {}),
|
||||||
}).where(eq(guides.id, guideId));
|
}).where(eq(guides.id, guideId));
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export const updateGuideSchema = z.strictObject({
|
|||||||
posterArtOffsetX: z.number().finite().min(-1000).max(1000).optional(),
|
posterArtOffsetX: z.number().finite().min(-1000).max(1000).optional(),
|
||||||
posterArtOffsetY: z.number().finite().min(-1000).max(1000).optional(),
|
posterArtOffsetY: z.number().finite().min(-1000).max(1000).optional(),
|
||||||
posterArtScale: z.number().finite().min(50).max(500).optional(),
|
posterArtScale: z.number().finite().min(50).max(500).optional(),
|
||||||
|
posterArtRotation: z.number().finite().min(-180).max(180).optional(),
|
||||||
posterTopColors: z.tuple([z.string().regex(/^#[0-9a-fA-F]{6}$/u), z.string().regex(/^#[0-9a-fA-F]{6}$/u)]).optional(),
|
posterTopColors: z.tuple([z.string().regex(/^#[0-9a-fA-F]{6}$/u), z.string().regex(/^#[0-9a-fA-F]{6}$/u)]).optional(),
|
||||||
posterBottomColors: z.tuple([z.string().regex(/^#[0-9a-fA-F]{6}$/u), z.string().regex(/^#[0-9a-fA-F]{6}$/u)]).optional(),
|
posterBottomColors: z.tuple([z.string().regex(/^#[0-9a-fA-F]{6}$/u), z.string().regex(/^#[0-9a-fA-F]{6}$/u)]).optional(),
|
||||||
posterBleeding: z.array(z.strictObject({
|
posterBleeding: z.array(z.strictObject({
|
||||||
|
|||||||
Reference in New Issue
Block a user