+92
apps/skillulator/src/components/MasterSkillVariationDialog.tsx
+92
apps/skillulator/src/components/MasterSkillVariationDialog.tsx
···
1
+
import type { RefObject } from "react";
2
+
import { IncreaseSkillToMaxButton, SkillIconButton } from "./action-buttons";
3
+
import { Requirement } from "./Requirement";
4
+
import type { Language, Skill as SkillType } from "@/types";
5
+
6
+
export function MasterSkillVariationDialog(props: {
7
+
ref: RefObject<HTMLDialogElement | null>;
8
+
masterVariations: SkillType["masterVariations"];
9
+
skill: SkillType;
10
+
jobId: number | undefined;
11
+
skillId: number;
12
+
hasMinLevelRequirements: boolean;
13
+
isMaxed: boolean;
14
+
lang: Language;
15
+
}) {
16
+
const skillName = props.skill.name.en
17
+
.replaceAll(" ", "")
18
+
.replace(/'/, "")
19
+
.replace(/[\[\]()]/g, "")
20
+
.replace(":", "");
21
+
return (
22
+
<dialog
23
+
ref={props.ref}
24
+
className="border border-gray-300 shadow-sm rounded-md p-5 w-1/2 xl:w-[60%] mx-auto my-auto"
25
+
>
26
+
<div className="flex justify-between mb-4">
27
+
<h2 className="font-bold">
28
+
Select a Master Variation (you can only choose one)
29
+
</h2>
30
+
<button
31
+
type="button"
32
+
onClick={() => props?.ref?.current?.close()}
33
+
className="text-sm bg-red-500 text-white py-1 px-2 rounded-md cursor-pointer"
34
+
>
35
+
Close Dialog
36
+
</button>
37
+
</div>
38
+
<div className="flex gap-4">
39
+
{props?.masterVariations?.map((variation, index) => {
40
+
const selectedMasterVariation = props?.masterVariations?.some(
41
+
(s) => s.isSelected === true,
42
+
)
43
+
? props?.masterVariations?.filter((s) => s.isSelected === true)
44
+
: props.masterVariations;
45
+
46
+
const isSelectedMasterVariation =
47
+
!selectedMasterVariation?.every((v) => v.id === variation.id) &&
48
+
selectedMasterVariation?.length === 1;
49
+
50
+
return (
51
+
<div
52
+
key={JSON.stringify({ id: variation.id, index })}
53
+
data-skill={skillName}
54
+
className="relative flex flex-col items-center flex-1 py-2 bg-white border border-gray-300 rounded-md basis-1/2 min-content"
55
+
>
56
+
<SkillIconButton
57
+
locale={props.lang}
58
+
skill={{
59
+
...variation,
60
+
id: props.skill.id,
61
+
masterVariations: props.masterVariations,
62
+
}}
63
+
masterVariationSkillId={variation?.id}
64
+
isSelectedMasterVariation={isSelectedMasterVariation}
65
+
jobId={props.jobId}
66
+
hasMinLevelRequirements={variation?.hasMinLevelRequirements}
67
+
isMaxed={variation?.isMaxed}
68
+
/>
69
+
<div>
70
+
{variation.requirements.map((variation, index: number) => (
71
+
<Requirement
72
+
key={JSON.stringify({ variation, index })}
73
+
hasMinLevelRequirements={variation.hasMinLevel}
74
+
skill={{ level: variation.level, name: variation.name }}
75
+
/>
76
+
))}
77
+
</div>
78
+
<IncreaseSkillToMaxButton
79
+
skillId={props.skill.id}
80
+
jobId={props.jobId}
81
+
isSelectedMasterVariation={isSelectedMasterVariation}
82
+
masterVariationSkillId={variation.id}
83
+
hasMinLevelRequirements={variation?.hasMinLevelRequirements}
84
+
isMaxed={variation?.isMaxed}
85
+
/>
86
+
</div>
87
+
);
88
+
})}
89
+
</div>
90
+
</dialog>
91
+
);
92
+
}
+75
apps/skillulator/src/components/Skill.tsx
+75
apps/skillulator/src/components/Skill.tsx
···
1
+
import type { Language, Skill as SkillType } from "@/types";
2
+
import { languages } from "@/utils/constants";
3
+
import { getLanguageForSkill } from "@/utils/language";
4
+
import { IncreaseSkillToMaxButton, SkillIconButton } from "./action-buttons";
5
+
import { Requirement } from "./Requirement";
6
+
import { useRef, type ComponentRef } from "react";
7
+
import { MasterSkillVariationDialog } from "./MasterSkillVariationDialog";
8
+
9
+
interface SkillProps {
10
+
skill: SkillType;
11
+
jobId: number | undefined;
12
+
skillId: number;
13
+
hasMinLevelRequirements: boolean;
14
+
isMaxed: boolean;
15
+
lang: Language;
16
+
}
17
+
18
+
export default function Skill(props: SkillProps) {
19
+
const locale = getLanguageForSkill(languages, props.lang);
20
+
const skillName = props.skill.name.en
21
+
.replaceAll(" ", "")
22
+
.replace(/'/, "")
23
+
.replace(/[\[\]()]/g, "")
24
+
.replace(":", "");
25
+
26
+
const isMasterVariationSkill =
27
+
props?.skill?.masterVariations?.length &&
28
+
props?.skill?.masterVariations?.length > 1;
29
+
30
+
const masterDialogRef = useRef<ComponentRef<"dialog">>(null);
31
+
32
+
return (
33
+
<div
34
+
data-skill={skillName}
35
+
className="scoped-anchor relative flex flex-col items-center flex-1 pt-3 bg-white border border-gray-300 rounded-md basis-1/2 min-content"
36
+
>
37
+
<SkillIconButton {...props} locale={locale} />
38
+
{props.skill.isPassiveSkill ? (
39
+
<span className="text-sm font-semibold text-purple-600">
40
+
(passive skill)
41
+
</span>
42
+
) : null}
43
+
<div>
44
+
{props.skill.requirements.map((skill, index: number) => (
45
+
<Requirement
46
+
key={JSON.stringify({ skill, index })}
47
+
hasMinLevelRequirements={skill.hasMinLevel}
48
+
skill={{ level: skill.level, name: skill.name }}
49
+
/>
50
+
))}
51
+
</div>
52
+
<div>
53
+
<button
54
+
type="button"
55
+
aria-label="open master variation dialog"
56
+
onClick={() => masterDialogRef?.current?.showModal()}
57
+
>
58
+
{isMasterVariationSkill ? (
59
+
<img
60
+
src="https://api.flyff.com/image/badge/master6.png"
61
+
alt=""
62
+
className="h-6 w-6 absolute top-2 left-3 cursor-pointer"
63
+
/>
64
+
) : null}
65
+
</button>
66
+
<IncreaseSkillToMaxButton {...props} />
67
+
</div>
68
+
<MasterSkillVariationDialog
69
+
ref={masterDialogRef}
70
+
masterVariations={props?.skill?.masterVariations}
71
+
{...props}
72
+
/>
73
+
</div>
74
+
);
75
+
}
+20
-15
apps/skillulator/src/hooks/useSkillTree.ts
+20
-15
apps/skillulator/src/hooks/useSkillTree.ts
···
11
11
import { useCallback, useEffect, useState } from "react";
12
12
import { useTranslation } from "react-i18next";
13
13
import { useTreeStore } from "@/zustand/treeStore";
14
+
import type { Language, Skills } from "@/types";
14
15
15
16
export function useSkillTree() {
16
17
const { i18n } = useTranslation();
···
98
99
]);
99
100
100
101
const sortedSkills = skills
101
-
?.toSorted((a, b) => a.level - b.level)
102
-
?.map((skill) => ({
103
-
...skill,
104
-
hasMinLevelRequirements: checkSkillRequirements(skill),
105
-
isMaxed: isSkillMaxed(skill),
106
-
masterVariations: skill?.masterVariations
107
-
?.toSorted((a, b) => a.level - b.level)
108
-
?.map((variation) => ({
109
-
...variation,
110
-
hasMinLevelRequirements: checkSkillRequirements(variation),
111
-
isMaxed: isSkillMaxed(variation),
112
-
isSelected: isMasterSkillSelected(variation),
113
-
})),
114
-
}));
102
+
? [...skills]
103
+
.sort((a, b) => a.level - b.level)
104
+
.map((skill) => ({
105
+
...skill,
106
+
hasMinLevelRequirements: checkSkillRequirements(skill),
107
+
isMaxed: isSkillMaxed(skill),
108
+
masterVariations: skill?.masterVariations
109
+
? [...skill.masterVariations]
110
+
.sort((a, b) => a.level - b.level)
111
+
.map((variation) => ({
112
+
...variation,
113
+
hasMinLevelRequirements: checkSkillRequirements(variation),
114
+
isMaxed: isSkillMaxed(variation),
115
+
isSelected: isMasterSkillSelected(variation),
116
+
}))
117
+
: [],
118
+
}))
119
+
: [];
115
120
116
121
return {
117
122
job,
···
119
124
level,
120
125
skillPoints,
121
126
copied,
122
-
language: i18n.language,
127
+
language: i18n.language as Language,
123
128
handleLevelChange,
124
129
copyToClipboard,
125
130
handleReset,
+8
-2
apps/skillulator/src/index.css
+8
-2
apps/skillulator/src/index.css
···
28
28
}
29
29
30
30
.attributes-popover {
31
-
inset: auto;
32
-
margin: 0;
33
31
position-anchor: --attributes-popover;
34
32
position-area: bottom right;
35
33
position-try-fallbacks: --top-center, --top-left;
34
+
}
35
+
36
+
body:has(dialog[open]) .skill-icon-anchor {
37
+
anchor-name: none !important;
38
+
}
39
+
40
+
.skill-icon-anchor {
41
+
anchor-name: --attributes-popover;
36
42
}
37
43
38
44
@position-try --top-left {
apps/skillulator/src/routes/c/$class/components/Requirement.tsx
apps/skillulator/src/components/Requirement.tsx
apps/skillulator/src/routes/c/$class/components/Requirement.tsx
apps/skillulator/src/components/Requirement.tsx
-158
apps/skillulator/src/routes/c/$class/components/Skill.tsx
-158
apps/skillulator/src/routes/c/$class/components/Skill.tsx
···
1
-
import type { Skill as SkillType } from "@/types";
2
-
import { languages } from "@/utils/constants";
3
-
import { getLanguageForSkill } from "@/utils/language";
4
-
import {
5
-
// DecreaseSkillPointButton,
6
-
IncreaseSkillToMaxButton,
7
-
SkillIconButton,
8
-
} from "./action-buttons";
9
-
import { Requirement } from "./Requirement";
10
-
import { useRef, type ComponentRef, type RefObject } from "react";
11
-
12
-
interface SkillProps {
13
-
skill: SkillType;
14
-
jobId: number | undefined;
15
-
skillId: number;
16
-
hasMinLevelRequirements: boolean;
17
-
isMaxed: boolean;
18
-
lang: string;
19
-
}
20
-
21
-
export default function Skill(props: SkillProps) {
22
-
const locale = getLanguageForSkill(languages, props.lang);
23
-
const skillName = props.skill.name.en
24
-
.replaceAll(" ", "")
25
-
.replace(/'/, "")
26
-
.replace(/[\[\]()]/g, "")
27
-
.replace(":", "");
28
-
29
-
const isMasterVariationSkill =
30
-
typeof props?.skill?.masterVariations?.length !== "undefined";
31
-
32
-
const masterDialogRef = useRef<ComponentRef<"dialog">>(null);
33
-
34
-
return (
35
-
<div
36
-
data-skill={skillName}
37
-
className="scoped-anchor relative flex flex-col items-center flex-1 pt-3 bg-white border border-gray-300 rounded-md basis-1/2 min-content"
38
-
>
39
-
<SkillIconButton {...props} locale={locale} />
40
-
{props.skill.isPassiveSkill ? (
41
-
<span className="text-sm font-semibold text-purple-600">
42
-
(passive skill)
43
-
</span>
44
-
) : null}
45
-
<div>
46
-
{props.skill.requirements.map((skill, index: number) => (
47
-
<Requirement
48
-
key={JSON.stringify({ skill, index })}
49
-
hasMinLevelRequirements={skill.hasMinLevel}
50
-
skill={{ level: skill.level, name: skill.name }}
51
-
/>
52
-
))}
53
-
</div>
54
-
<div>
55
-
<button
56
-
type="button"
57
-
aria-label="open master variation dialog"
58
-
onClick={() => masterDialogRef?.current?.showModal()}
59
-
>
60
-
{isMasterVariationSkill ? (
61
-
<img
62
-
src="https://api.flyff.com/image/badge/master6.png"
63
-
alt=""
64
-
className="h-6 w-6 absolute top-2 left-3 cursor-pointer"
65
-
/>
66
-
) : null}
67
-
</button>
68
-
{/* <DecreaseSkillPointButton {...props} /> */}
69
-
<IncreaseSkillToMaxButton {...props} />
70
-
</div>
71
-
<MasterSkillVariationDialog
72
-
ref={masterDialogRef}
73
-
masterVariations={props?.skill?.masterVariations}
74
-
{...props}
75
-
/>
76
-
</div>
77
-
);
78
-
}
79
-
80
-
function MasterSkillVariationDialog(props: {
81
-
ref: RefObject<HTMLDialogElement | null>;
82
-
masterVariations: SkillType["masterVariations"];
83
-
skill: SkillType;
84
-
jobId: number | undefined;
85
-
skillId: number;
86
-
hasMinLevelRequirements: boolean;
87
-
isMaxed: boolean;
88
-
lang: string;
89
-
}) {
90
-
return (
91
-
<dialog
92
-
ref={props.ref}
93
-
className="border border-gray-300 shadow-sm rounded-md p-5 w-1/2 xl:w-[60%] mx-auto my-auto"
94
-
>
95
-
<div className="flex justify-between mb-4">
96
-
<h2 className="font-bold">
97
-
Select a Master Variation (you can only choose one)
98
-
</h2>
99
-
<button
100
-
type="button"
101
-
onClick={() => props?.ref?.current?.close()}
102
-
className="text-sm bg-red-500 text-white py-1 px-2 rounded-md cursor-pointer"
103
-
>
104
-
Close Dialog
105
-
</button>
106
-
</div>
107
-
<div className="flex gap-4">
108
-
{props?.masterVariations?.map((variation, index) => {
109
-
const selectedMasterVariation = props?.masterVariations?.some(
110
-
(s) => s.isSelected === true,
111
-
)
112
-
? props?.masterVariations?.filter((s) => s.isSelected === true)
113
-
: props.masterVariations;
114
-
115
-
const isSelectedMasterVariation =
116
-
!selectedMasterVariation?.every((v) => v.id === variation.id) &&
117
-
selectedMasterVariation?.length === 1;
118
-
119
-
return (
120
-
<div
121
-
key={JSON.stringify({ id: variation.id, index })}
122
-
data-skill={props.skill.name}
123
-
className="relative flex flex-col items-center flex-1 py-2 bg-white border border-gray-300 rounded-md basis-1/2 min-content"
124
-
>
125
-
<SkillIconButton
126
-
locale={props.lang}
127
-
skill={{ ...variation, id: props.skill.id }}
128
-
masterVariationSkillId={variation?.id}
129
-
isSelectedMasterVariation={isSelectedMasterVariation}
130
-
jobId={props.jobId}
131
-
hasMinLevelRequirements={variation?.hasMinLevelRequirements}
132
-
isMaxed={variation?.isMaxed}
133
-
/>
134
-
<div>
135
-
{variation.requirements.map((variation, index: number) => (
136
-
<Requirement
137
-
key={JSON.stringify({ variation, index })}
138
-
hasMinLevelRequirements={variation.hasMinLevel}
139
-
skill={{ level: variation.level, name: variation.name }}
140
-
/>
141
-
))}
142
-
</div>
143
-
{/* <DecreaseSkillPointButton {...props} /> */}
144
-
<IncreaseSkillToMaxButton
145
-
skillId={props.skill.id}
146
-
jobId={props.jobId}
147
-
isSelectedMasterVariation={isSelectedMasterVariation}
148
-
masterVariationSkillId={variation.id}
149
-
hasMinLevelRequirements={variation?.hasMinLevelRequirements}
150
-
isMaxed={variation?.isMaxed}
151
-
/>
152
-
</div>
153
-
);
154
-
})}
155
-
</div>
156
-
</dialog>
157
-
);
158
-
}
apps/skillulator/src/routes/c/$class/components/Tooltip.tsx
apps/skillulator/src/components/Tooltip.tsx
apps/skillulator/src/routes/c/$class/components/Tooltip.tsx
apps/skillulator/src/components/Tooltip.tsx
+80
-80
apps/skillulator/src/routes/c/$class/route.tsx
+80
-80
apps/skillulator/src/routes/c/$class/route.tsx
···
2
2
import clsx from "clsx";
3
3
import { t } from "i18next";
4
4
import { Suspense } from "react";
5
-
import Skill from "./components/Skill";
5
+
import Skill from "../../../components/Skill";
6
6
import { useSkillTree } from "@/hooks/useSkillTree";
7
-
import { CopyButton, ResetButton } from "./components/action-buttons";
7
+
import { CopyButton, ResetButton } from "../../../components/action-buttons";
8
8
9
9
export const Route = createFileRoute("/c/$class")({
10
-
component: SkillTree,
10
+
component: SkillTree,
11
11
});
12
12
13
13
function SkillTree() {
14
-
const {
15
-
job,
16
-
skills,
17
-
level,
18
-
skillPoints,
19
-
copied,
20
-
language,
21
-
handleLevelChange,
22
-
copyToClipboard,
23
-
handleReset,
24
-
} = useSkillTree();
14
+
const {
15
+
job,
16
+
skills,
17
+
level,
18
+
skillPoints,
19
+
copied,
20
+
language,
21
+
handleLevelChange,
22
+
copyToClipboard,
23
+
handleReset,
24
+
} = useSkillTree();
25
25
26
-
const params = useParams({ from: "/c/$class" });
26
+
const params = useParams({ from: "/c/$class" });
27
27
28
-
return (
29
-
<>
30
-
<Suspense>
31
-
<div className="px-5 2xl:px-0 my-10">
32
-
<div className="flex flex-col justify-between mb-10 md:flex-row max-w-[1440px] mx-auto">
33
-
<div className="flex flex-col-reverse">
34
-
<h1 className="text-2xl font-bold capitalize">{params.class}</h1>
35
-
<Link
36
-
to="/"
37
-
className="mb-2 text-indigo-600 hover:underline md:mb-4 a11y-focus"
38
-
>
39
-
{" "}
40
-
← {t("classSelectionLink")}
41
-
</Link>
42
-
</div>
43
-
<div className="flex flex-col gap-2 md:flex-row">
44
-
<div className="flex justify-between gap-2">
45
-
<div className="self-end h-min">
46
-
<p>{t("availSkillPoints")}</p>
47
-
<span className="font-bold">{skillPoints}</span>
48
-
</div>
49
-
<div className="flex flex-col self-end h-min">
50
-
<label htmlFor="characterLevel" className="text-sm">
51
-
{t("charLevel")}
52
-
</label>
53
-
<input
54
-
type="number"
55
-
className="rounded-md border border-gray-300 px-4 py-1.5 a11y-focus"
56
-
inputMode="numeric"
57
-
pattern="[0-9]*"
58
-
value={level}
59
-
onChange={handleLevelChange}
60
-
min={15}
61
-
max={190}
62
-
/>
63
-
</div>
64
-
</div>
65
-
<CopyButton copied={copied} copyToClipboard={copyToClipboard} />
66
-
<ResetButton handleReset={handleReset} />
67
-
</div>
68
-
</div>
69
-
<div
70
-
className={clsx(
71
-
"xl:grid max-w-[1440px] mx-auto gap-0.5",
72
-
params.class,
73
-
)}
74
-
>
75
-
{skills?.map((skill, index: number) => {
76
-
return (
77
-
<Skill
78
-
lang={language}
79
-
key={JSON.stringify({ name: skill.name, index })}
80
-
hasMinLevelRequirements={skill.hasMinLevelRequirements}
81
-
isMaxed={skill.isMaxed}
82
-
skill={skill}
83
-
skillId={skill.id}
84
-
jobId={job?.id}
85
-
/>
86
-
);
87
-
})}
88
-
</div>
89
-
</div>
90
-
</Suspense>
91
-
</>
92
-
);
28
+
return (
29
+
<>
30
+
<Suspense>
31
+
<div className="px-5 2xl:px-0 my-10">
32
+
<div className="flex flex-col justify-between mb-10 md:flex-row max-w-[1440px] mx-auto">
33
+
<div className="flex flex-col-reverse">
34
+
<h1 className="text-2xl font-bold capitalize">{params.class}</h1>
35
+
<Link
36
+
to="/"
37
+
className="mb-2 text-indigo-600 hover:underline md:mb-4 a11y-focus"
38
+
>
39
+
{" "}
40
+
← {t("classSelectionLink")}
41
+
</Link>
42
+
</div>
43
+
<div className="flex flex-col gap-2 md:flex-row">
44
+
<div className="flex justify-between gap-2">
45
+
<div className="self-end h-min">
46
+
<p>{t("availSkillPoints")}</p>
47
+
<span className="font-bold">{skillPoints}</span>
48
+
</div>
49
+
<div className="flex flex-col self-end h-min">
50
+
<label htmlFor="characterLevel" className="text-sm">
51
+
{t("charLevel")}
52
+
</label>
53
+
<input
54
+
type="number"
55
+
className="rounded-md border border-gray-300 px-4 py-1.5 a11y-focus"
56
+
inputMode="numeric"
57
+
pattern="[0-9]*"
58
+
value={level}
59
+
onChange={handleLevelChange}
60
+
min={15}
61
+
max={190}
62
+
/>
63
+
</div>
64
+
</div>
65
+
<CopyButton copied={copied} copyToClipboard={copyToClipboard} />
66
+
<ResetButton handleReset={handleReset} />
67
+
</div>
68
+
</div>
69
+
<div
70
+
className={clsx(
71
+
"xl:grid max-w-[1440px] mx-auto gap-0.5",
72
+
params.class,
73
+
)}
74
+
>
75
+
{skills?.map((skill, index: number) => {
76
+
return (
77
+
<Skill
78
+
lang={language}
79
+
key={JSON.stringify({ name: skill.name, index })}
80
+
hasMinLevelRequirements={skill.hasMinLevelRequirements}
81
+
isMaxed={skill.isMaxed}
82
+
skill={skill}
83
+
skillId={skill.id}
84
+
jobId={job?.id}
85
+
/>
86
+
);
87
+
})}
88
+
</div>
89
+
</div>
90
+
</Suspense>
91
+
</>
92
+
);
93
93
}
apps/skillulator/src/routes/index/components/LinkCard.tsx
apps/skillulator/src/components/LinkCard.tsx
apps/skillulator/src/routes/index/components/LinkCard.tsx
apps/skillulator/src/components/LinkCard.tsx
+1
-1
apps/skillulator/src/routes/index/route.lazy.tsx
+1
-1
apps/skillulator/src/routes/index/route.lazy.tsx
···
2
2
import { useTranslation } from "react-i18next";
3
3
import { JOBS } from "@/utils/constants";
4
4
import { createLazyFileRoute } from "@tanstack/react-router";
5
-
import { LinkCard } from "./components/LinkCard";
5
+
import { LinkCard } from "@/components/LinkCard";
6
6
7
7
export const Route = createLazyFileRoute("/")({
8
8
component: Index,
+3
-1
apps/skillulator/src/utils/language.ts
+3
-1
apps/skillulator/src/utils/language.ts
···
1
+
import type { Language } from "@/types";
1
2
import type { languages } from "./constants";
2
3
3
4
export function getLanguageForSkill(
4
5
langs: typeof languages,
5
6
appLanguage: string,
6
7
) {
7
-
return langs.find((lang) => lang.label === appLanguage)?.value ?? "en";
8
+
return (langs.find((lang) => lang.label === appLanguage)?.value ??
9
+
"en") as Language;
8
10
}