Skip to content

Commit f159aab

Browse files
intsilencesimeng-liclaude
authored
feat(base64-decoder): add Base64 encoder tool (#25)
* feat(base64-decoder): add Base64 encoder and decoder tool Adds a new @logto/tools-base64-decoder package that provides a Base64 / Base64URL encoder and decoder, wired into the dev-app shell with full i18n coverage and aligned on @logto/website-ui-foundation@^0.2.0. * fix(base64-decoder): address Copilot review feedback - Drop the unused styles.decoder className in the dev-app Base64DecoderPage. - Update dev-app locale title/description for base64Decoder to reflect the live tool. - Re-derive the welcome sample when the host language changes while inputs are untouched. - Remove the unused @silverhand/essentials dependency. - Localize the copy-failed toast via a new copy_failed phrase. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: simeng-li <simeng@silverhand.io> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 430072e commit f159aab

71 files changed

Lines changed: 1508 additions & 45 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

commitlint.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ const config: UserConfig = {
55
extends: ['@commitlint/config-conventional'],
66
rules: {
77
'type-enum': [2, 'always', [...conventional.rules['type-enum'][2], 'release']],
8-
'scope-enum': [2, 'always', ['repo', 'jwt-decoder', 'dev-app', 'i18nova', 'components', 'ci', 'deps']],
8+
'scope-enum': [2, 'always', ['repo', 'jwt-decoder', 'base64-decoder', 'dev-app', 'i18nova', 'components', 'ci', 'deps']],
99
},
1010
};
1111

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
{
2+
"name": "@logto/tools-base64-decoder",
3+
"version": "0.0.0",
4+
"description": "Base64 / Base64URL encoder and decoder package for Logto tools.",
5+
"type": "module",
6+
"main": "src/index.ts",
7+
"files": [
8+
"src"
9+
],
10+
"scripts": {
11+
"precommit": "lint-staged --config ../../lint-staged.config.cjs",
12+
"dev": "vitest",
13+
"build": "tsc",
14+
"lint": "eslint --ext .ts --ext .tsx src",
15+
"stylelint": "stylelint \"src/**/*.scss\"",
16+
"typecheck": "tsc --noEmit",
17+
"test": "vitest run"
18+
},
19+
"engines": {
20+
"node": "^22.0.0"
21+
},
22+
"dependencies": {
23+
"classnames": "^2.3.1",
24+
"@logto/tools-i18nova": "workspace:^",
25+
"react-hot-toast": "^2.4.0"
26+
},
27+
"peerDependencies": {
28+
"@logto/website-ui-foundation": "^0.2.0",
29+
"react": "^18.0.0",
30+
"react-dom": "^18.0.0"
31+
},
32+
"devDependencies": {
33+
"@logto/website-ui-foundation": "^0.2.0",
34+
"@silverhand/eslint-config": "^6.0.1",
35+
"@silverhand/eslint-config-react": "^6.0.2",
36+
"@silverhand/ts-config": "^6.0.0",
37+
"@silverhand/ts-config-react": "^6.0.0",
38+
"@testing-library/react": "^16.3.0",
39+
"@types/react": "^18.3.3",
40+
"@types/react-dom": "^18.3.0",
41+
"eslint": "^8.56.0",
42+
"jsdom": "^26.1.0",
43+
"lint-staged": "^16.0.0",
44+
"prettier": "^3.0.0",
45+
"stylelint": "^15.2.0",
46+
"typescript": "^5.7.2",
47+
"vite": "^6.2.7",
48+
"vite-plugin-svgr": "^4.5.0",
49+
"vite-tsconfig-paths": "^6.0.0",
50+
"vitest": "^3.2.4"
51+
},
52+
"eslintConfig": {
53+
"extends": "@silverhand/react",
54+
"rules": {
55+
"react/jsx-no-target-blank": [
56+
"error",
57+
{
58+
"allowReferrer": true
59+
}
60+
],
61+
"complexity": "off"
62+
}
63+
},
64+
"prettier": "@silverhand/eslint-config/.prettierrc",
65+
"stylelint": {
66+
"extends": "@silverhand/eslint-config-react/.stylelintrc",
67+
"rules": {
68+
"scss/function-no-unknown": [
69+
true,
70+
{
71+
"ignoreFunctions": [
72+
"/^_./"
73+
]
74+
}
75+
]
76+
}
77+
}
78+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import Decoder from './Decoder';
2+
import { I18nProvider, type Base64DecoderI18nAdapter } from './i18n';
3+
4+
type Props = {
5+
readonly i18n: Base64DecoderI18nAdapter;
6+
readonly className?: string;
7+
};
8+
9+
const Base64Decoder = ({ i18n, className }: Props) => {
10+
return (
11+
<I18nProvider i18n={i18n}>
12+
<Decoder className={className} />
13+
</I18nProvider>
14+
);
15+
};
16+
17+
export default Base64Decoder;
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import classNames from 'classnames';
2+
import { useEffect, useMemo, useState } from 'react';
3+
4+
import ActionBar from './components/ActionBar';
5+
import Headline from './components/Headline';
6+
import OptionsBar from './components/OptionsBar';
7+
import Section from './components/Section';
8+
import TextPane from './components/TextPane';
9+
import { usePhrases } from './i18n';
10+
import styles from './index.module.scss';
11+
import { EditorMode } from './types';
12+
import { Base64DecodeError, decodeBase64, encodeBase64 } from './utils';
13+
14+
type Props = {
15+
readonly className?: string;
16+
};
17+
18+
type Computed = {
19+
plainText: string;
20+
base64Text: string;
21+
hasInvalidBase64: boolean;
22+
};
23+
24+
const Decoder = ({ className }: Props) => {
25+
const { t } = usePhrases();
26+
const welcomeSample = t('welcome_sample');
27+
const [mode, setMode] = useState<EditorMode>(EditorMode.Decode);
28+
const [urlSafe, setUrlSafe] = useState(false);
29+
const [plainInput, setPlainInput] = useState('');
30+
const [base64Input, setBase64Input] = useState(() => encodeBase64(welcomeSample));
31+
const [hasUserEdited, setHasUserEdited] = useState(false);
32+
33+
useEffect(() => {
34+
if (!hasUserEdited) {
35+
setBase64Input(encodeBase64(welcomeSample));
36+
}
37+
}, [welcomeSample, hasUserEdited]);
38+
39+
const onPlainInputChange = (next: string) => {
40+
setHasUserEdited(true);
41+
setPlainInput(next);
42+
};
43+
44+
const onBase64InputChange = (next: string) => {
45+
setHasUserEdited(true);
46+
setBase64Input(next);
47+
};
48+
49+
const { plainText, base64Text, hasInvalidBase64 } = useMemo<Computed>(() => {
50+
if (mode === EditorMode.Encode) {
51+
return {
52+
plainText: plainInput,
53+
base64Text: encodeBase64(plainInput, urlSafe),
54+
hasInvalidBase64: false,
55+
};
56+
}
57+
58+
try {
59+
return {
60+
plainText: decodeBase64(base64Input),
61+
base64Text: base64Input,
62+
hasInvalidBase64: false,
63+
};
64+
} catch (error: unknown) {
65+
if (error instanceof Base64DecodeError) {
66+
return {
67+
plainText: '',
68+
base64Text: base64Input,
69+
hasInvalidBase64: base64Input.trim() !== '',
70+
};
71+
}
72+
throw error;
73+
}
74+
}, [mode, urlSafe, plainInput, base64Input]);
75+
76+
const onModeChange = (nextMode: EditorMode) => {
77+
if (nextMode === mode) {
78+
return;
79+
}
80+
81+
if (nextMode === EditorMode.Decode) {
82+
setBase64Input(base64Text);
83+
} else {
84+
setPlainInput(plainText);
85+
}
86+
87+
setMode(nextMode);
88+
};
89+
90+
const output = mode === EditorMode.Encode ? base64Text : plainText;
91+
92+
return (
93+
<Section className={className}>
94+
<Headline mode={mode} onModeChange={onModeChange} />
95+
<div className={classNames(styles.contentForm, styles[mode])}>
96+
<div className={styles.plainColumn}>
97+
<TextPane
98+
title={t('plain_text_label')}
99+
value={plainText}
100+
placeholder={mode === EditorMode.Encode ? t('plain_text_placeholder') : undefined}
101+
isReadOnly={mode === EditorMode.Decode}
102+
onChange={onPlainInputChange}
103+
/>
104+
</div>
105+
<div className={styles.base64Column}>
106+
<TextPane
107+
title={t('base64_label')}
108+
value={base64Text}
109+
placeholder={mode === EditorMode.Decode ? t('base64_placeholder') : undefined}
110+
isReadOnly={mode === EditorMode.Encode}
111+
isInvalid={hasInvalidBase64}
112+
onChange={onBase64InputChange}
113+
/>
114+
{hasInvalidBase64 && <div className={styles.errorText}>{t('invalid_base64')}</div>}
115+
</div>
116+
</div>
117+
<OptionsBar isUrlSafe={urlSafe} onUrlSafeChange={setUrlSafe} />
118+
<ActionBar output={output} />
119+
</Section>
120+
);
121+
};
122+
123+
export default Decoder;
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
@use '@logto/website-ui-foundation/scss/_underscore.scss' as _;
2+
3+
.actionBar {
4+
width: 100%;
5+
display: flex;
6+
gap: _.unit(6);
7+
align-items: flex-end;
8+
justify-content: flex-end;
9+
margin: _.unit(6) 0;
10+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { Button } from '@logto/website-ui-foundation';
2+
import { toast } from 'react-hot-toast';
3+
4+
import { usePhrases } from '../../i18n';
5+
6+
import styles from './index.module.scss';
7+
8+
type Props = {
9+
readonly output: string;
10+
};
11+
12+
const ActionBar = ({ output }: Props) => {
13+
const { t } = usePhrases();
14+
15+
const onCopy = async () => {
16+
try {
17+
await navigator.clipboard.writeText(output);
18+
toast.success(t('copy_to_clipboard'));
19+
} catch {
20+
toast.error(t('copy_failed'));
21+
}
22+
};
23+
24+
return (
25+
<div className={styles.actionBar}>
26+
<Button type="primary" disabled={output === ''} onClick={onCopy}>
27+
{t('copy')}
28+
</Button>
29+
</div>
30+
);
31+
};
32+
33+
export default ActionBar;
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
@use '@logto/website-ui-foundation/scss/_underscore.scss' as _;
2+
@use '@logto/website-ui-foundation/scss/_media.scss' as media;
3+
4+
.headline {
5+
font-weight: bold;
6+
font-size: 48px;
7+
line-height: 56px;
8+
color: #fff;
9+
margin: _.unit(25) 0 _.unit(16);
10+
z-index: 0;
11+
display: flex;
12+
align-items: center;
13+
justify-content: center;
14+
gap: _.unit(3);
15+
16+
h1 {
17+
font-size: inherit;
18+
line-height: inherit;
19+
font-weight: inherit;
20+
}
21+
}
22+
23+
.subtitle {
24+
font-size: 18px;
25+
line-height: 26px;
26+
color: #ffffffb3;
27+
text-align: center;
28+
margin: _.unit(-12) 0 _.unit(16);
29+
max-width: 900px;
30+
z-index: 0;
31+
32+
&.light {
33+
color: #747778;
34+
}
35+
}
36+
37+
.spacer,
38+
.switchButton {
39+
opacity: 30%;
40+
color: #fffbff;
41+
}
42+
43+
.switchButton {
44+
background: none;
45+
border: none;
46+
font-size: inherit;
47+
font-weight: inherit;
48+
line-height: inherit;
49+
cursor: pointer;
50+
padding: _.unit(1) _.unit(2);
51+
52+
&:hover {
53+
border-radius: 8px;
54+
background: rgba(255, 255, 255, 10%);
55+
opacity: 60%;
56+
}
57+
}
58+
59+
@include media.medium {
60+
.headline,
61+
.subtitle {
62+
max-width: 80%;
63+
}
64+
}
65+
66+
@include media.mobile {
67+
.headline {
68+
font-size: 32px;
69+
line-height: 40px;
70+
font-weight: bold;
71+
margin: _.unit(12) 0 _.unit(8);
72+
text-align: start;
73+
align-self: start;
74+
flex-direction: column;
75+
}
76+
77+
.subtitle {
78+
font-size: 16px;
79+
line-height: 24px;
80+
margin: _.unit(-4) 0 _.unit(12);
81+
text-align: start;
82+
align-self: start;
83+
}
84+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { usePhrases } from '../../i18n';
2+
import { EditorMode } from '../../types';
3+
4+
import styles from './index.module.scss';
5+
6+
type Props = {
7+
readonly mode: EditorMode;
8+
readonly onModeChange: (mode: EditorMode) => void;
9+
};
10+
11+
const Headline = ({ mode, onModeChange }: Props) => {
12+
const { t } = usePhrases();
13+
const modeLabel = mode === EditorMode.Decode ? t('decoder') : t('encoder');
14+
const switchLabel = mode === EditorMode.Decode ? t('encoder') : t('decoder');
15+
16+
return (
17+
<div>
18+
<div className={styles.headline}>
19+
<h1>Base64 {modeLabel}</h1>
20+
<span className={styles.spacer}>/</span>
21+
<button
22+
className={styles.switchButton}
23+
onClick={() => {
24+
onModeChange(mode === EditorMode.Decode ? EditorMode.Encode : EditorMode.Decode);
25+
}}
26+
>
27+
{switchLabel}
28+
</button>
29+
</div>
30+
<p className={styles.subtitle}>{t('subtitle')}</p>
31+
</div>
32+
);
33+
};
34+
35+
export default Headline;

0 commit comments

Comments
 (0)