diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..ac5cfcf --- /dev/null +++ b/.editorconfig @@ -0,0 +1,17 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false + +# upstream font license files, kept byte for byte as published +[OFL-ibm-plex-*.txt] +end_of_line = crlf +trim_trailing_whitespace = false diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index bf9b8dd..c51984c 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -14,6 +14,7 @@ body: - JSON formatter - JWT decoder - Base64 + - URL encoder/decoder - Invoice generator - Shell (theme, command palette, header) - Other / not sure diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 856d5e6..eabb2a8 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -6,3 +6,6 @@ contact_links: - name: CODERCOPS url: https://www.codercops.com about: Learn about the team behind toolbelt or get in touch about a project. + - name: Ask a question on Discord + url: https://discord.gg/mpGdJyjaRQ + about: Chat with the maintainers and other contributors. Issue claims still happen on GitHub. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index b4c3cbe..264e5f4 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -10,6 +10,14 @@ Release PRs go `develop` → `production` and are merged as a merge commit Closes # +## How I tested this + + + +## AI assistance + + + ## Type of change - [ ] Bug fix @@ -20,8 +28,10 @@ Closes # ## Checklist +- [ ] I was assigned the linked issue - [ ] `npm run lint && npm run test && npm run build` passes locally - [ ] Logic changes live in `lib/` and have a test in `lib/__tests__/` +- [ ] For a bug fix: the new test fails without my change - [ ] No analytics, trackers, or calls that send user data off-device - [ ] Works in both light and dark themes (if UI changed) - [ ] Inputs have labels and it works with the keyboard alone (if UI changed) diff --git a/.github/workflows/claim-check.yml b/.github/workflows/claim-check.yml new file mode 100644 index 0000000..aaf337e --- /dev/null +++ b/.github/workflows/claim-check.yml @@ -0,0 +1,95 @@ +name: Claim check + +# A contributor's PR counts only for an issue that was assigned to them first +# (see CONTRIBUTING.md). This check says so on the PR itself, so nobody has to +# wait for a review to find out. +# +# It uses pull_request_target so fork PRs get an answer without a maintainer +# approving the run. That is safe here: the job never checks out or runs the +# PR's code, and its token can only read. +on: + pull_request_target: + branches: [develop] + types: [opened, edited, reopened, synchronize] + +permissions: + issues: read + pull-requests: read + +concurrency: + group: claim-check-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + assigned: + name: Linked issue is assigned to the author + # Maintainers and bots don't claim issues. + if: >- + github.event.pull_request.user.type != 'Bot' && + !contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.pull_request.author_association) + runs-on: ubuntu-latest + steps: + - name: Check the linked issue + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + PR: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + + pr=$(gh api graphql \ + -f owner="${REPO%/*}" -f name="${REPO#*/}" -F pr="$PR" \ + -f query='query($owner: String!, $name: String!, $pr: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $pr) { + author { login } + body + closingIssuesReferences(first: 20) { + nodes { number repository { nameWithOwner } assignees(first: 20) { nodes { login } } } + } + } + } + }' \ + --jq '.data.repository.pullRequest') + author=$(jq -r '.author.login // ""' <<<"$pr") + + # The issues GitHub will close when this PR merges ("Closes #12" in the description). + linked=$(jq --arg repo "$REPO" '[ .closingIssuesReferences.nodes[] | { + ref: (if .repository.nameWithOwner == $repo then "#\(.number)" else "\(.repository.nameWithOwner)#\(.number)" end), + assignees: [.assignees.nodes[].login] + } ]' <<<"$pr") + + # GitHub can take a moment to record those links on a new PR, so when it + # has none, read "Closes #12" from the description here. + if [ "$(jq length <<<"$linked")" -eq 0 ]; then + numbers=$(jq -r '[ (.body // "") | scan("(?i)\\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\\s*:?\\s+#([0-9]+)") | .[0] ] | unique | .[]' <<<"$pr") + for number in $numbers; do + # A number can also belong to a PR, or to nothing at all. Those don't count. + if issue=$(gh api "repos/$REPO/issues/$number" --jq 'select(.pull_request == null) | {ref: "#\(.number)", assignees: [.assignees[].login]}' 2>/dev/null) && [ -n "$issue" ]; then + linked=$(jq --argjson issue "$issue" '. + [$issue]' <<<"$linked") + fi + done + fi + + fail() { + echo "::error title=$1::$2" + printf '### %s\n\n%s\n' "$1" "$2" >> "$GITHUB_STEP_SUMMARY" + exit 1 + } + + if [ "$(jq length <<<"$linked")" -eq 0 ]; then + fail "No linked issue" "This PR isn't linked to an issue. Add \"Closes #12\" to the description, with the number of the issue that was assigned to you. If you haven't claimed one yet, comment on the issue and wait to be assigned (CONTRIBUTING.md has the rules)." + fi + + # Every linked issue has to be assigned to the PR author. + missing=$(jq -r --arg author "$author" ' + [ .[] + | select(all(.assignees[]; ascii_downcase != ($author | ascii_downcase))) + | .ref ] + | join(", ")' <<<"$linked") + + if [ -n "$missing" ]; then + fail "Issue not assigned to you" "$missing isn't assigned to @$author. Comment on the issue and wait for a maintainer to assign it before you open a PR (CONTRIBUTING.md has the rules). Once it's assigned to you, this check passes the next time it runs: push a commit or edit the PR description." + fi + + echo "Assigned to @$author: $(jq -r '[.[].ref] | join(", ")' <<<"$linked")" diff --git a/.github/workflows/description-check.yml b/.github/workflows/description-check.yml new file mode 100644 index 0000000..21807b6 --- /dev/null +++ b/.github/workflows/description-check.yml @@ -0,0 +1,106 @@ +name: Description check + +# Every contributor PR says how it was tested and whether AI tools helped (see +# CONTRIBUTING.md). This check says so on the PR itself while one of the two is +# missing or still empty, so nobody has to wait for a review to find out. +# +# It uses pull_request_target so fork PRs get an answer without a maintainer +# approving the run. That is safe here: the job never checks out or runs the +# PR's code, and its token can only read. +on: + pull_request_target: + branches: [develop] + types: [opened, edited, reopened, synchronize] + +permissions: + pull-requests: read + +concurrency: + group: description-check-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + sections: + name: Says how it was tested and whether AI helped + # Maintainers and bots are exempt. + if: >- + github.event.pull_request.user.type != 'Bot' && + !contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.pull_request.author_association) + runs-on: ubuntu-latest + steps: + - name: Check the description + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + PR: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + + # Read the description as it is now, so a re-run sees later edits. + gh api "repos/$REPO/pulls/$PR" --jq '.body // ""' > body.md + + python3 - <<'PY' + import os + import re + import sys + + body = open("body.md", encoding="utf-8").read() + # The hints in the PR template are HTML comments. They aren't an answer. + body = re.sub(r"", "", body, flags=re.S) + lines = body.splitlines() + + # Section name, the headings we accept for it, and what to write there. + WANTED = [ + ( + "How I tested this", + r"how i tested|\btest(ed|ing|s)?\b|\bverif|\bvalidat|\bqa\b", + "what you ran or clicked through to check that this works", + ), + ( + "AI assistance", + r"\bai[ -]?(assist|usage|use|disclos|tool|help|generated)|\b(use|usage) of ai\b|^\W*ai\W*$", + 'write "none", or name the tool and what you used it for', + ), + ] + + + def is_heading(line): + """A Markdown heading, or a bold line used as one.""" + return bool(re.match(r"\s{0,3}#{1,6}\s", line) or re.fullmatch(r"\s*(\*\*|__).+(\*\*|__)\s*:?\s*", line)) + + + def answer(pattern): + """The text under the first matching heading, or after a "Label: answer" line. None when there is neither.""" + for i, line in enumerate(lines): + if is_heading(line) and re.search(pattern, line, re.I): + below = [] + for following in lines[i + 1 :]: + if is_heading(following): + break + below.append(following) + return "\n".join(below).strip() + label, colon, inline = line.partition(":") + if colon and len(label) < 60 and re.search(pattern, label, re.I) and inline.strip(" *_"): + return inline.strip(" *_") + return None + + + problems = [] + for name, pattern, hint in WANTED: + found = answer(pattern) + if found is None: + problems.append(f'The description has no "{name}" section. Add one: {hint}.') + elif not found: + problems.append(f'The "{name}" section is empty. Fill it in: {hint}.') + + if not problems: + print("Both sections are filled in.") + sys.exit(0) + + tail = "Edit the PR description and this check runs again (CONTRIBUTING.md has the rules)." + for problem in problems: + print(f"::error title=PR description::{problem} {tail}") + with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as summary: + summary.write("### PR description\n\n" + "\n".join(f"- {p}" for p in problems) + f"\n\n{tail}\n") + sys.exit(1) + PY diff --git a/.gitignore b/.gitignore index 9e38ce2..4328ae7 100644 --- a/.gitignore +++ b/.gitignore @@ -56,3 +56,7 @@ public/version.txt # Generated by scripts/og-assets.mjs before build/dev. generated/ + +# local tooling notes +CLAUDE.md +AGENTS.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 18a4822..5dbd3ad 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,13 +8,55 @@ We're taking part in Hacktoberfest this year, and first-time contributors are ve 1. Pick an open issue labeled [`hacktoberfest`](https://github.com/codercops/toolbelt/issues?q=is%3Aissue+is%3Aopen+label%3Ahacktoberfest+no%3Aassignee) or [`good first issue`](https://github.com/codercops/toolbelt/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22+no%3Aassignee) that nobody is assigned to. Want to build a whole new tool? Look at the [`new tool`](https://github.com/codercops/toolbelt/issues?q=is%3Aissue+is%3Aopen+label%3A%22new+tool%22) issues. 2. Comment on it to ask for it. I'll assign it to you, usually within a day. Please don't start on an issue that's assigned to someone else. -3. Take one issue at a time. Once your PR is merged, grab the next one. +3. Take one issue at a time. Once your PR is merged, grab the next one. After 3 merged PRs with us you can hold two issues at once. 4. If you're assigned and there's no PR or update for 5 days, I'll free the issue up for someone else. Just comment if you need more time. -5. Open your PR against `develop` and put `Closes #` in the description. +5. Open your PR against `develop` and put `Closes #` in the description. A check on the PR ("Claim check") fails if that issue isn't assigned to you. Another one ("Description check") fails while the "How I tested this" or "AI assistance" section of the description is missing or empty. -PRs count for Hacktoberfest once they're merged or labeled `hacktoberfest-accepted`. PRs that aren't linked to an issue, only reformat code, or change things nobody asked for will be closed, and spammy ones get the `spam` label. If you have an idea that isn't an issue yet, open an issue first so we can agree on it before you write code. +PRs count for Hacktoberfest once they're merged or labeled `hacktoberfest-accepted`. PRs that aren't linked to an issue, only reformat code, or change things nobody asked for will be closed, and spammy ones get the `spam` label. If you have an idea that isn't an issue yet, open an issue first so we can agree on it before you write code. The full rules are under [Ground rules](#ground-rules) below. -Stuck? Ask on the issue. Questions are welcome. +Stuck? Ask on the issue, or in `#oss-help` on our [Discord](https://discord.gg/mpGdJyjaRQ). Questions are welcome. Claims still happen on the issue, so the assignment stays visible to everyone. + +## Ground rules + +### AI tools + +You can use AI tools (Copilot, ChatGPT, Claude, Cursor and so on). Two conditions: + +1. Say so in the PR. If a tool wrote or rewrote more than autocomplete-sized pieces, fill in the "AI assistance" line in the PR template: which tool, and what you used it for. +2. You own every line. Run it, test it, and be ready to explain it in review. If you can't explain a change, the PR gets closed. + +PRs that are unreviewed AI output (code that doesn't run, invented APIs, a description that doesn't match the diff, unrelated rewrites) are treated as spam. + +### Spam + +These get closed and labeled `spam`: + +- a PR for an issue that isn't assigned to you, or that isn't linked to an issue at all +- formatting, whitespace or typo-only changes nobody asked for +- one fix split into several PRs +- a copy of someone else's PR +- claiming a pile of issues at once, or bulk and automated submissions +- untested generated code + +Two spam PRs and you won't be assigned more issues this month, and you're out of the CODERCOPS goodies. + +### Reviews + +Every PR needs a "How I tested this" line: what you ran or clicked through to check it works. + +For a bug fix, check that your new test fails without the fix: undo the change in the source, run the tests, then put it back. A test that passes either way doesn't catch anything. + +I review most PRs within a few days. Please don't ping repeatedly or DM maintainers about a review. If three days pass with no reply, one follow-up comment on the PR is fine. + +### Goodies + +The top 5 contributors this October get a CODERCOPS T-shirt and stickers. You need at least 5 merged PRs labeled `hacktoberfest-accepted` across chatcops, ogcops, toolbelt and discord-bot, merged between Oct 1 and Oct 31 (UTC), and ties go to whoever got there first. To keep it fair: + +- only PRs for issues that were assigned to you count +- a fix split into several PRs counts once +- what counts is the maintainers' call, and that call is final + +These goodies come from CODERCOPS through our own open source program. They're separate from Hacktoberfest, so anything Hacktoberfest itself gives out is a different thing with its own rules. ## Getting set up @@ -56,12 +98,12 @@ npm run lint && npm run test && npm run build ## Adding a tool 1. Add a typed entry at the end of the `TOOLS` array in `lib/tools.ts`, filling every field of the `Tool` interface. - - Pick `accentVar` from the theme variables in `app/globals.css` (`--cyan`, `--amber`, `--rose`, `--violet`, `--sky`, `--lime`) and set `accentHex` to that variable's dark-theme value. + - Pick `accentVar` from the accent variables in `app/globals.css` (`--cyan`, `--amber`, `--violet`, `--sky`, `--lime`, `--green`, `--fuchsia`) and set `accentHex` to that variable's dark-theme value. Take one that no tool uses yet. If they're all taken, reuse the one the fewest tools have. Don't use `--rose`: it's the color of error messages. - `heroDim` must be `""` or part of `heroHeading`. - Write 3 or 4 FAQs. Keep `metaTitle` under 60 characters and `metaDescription` around 150 to 160. - Keep `features` and `ogDescription` to plain Latin text; the social card font has no arrows or symbols. 2. Create `app/(tools)//page.tsx`, a `Client.tsx` client component, and `opengraph-image.tsx`. Copying `page.tsx` and `opengraph-image.tsx` from `app/(tools)/base64/` and changing the slug is the easiest start. -3. Put pure logic in `lib/.ts` with tests in `lib/__tests__/.test.ts`. Use relative imports in tests; vitest has no `@/` alias. +3. Put pure logic in `lib/.ts` with tests in `lib/__tests__/.test.ts`. Tests can import with `@/` like the app does. 4. Add the tool to the "Which tool?" dropdown in `.github/ISSUE_TEMPLATE/bug_report.yml`. The home page, metadata, JSON-LD, sitemap, manifest and command palette pick the new tool up from the registry automatically. Several people may be adding tools at the same time, so rebase on `develop` before asking for review. diff --git a/README.md b/README.md index 4a04634..b88092d 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ [![CI](https://github.com/codercops/toolbelt/actions/workflows/ci.yml/badge.svg)](https://github.com/codercops/toolbelt/actions/workflows/ci.yml) [![GitHub stars](https://img.shields.io/github/stars/codercops/toolbelt?labelColor=0A0B0F&color=9E7BFF&logo=github)](https://github.com/codercops/toolbelt/stargazers) [![Last commit](https://img.shields.io/github/last-commit/codercops/toolbelt?labelColor=0A0B0F&color=8A93A8)](https://github.com/codercops/toolbelt/commits) +[![Discord](https://img.shields.io/badge/chat-Discord-5865F2.svg?labelColor=0A0B0F&logo=discord&logoColor=white)](https://discord.gg/mpGdJyjaRQ) [![Made by CODERCOPS](https://img.shields.io/badge/by-CODERCOPS-9E7BFF.svg?labelColor=0A0B0F)](https://www.codercops.com) A developer's toolbelt: fast, privacy-first browser tools. Everything runs client-side, so there is no login, no upload, and no data leaves your tab. Live at **[tools.codercops.com](https://tools.codercops.com)**. @@ -103,6 +104,8 @@ Note: Type-checking a fresh clone will fail if `generated/og-fonts.ts` doesn't e tools.codercops.com runs on Cloudflare Workers through the OpenNext adapter (`wrangler.jsonc`, `open-next.config.ts`), deployed by Workers Builds: build with `npx opennextjs-cloudflare build`, deploy with `npx opennextjs-cloudflare deploy` (`--env dev` for the `toolbelt-dev` develop Worker). The only build variable is `NEXT_PUBLIC_DEPLOY_ENV` (`production`, or `develop`, which makes the site noindex). The GitHub star count is cached in the `toolbelt-cache` R2 bucket. `npm run preview` builds and serves the Worker locally. If you fork it, update the domain in `lib/tools.ts` (`SITE_URL`), `lib/og.tsx:198,212`, `lib/invoice-pdf.ts:315` and `routes` in `wrangler.jsonc` so the sitemap and canonical URLs point at your host, and update `GITHUB_REPO` in `lib/tools.ts` so the star-count link points at your fork. +Verifying site ownership in Google Search Console and Bing Webmaster Tools is optional. Configure `GOOGLE_SITE_VERIFICATION` and/or `BING_SITE_VERIFICATION` as secrets on your Worker rather than plain-text environment variables (plain-text variables are removed on subsequent deploys because `keep_vars` is not enabled in `wrangler.jsonc`, whereas secrets persist across deploys, are available at runtime, and require no rebuild). Run `npx wrangler secret put GOOGLE_SITE_VERIFICATION` and `npx wrangler secret put BING_SITE_VERIFICATION` (pass `--env dev` for the development Worker). Alternatively, add both with type `Secret` under Cloudflare dashboard → Workers & Pages → your Worker → Settings → Variables and Secrets. Each token renders its ownership meta tag (`google-site-verification`, and Bing's `msvalidate.01`) only when its secret is configured, so previews and forks that leave them unset render no verification tags at all. + ## Add a tool The tool set is a single typed registry in [`lib/tools.ts`](lib/tools.ts). The home grid, per-tool metadata, JSON-LD (SoftwareApplication, breadcrumb, FAQ), social share images, hero, FAQ, CTA, sitemap, web manifest, and command palette all derive from it. To add a tool: @@ -135,7 +138,7 @@ See [CONTRIBUTING.md](./CONTRIBUTING.md) for the full flow. ## Contributing -Contributions are welcome. See [CONTRIBUTING.md](./CONTRIBUTING.md) and the [Code of Conduct](./CODE_OF_CONDUCT.md). In short: keep logic in `lib/`, add a test, and make sure `npm run lint && npm run test && npm run build` passes. If a tool proves useful, a star helps other developers find it. +Contributions are welcome. See [CONTRIBUTING.md](./CONTRIBUTING.md) and the [Code of Conduct](./CODE_OF_CONDUCT.md). Questions? Come chat on our [Discord](https://discord.gg/mpGdJyjaRQ). In short: keep logic in `lib/`, add a test, and make sure `npm run lint && npm run test && npm run build` passes. If a tool proves useful, a star helps other developers find it. ## Security diff --git a/app/(tools)/base64/Base64Client.tsx b/app/(tools)/base64/Base64Client.tsx index f168b5c..a1fb155 100644 --- a/app/(tools)/base64/Base64Client.tsx +++ b/app/(tools)/base64/Base64Client.tsx @@ -10,6 +10,7 @@ import { EncodingsTab } from "./components/EncodingsTab"; import { ShortcutsLegend } from "@/components/shared/ShortcutsLegend"; import { useRegisterCommands } from "@/components/shared/CommandPalette"; import { cn } from "@/lib/cn"; +import { usePlatform } from "@/lib/platform"; type TabId = "text" | "image" | "file" | "decode" | "encodings"; @@ -21,11 +22,8 @@ const TABS: { id: TabId; label: string; icon: React.ReactNode; hint: string }[] { id: "encodings", label: "Other encodings", icon: , hint: "Hex · Base32 · Base58 · Ascii85 · Hashes" }, ]; -const isMac = - typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform); -const MOD = isMac ? "⌘" : "Ctrl"; - export function Base64Client() { + const { modKey, shiftKey } = usePlatform(); const [tab, setTab] = useState("text"); const tablistRef = useRef(null); @@ -91,10 +89,10 @@ export function Base64Client() { diff --git a/app/(tools)/base64/components/FileTab.tsx b/app/(tools)/base64/components/FileTab.tsx index f6a0519..1e7dbdb 100644 --- a/app/(tools)/base64/components/FileTab.tsx +++ b/app/(tools)/base64/components/FileTab.tsx @@ -21,6 +21,7 @@ export function FileTab() { const [dragging, setDragging] = useState(false); const [batch, setBatch] = useState(null); const fileRef = useRef(null); + const pickerRef = useRef(null); const batchRef = useRef(null); const { push } = useToast(); @@ -91,14 +92,20 @@ export function FileTab() { }} onDragLeave={() => setDragging(false)} onDrop={onDrop} - onClick={() => fileRef.current?.click()} className={cn( - "cursor-pointer rounded-lg border border-dashed transition-all p-6 text-center", + "relative cursor-pointer rounded-lg border border-dashed transition-all p-6 text-center", dragging ? "border-[var(--cyan)] bg-[rgba(0,229,199,0.05)]" : "border-[var(--hairline-strong)] bg-[var(--bg-soft)] hover:border-[var(--hairline-strong)]" )} > + ) : ( -
+
Drop any file here
diff --git a/app/(tools)/base64/components/ImageTab.tsx b/app/(tools)/base64/components/ImageTab.tsx index 149ccdc..b61bd33 100644 --- a/app/(tools)/base64/components/ImageTab.tsx +++ b/app/(tools)/base64/components/ImageTab.tsx @@ -21,6 +21,7 @@ export function ImageTab() { const [info, setInfo] = useState(null); const [dragging, setDragging] = useState(false); const fileRef = useRef(null); + const pickerRef = useRef(null); const { push } = useToast(); async function handleFile(file: File) { @@ -76,14 +77,20 @@ export function ImageTab() { }} onDragLeave={() => setDragging(false)} onDrop={onDrop} - onClick={() => fileRef.current?.click()} className={cn( - "cursor-pointer rounded-lg border border-dashed transition-all p-6 text-center", + "relative cursor-pointer rounded-lg border border-dashed transition-all p-6 text-center", dragging ? "border-[var(--cyan)] bg-[rgba(0,229,199,0.05)]" : "border-[var(--hairline-strong)] bg-[var(--bg-soft)] hover:border-[var(--hairline-strong)]" )} > +
) : ( -
+
Drop an image here diff --git a/app/(tools)/base64/components/TextTab.tsx b/app/(tools)/base64/components/TextTab.tsx index dd1c996..35a6838 100644 --- a/app/(tools)/base64/components/TextTab.tsx +++ b/app/(tools)/base64/components/TextTab.tsx @@ -5,6 +5,7 @@ import { ArrowLeftRight, AlertCircle, Info, FileCode, Scissors, Archive } from " import { base64ToBytes, bytesToBase64, + decodeUtf8Bytes, detectBase64, formatBytes, gunzipBytes, @@ -16,6 +17,7 @@ import { } from "@/lib/base64-utils"; import { CopyButton } from "@/components/shared/CopyButton"; import { cn } from "@/lib/cn"; +import { formatModShiftKey, usePlatform } from "@/lib/platform"; type Direction = "encode" | "decode"; @@ -23,6 +25,7 @@ type ChunkPreset = "none" | "mime76" | "pem64" | "email"; const CHUNK_SIZES: Record = { none: 0, mime76: 76, pem64: 64, email: 76 }; export function TextTab() { + const { isMac } = usePlatform(); const [direction, setDirection] = useState("encode"); const [input, setInput] = useState(""); const [output, setOutput] = useState(""); @@ -78,12 +81,12 @@ export function TextTab() { return; } } - try { - const text = new TextDecoder("utf-8", { fatal: false }).decode(bytes); - setOutput(text); + const decoded = decodeUtf8Bytes(bytes); + if (decoded.ok) { + setOutput(decoded.value); setError(null); - } catch (e) { - setError(e instanceof Error ? e.message : "Decode failed"); + } else { + setError(decoded.error); setOutput(""); } } @@ -265,7 +268,7 @@ export function TextTab() { type="button" onClick={swap} className="group w-11 h-11 rounded-full border border-[var(--hairline-strong)] bg-[var(--bg-raise)] flex items-center justify-center hover:border-[var(--cyan)] hover:shadow-[0_0_30px_-8px_rgba(0,229,199,0.6)] transition-all" - title="Swap direction (⌘⇧S)" + title={`Swap direction (${formatModShiftKey("S", isMac)})`} aria-label="Swap direction" > diff --git a/app/(tools)/json-formatter/JsonFormatterClient.tsx b/app/(tools)/json-formatter/JsonFormatterClient.tsx index bc1828c..f8c6b9c 100644 --- a/app/(tools)/json-formatter/JsonFormatterClient.tsx +++ b/app/(tools)/json-formatter/JsonFormatterClient.tsx @@ -23,8 +23,10 @@ import { HistoryPanel, useHistory } from "@/components/shared/HistoryPanel"; import { useRegisterCommands } from "@/components/shared/CommandPalette"; import { downloadBlob } from "@/lib/download"; import { cn } from "@/lib/cn"; +import { usePlatform } from "@/lib/platform"; export function JsonFormatterClient() { + const { modKey, shiftKey } = usePlatform(); const [input, setInput] = useState(""); const [output, setOutput] = useState(""); const [indent, setIndent] = useState<2 | 4>(2); @@ -290,19 +292,19 @@ export function JsonFormatterClient() { useRegisterCommands( "json-formatter", [ - { id: "json:format", section: "JSON Formatter", title: "Format", run: () => doFormat(), shortcut: ["⌘", "↵"] }, - { id: "json:minify", section: "JSON Formatter", title: "Minify", run: doMinify, shortcut: ["⌘", "M"] }, + { id: "json:format", section: "JSON Formatter", title: "Format", run: () => doFormat(), shortcut: [modKey, "↵"] }, + { id: "json:minify", section: "JSON Formatter", title: "Minify", run: doMinify, shortcut: [modKey, "M"] }, { id: "json:validate", section: "JSON Formatter", title: "Validate", run: doValidate }, - { id: "json:sort", section: "JSON Formatter", title: "Sort keys alphabetically", run: doSort, shortcut: ["⌘", "⇧", "S"] }, + { id: "json:sort", section: "JSON Formatter", title: "Sort keys alphabetically", run: doSort, shortcut: [modKey, shiftKey, "S"] }, { id: "json:diff", section: "JSON Formatter", title: diffOpen ? "Close diff panel" : "Open diff panel", run: () => setDiffOpen((v) => !v) }, { id: "json:stats", section: "JSON Formatter", title: statsOpen ? "Hide stats" : "Show stats", run: () => setStatsOpen((v) => !v) }, - { id: "json:find", section: "JSON Formatter", title: "Find in input", run: () => setFindOpen(true), shortcut: ["⌘", "F"] }, + { id: "json:find", section: "JSON Formatter", title: "Find in input", run: () => setFindOpen(true), shortcut: [modKey, "F"] }, { id: "json:fetch", section: "JSON Formatter", title: "Fetch from URL", run: () => setUrlDialog(true) }, { id: "json:sample", section: "JSON Formatter", title: "Load example", run: handleLoadSample }, - { id: "json:download", section: "JSON Formatter", title: "Download as .json", run: handleDownload, shortcut: ["⌘", "D"] }, - { id: "json:clear", section: "JSON Formatter", title: "Clear all", run: handleClear, shortcut: ["⌘", "K"] }, + { id: "json:download", section: "JSON Formatter", title: "Download as .json", run: handleDownload, shortcut: [modKey, "D"] }, + { id: "json:clear", section: "JSON Formatter", title: "Clear all", run: handleClear, shortcut: [modKey, shiftKey, "K"] }, ], - [doFormat, doMinify, doValidate, doSort, diffOpen, statsOpen, handleDownload, handleLoadSample, handleClear] + [doFormat, doMinify, doValidate, doSort, diffOpen, statsOpen, handleDownload, handleLoadSample, handleClear, modKey, shiftKey] ); // Scroll to find match diff --git a/app/(tools)/json-formatter/components/OutputPanel.tsx b/app/(tools)/json-formatter/components/OutputPanel.tsx index d4e447f..2cd7337 100644 --- a/app/(tools)/json-formatter/components/OutputPanel.tsx +++ b/app/(tools)/json-formatter/components/OutputPanel.tsx @@ -1,6 +1,7 @@ "use client"; import { useMemo, useState } from "react"; +import { parse, LosslessNumber } from "lossless-json"; import { ChevronDown, ChevronRight, List, TreePine, FoldVertical, UnfoldVertical } from "lucide-react"; import { tokenize } from "@/lib/json-utils"; import { cn } from "@/lib/cn"; @@ -146,10 +147,10 @@ function CodeView({ output, theme }: { output: string; theme: "dark" | "light" } } /* Tree view */ -function TreeView({ output, forceOpen }: { output: string; forceOpen: boolean | null }) { +export function TreeView({ output, forceOpen }: { output: string; forceOpen: boolean | null }) { const data = useMemo(() => { try { - return { ok: true, value: JSON.parse(output) } as const; + return { ok: true, value: parse(output) } as const; } catch { return { ok: false } as const; } @@ -179,7 +180,7 @@ function TreeNode({ }) { const defaultOpen = forceOpen === null ? depth < 2 : forceOpen; const [open, setOpen] = useState(defaultOpen); - const isObj = value !== null && typeof value === "object" && !Array.isArray(value); + const isObj = value !== null && typeof value === "object" && !Array.isArray(value) && !(value instanceof LosslessNumber); const isArr = Array.isArray(value); const isContainer = isObj || isArr; const entries = isObj @@ -269,6 +270,7 @@ function TreeNode({ function ValueToken({ value }: { value: unknown }) { if (value === null) return null; if (typeof value === "string") return "{value}"; + if (value instanceof LosslessNumber) return {value.toString()}; if (typeof value === "number") return {String(value)}; if (typeof value === "boolean") return {String(value)}; return {String(value)}; diff --git a/app/(tools)/json-formatter/components/Toolbar.tsx b/app/(tools)/json-formatter/components/Toolbar.tsx index 1a4f663..9728726 100644 --- a/app/(tools)/json-formatter/components/Toolbar.tsx +++ b/app/(tools)/json-formatter/components/Toolbar.tsx @@ -16,6 +16,7 @@ import { import { CopyButton } from "@/components/shared/CopyButton"; import { ShortcutsLegend } from "@/components/shared/ShortcutsLegend"; import { cn } from "@/lib/cn"; +import { formatModKey, usePlatform } from "@/lib/platform"; interface ToolbarProps { indent: 2 | 4; @@ -37,10 +38,6 @@ interface ToolbarProps { onJson5Toggle: (on: boolean) => void; } -const isMac = - typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform); -const MOD = isMac ? "⌘" : "Ctrl"; - export function Toolbar({ indent, onIndentChange, @@ -60,6 +57,8 @@ export function Toolbar({ json5, onJson5Toggle, }: ToolbarProps) { + const { isMac, modKey, shiftKey } = usePlatform(); + return (
- @@ -150,13 +149,13 @@ export function Toolbar({ diff --git a/app/(tools)/url-encoder/UrlEncoderClient.tsx b/app/(tools)/url-encoder/UrlEncoderClient.tsx new file mode 100644 index 0000000..fafd4f9 --- /dev/null +++ b/app/(tools)/url-encoder/UrlEncoderClient.tsx @@ -0,0 +1,186 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { AlertCircle, ArrowLeftRight } from "lucide-react"; +import { encodeUrl, decodeUrl, parseUrl, type UrlEncodeMode } from "@/lib/url-utils"; +import { CopyButton } from "@/components/shared/CopyButton"; +import { useRegisterCommands } from "@/components/shared/CommandPalette"; + +type Direction = "encode" | "decode"; + +export function UrlEncoderClient() { + const [direction, setDirection] = useState("encode"); + const [mode, setMode] = useState("component"); + const [plusAsSpace, setPlusAsSpace] = useState(false); + const [input, setInput] = useState(""); + + // Pure, synchronous conversion — derived from input/settings, no effect needed. + const { output, error } = useMemo((): { output: string; error: string | null } => { + if (!input) return { output: "", error: null }; + if (direction === "encode") { + const r = encodeUrl(input, mode); + return r.ok ? { output: r.value, error: null } : { output: "", error: r.error }; + } + const r = decodeUrl(input, { plusAsSpace }); + return r.ok ? { output: r.value, error: null } : { output: "", error: r.error }; + }, [input, direction, mode, plusAsSpace]); + + // When either side looks like an absolute URL, show its breakdown. + const parsed = useMemo(() => { + for (const candidate of [input, output]) { + if (!candidate) continue; + const r = parseUrl(candidate); + if (r.ok) return r.value; + } + return null; + }, [input, output]); + + function swap() { + setDirection((d) => (d === "encode" ? "decode" : "encode")); + setInput(output); + } + + useRegisterCommands( + "url-encoder", + [ + { id: "url:encode", section: "URL Encoder", title: "Encode tab", run: () => setDirection("encode") }, + { id: "url:decode", section: "URL Encoder", title: "Decode tab", run: () => setDirection("decode") }, + { id: "url:swap", section: "URL Encoder", title: "Swap direction", run: swap }, + ], + [output] + ); + + return ( +
+
+
+ + +
+ + {direction === "encode" ? ( +
+ + +
+ ) : ( + + )} +
+ +
+
+
+ + {direction === "encode" ? "Text or URL" : "Percent-encoded input"} + + {input.length} chars +
+