diff --git a/.gitignore b/.gitignore index 201c15dff..2fd9c5bae 100644 --- a/.gitignore +++ b/.gitignore @@ -32,4 +32,6 @@ local_output diff.txt docs/* -.claude \ No newline at end of file +.claude +.agents +graphify-out \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..bbb5bfc5b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,396 @@ +# AGENTS.md + +Working agreement for AI agents on this repo. Read this before touching anything. + +--- + +## Hard rules + +These are not preferences. Breaking them means the work gets rejected. + +| Rule | Detail | +|---|---| +| **No comments in code** | Do not add `//` or `/* */`. Existing comments may stay. Name things well instead. | +| **Never mention the assistant** | Not in code, not in commit messages, not in PR titles or bodies. No `Co-Authored-By`, no "Generated with", no tool names. Commits are authored by the repo owner. | +| **Never run the dev server** | No `npm run dev`, no `wxt`. Visual checks are the owner's job. Give them a checklist instead. | +| **Never commit unprompted** | Implement, verify, then stop and report. Commit and open a PR only when explicitly told to. | +| **New branch per fix/feature** | Before editing, create a new branch off the current one (whatever it is) named for the task. Make the changes there, uncommitted. Stop after verification and let the owner test visually themselves. Only commit and open a PR when explicitly told to, in that order. | +| **Fix root causes, not symptoms** | Trace a bug to where it actually originates before writing anything. A patch that suppresses the visible symptom while the real bug stays in place gets rejected, even if it looks fixed. | +| **No opportunistic changes** | Touch only what the task requires. Do not refactor, rename, reformat, or "improve" code that isn't part of the task, even if it's adjacent to what you're editing. | + +--- + +## Stack + +React 19 · TypeScript 6 · WXT 0.20 (browser extension, Chrome + Firefox) · Tailwind 4 · daisyUI 5 · framer-motion 12 · TanStack Query 5 · Biome 2 · bun (tests only) + +UI text is **Persian and RTL**. Match the surrounding tone; do not switch to English strings. + +--- + +## Verification + +Run all four before reporting anything as done: + +``` +npm run compile # tsc --noEmit +npm test # bun test +npx biome check src +npm run build # wxt build, catches CSS and asset issues tsc cannot +``` + +Checking the built CSS at `.output/chrome-mv3/assets/newtab-*.css` is often the fastest way to prove a styling claim. Use it — several bugs in this repo were classes that compile to nothing. + +**A green build is not proof that nothing changed.** To show a refactor left behaviour alone, record the byte size and content hash of `.output/chrome-mv3/background.js` and the chunks under `.output/chrome-mv3/chunks/` before the change, then rebuild and compare. The hash is derived from the content, so an unchanged hash means the emitted code is identical. A deliberate change should move those numbers by an amount you can explain — inlining one nine line component moved a chunk by exactly 38 bytes. + +--- + +## Renaming and the published extension + +Source file names do not reach the published extension. Everything under `src/` is bundled +and minified into `background.js` and a couple of chunks, `sourcemap` is off in +`wxt.config.ts`, and grepping the built output for any source file name returns nothing. A +rename that only moves files and rewrites imports produces a byte identical bundle, down to +the content hash in the chunk filename. + +Four things genuinely can break a published build, and none of them is a file name: + +- **Storage key values.** Changing a key string orphans every existing user's data. The + file holding the keys may be renamed freely; the strings inside it may not. +- **`entrypoints/`.** WXT derives the manifest from that directory, so renaming anything in + it changes the manifest. +- **The manifest** — version, permissions, `gecko.id`, `chrome_url_overrides`. +- **A dynamic import built from a template literal.** This is the only one a rename can + break silently: `tsc` cannot follow it and the build still succeeds. Grep for a backtick + immediately after `import(` before any bulk rename. There are none in this repo today. + +When someone asks whether a rename is safe to ship, answer with those four and with a +bundle comparison, not with reassurance. + +--- + +## Code quality + +**Minimal, not clever.** Solve the problem with the least code that correctly does it. No speculative configurability, no handling for cases that cannot occur here, no code written "just in case." + +**Root cause over patch.** When something is broken, find where it actually breaks and fix it there. Do not bolt a condition onto the symptom site while the real bug stays untouched elsewhere. + +**Readable over impressive.** Prefer straightforward control flow and clear names over dense one-liners, deep nesting, or clever tricks. Someone new to this codebase should be able to follow the logic on the first read, without tracing it through three files. + +**Small, single-purpose functions and components.** If a function does three unrelated things, split it. If a component is thick with unrelated concerns, it's probably several components. + +**Extract shared code only when it's actually shared.** If a piece of logic or markup is used in two or more places, pull it into its own file. If it's used in exactly one place, leave it inline where it's used. Do not pre-emptively split out single-use code into a separate file "for organization" — that just adds indirection and files to jump between for no reason. + +Two things override that, and only these two. A single-use piece large enough that inlining it would bury its consumer stays in its own file — a hundred lines of markup or drawing code does not belong in the middle of a component. Pure logic worth a test also stays in its own dependency free module, because that is the only way it can be tested here (see Testing). Both are judgments about whether the consumer gets worse, not about tidiness, so say which one you are invoking. + +**Reuse before you write.** Check `src/components/ui`, `@/common/utils`, and the relevant feature folder for something that already does this before adding a new helper or duplicating logic. + +**Small diffs.** Changes should be traceable to the task. Do not rename variables, reformat untouched code, or restructure files you weren't asked to touch — see "No opportunistic changes" above. This only applies to unprompted changes: if the owner explicitly asks for a rename, cleanup, or broader refactor, do it, scoped to what was asked. + +**Flag it, don't silently fix it.** If while working you notice unrelated issues in code you touched or passed through — bad variable names, code that's harder to follow than it should be, logic that could be simplified — do not fix it as part of the current task. Mention it as a suggestion in your report instead. Only act on it if the owner then asks you to. + +**Say when you're unsure.** If the correct fix depends on something you don't actually understand yet, investigate or ask — don't guess and ship a plausible-looking change. If the uncertainty is about a package (an API that seems to have changed, an unfamiliar option, behaviour that doesn't match what you'd expect), check that package's official docs for the exact version pinned in this repo before implementing, rather than assuming from general knowledge. + +--- + +## Project structure + +One structure, applied to everything. A new file that does not fit it means the +structure is being worked around, not extended. + +### Layers + +``` +src/components/ui/ presentational primitives, no app knowledge +src/components/ cross cutting components that do know the app +src/common/ src/hooks/ src/context/ src/services/ non component globals +src/layouts/ src/pages/ features +``` + +Imports point upward through that list and never downward. **Nothing at or above +`src/components/` may import from `src/layouts/**` or `src/pages/**`.** If it needs to, +it is not global: either it belongs inside that one feature, or the thing it reaches +for belongs further up. + +### Where a new file goes + +Two questions, in this order. **Which feature owns it** — count the places that will +import it: + +| Importers | Owner | +|---|---| +| One file, or one feature folder | That feature folder | +| Two or more sibling folders | Their nearest common parent | +| Two or more unrelated areas | The matching global layer | + +Run the same count backwards before leaving something in a global folder. A global file +with a single consumer is misplaced, not reusable. + +**Then which folder inside that owner** — that is settled by the file's role, under +"Shape of a feature folder" below, and never by the import count. A helper used once and +a helper used ten times both live in `utils/`. The count decides ownership; the role +decides placement. + +### Shape of a feature folder + +Every feature folder looks like this, at every depth: + +``` +/ + ..tsx entry + -setting.tsx settings panel, when it has one + .context.tsx provider, when it has one + types.ts constants.ts this feature's own types and constants, flat + components/ sub components of this feature + variants/ alternate renderers this feature registers + hooks/ utils/ role folders, whenever the feature has files of that kind + __tests__/ + / only when it has its own entry file; same shape, recursively +``` + +**Sub components go in `components/`,** never a folder named after what they happen to +be. One level of nesting inside `components/` is fine for a named group that belongs to +one sub feature. + +**Every file sits in the folder for its role,** whether the feature has one of them or +twenty. A helper goes in `utils/`, a hook in `hooks/`, a sub component in `components/`, +an alternate renderer in `variants/`. A role folder with a single file is correct and +expected; a role folder that would be empty is simply absent. The point is that any +feature folder can be read without opening it, and that the same kind of file is always +found in the same place. + +**Types and constants are the exception: inside a feature they stay flat** as `types.ts` +and `constants.ts` in the feature root, because they describe the feature itself rather +than being a collection of like things. They take a folder only in the global layer, +where many unrelated features' shapes and values live side by side. + +**No folder name outside that list.** Not a second word for something already named +there, not a folder standing in for a single file's role. + +**A feature or sub feature folder's name matches its entry file's name,** plural or +singular included: `habit/` holds `habit.widget.tsx`. This does not reach role folders or +a named group inside `components/` — those hold a set of files and have no entry to match. + +### Naming + +kebab-case for every file and folder. A file that exports no JSX is `.ts`, never `.tsx`. + +| Role | Pattern | +|---|---| +| Widget entry | `.widget.tsx` | +| Non widget area entry | `.layout.tsx` | +| Routed page | `.page.tsx` | +| Settings panel | `-setting.tsx` | +| Context provider | `.context.tsx` | +| Server state hook | `-.hook.ts` | +| Local React hook | `use-.ts` | +| cva class variants | `.variants.ts`, beside the component | +| Alternate size or display renderer | `variants/-.tsx` | +| Domain shape | `.interface.ts`, or `types.ts` for a folder's own types | +| Constants | `constants.ts` | +| Helpers | `utils/.ts`, named for what the helper does | +| Test | `__tests__/.test.ts` | + +Take the suffix from that table rather than inventing one. `variants` is the single word +carrying two meanings, and they do not mix: as a file suffix it is cva classes beside a +component, as a folder it is the alternate renderers a feature registers. + +**A role word is part of the name, joined with a hyphen, never a dot.** +`holiday-badge.tsx`, not `holiday.badge.tsx`. `habit-item-skeleton.tsx`, not +`habit-item.skeleton.tsx`. Only the suffixes in the table above take a dot, and that list +is closed — `.item`, `.badge`, `.modal`, `.dropdown`, `.skeleton` and the rest are not +suffixes, they are the last word of the name. + +### Before adding a file + +1. Does it already exist? Check `src/components/ui`, `@/common/utils`, the feature folder. +2. Who owns it? Apply the table above. One consumer is rarely a reason for a new file — + check the two exceptions in Code quality before deciding it is. +3. A component? Then `components/` of the owning feature — or `src/components/ui`, but + only for a generic primitive with no app knowledge that other areas would reuse. +4. Take the suffix from the naming table. +5. Does the feature already have the role folder this file belongs in? Create it if + not; a single file in it is fine. +6. Re-read the import direction rule before calling it done. + +Documentation lives in this file, not in a README beside the code it describes. Nothing +keeps those in sync and they go stale without anyone noticing. + +--- + +## Conventions + +**Check `src/components/ui` first.** Before implementing any UI, look in `src/components/ui` for something that already covers it and import from `@/components/ui`. Do not hand roll a dialog or a popover. If the task genuinely needs a component that other parts of the app would reasonably reuse and it isn't in `src/components/ui` yet, build it there and use it from that location — don't leave a reusable component sitting in a feature folder. + +**Import through a barrel, not past it.** Where a folder has an `index.ts` — +`@/components/ui`, `@/components/gallery` — import from the folder, never from the file +behind it. The one exception is a file inside that same folder importing a sibling: +`popover-menu.tsx` reaches `@/components/ui/portal/portal` directly because going through +its own barrel would be a circular import. A deep path from outside is drift; a deep path +from inside the barrel's own folder is deliberate, so leave it. + +**Responsiveness matters, down to 500px wide.** This is a desktop browser extension: it renders in a new tab on a computer, never on a phone. 500px is the narrowest width worth supporting, so a layout that holds from 500px up is done — do not spend effort on narrower breakpoints or phone specific behaviour. + +**Semantic HTML and accessibility are not optional.** Use semantic tags (`button`, `nav`, `header`, `label`, etc.) instead of generic `div`/`span` where one fits, and take `aria-*` attributes, roles, and keyboard/focus behaviour seriously — not just for interactive elements borrowed from `src/components/ui`, but for anything new you build. + +**Variants live beside the component** as `*.variants.ts` using `cva`. Extend those rather than piling classes at the call site. A folder named `variants/` is a different thing entirely — see the naming table above. + +**Class merging** goes through `cn()` in `@/common/utils/cn` (clsx + tailwind-merge). + +**Animation** uses `Motion` and `Presence` from `@/common/motion`, never raw `framer-motion`. The wrappers are what make optimisation mode work. + +**Storage** goes through `@/common/storage`. Every key is typed in `src/common/constants/store.key.ts`. Deprecated keys get purged via `purgeDeprecatedStorageKeys`. + +**Cross component messaging** uses `callEvent` / `listenEvent` from `@/common/utils/call-event`, typed in the same file. + +**Icons** come from `Icon` in `@/icons`. + +**Path aliases are declared one per folder** in `wxt.config.ts`, and `@` on its own resolves to the repo root, not to `src`. A folder with no alias there can only be reached as `@/src/` through WXT's generic `@/*` fallback. That spelling is a missing alias, not a convention — add the folder to the alias map instead of writing it. + +**Analytics** via `@/analytics`. + +--- + +## Colour and theming + +Themes are chosen by a `data-theme` attribute on ``, never by OS preference, and a theme can also be fetched at runtime from a CDN. **The set of themes is open ended, so no code may assume what a token contains** — not its lightness, not its hue, not whether it is opaque. + +That single constraint produces every rule below. They apply to any colour decision anywhere in the app, not just to the themes that happen to ship today. + +### Colour belongs to a token, unless it depicts something + +Before writing any colour, decide which of two kinds it is. + +**Chrome** is the interface: surfaces, text, borders, states, emphasis. Chrome must come from a token, because it has to survive a theme nobody has written yet. A literal colour in chrome is a bug even when it looks right today. + +**Content** is a colour that carries its own meaning and would be wrong to re-theme: a thing being depicted (artwork, an illustrated object), a palette the user picks a value out of, a colour derived from an image, or a fill handed to an API that cannot take a class. Content is correctly hardcoded, and converting it to a token breaks it. + +When the same non-token colour appears in more than a couple of places, it is neither — it is a missing token. Name it once in the theme layer and point every site at that name. + +### Tokens come in pairs, and the pair is the unit + +Every surface token has a matching content token that is the only safe foreground on it. Use them together. Writing a literal foreground on a token background works until the token moves, and then it fails silently, because nothing in the build checks contrast. + +**When you add or edit a theme, check every pair.** Convert both sides to relative luminance and compute the WCAG ratio: nothing below **3:1**, and anything under 4.5:1 needs a reason. Two things will mislead you when you do: + +- **A token carrying alpha cannot be scored on its own.** It composites over whatever is behind it, so a naive reading pairs two near-identical values and reports a failure that does not exist. Score opaque pairs; judge translucent ones by eye. +- **A low ratio can be the brand rather than a defect.** If fixing it means changing the brand colour, it is not yours to fix — record it instead. + +### Never put an opacity modifier on a surface token + +Tailwind compiles `/N` to a `color-mix` against transparent, which **multiplies** whatever alpha the token already has. A theme is free to define its surfaces as translucent, and some do. The same class then lands anywhere between its nominal value and near zero depending on the theme, so an element styled this way disappears in exactly the themes where it mattered. + +To tint a surface, dilute the **content** token instead. A content token is near opaque in any sane theme and contrasts its own background by definition, so one class behaves the same everywhere: a light wash on dark themes, a dark wash on light ones. + +### Anything drawn over an image is its own context + +The app renders over a user supplied wallpaper, and individual surfaces may carry their own artwork. Chrome floating on unknown pixels cannot borrow lightness from the theme, because the theme says nothing about what is behind it. Use a token pair that is dark-surface-plus-light-foreground in every theme, and rely on it rather than on the surface tokens, which may be transparent or may invert. + +### Never use the OS-keyed variants + +`dark:` and `light:` key off `prefers-color-scheme`, which is unrelated to `data-theme`. They fire for a user whose OS disagrees with the theme they chose. Grep for them rather than assuming one is load bearing: none belong in `src`, so every hit is something to remove. + +### Prefer the project's semantic class over the raw utility + +`src/index.css` defines short names for the combinations this app actually uses — its surfaces, its body and muted text, its border, its widget radius. Use those rather than the underlying utility. They are the single place a decision like "what is a muted foreground" can be changed, and a raw utility at a call site opts that site out of any future change. When a combination you need has no name yet, add one there rather than inventing a new opacity step inline. + +### Verifying + +A theme is an attribute and a class is text, so both claims are checkable and neither should be asserted from memory: + +``` +npm run build +grep -o '[^{]*{[^}]*}' .output/chrome-mv3/assets/newtab-*.css +``` + +No output means the class compiled to nothing. Note that the compiler merges selectors that share a declaration, so match loosely — an exact `.class{` anchor can miss a rule that is present. To read what a token actually resolves to, pull the theme's block out of the same file. + +### Known debt + +Four shapes of debt exist in bulk. Do not treat them as fixed; do not sweep them inside an unrelated task; never add to them. Counts move every time work lands, so measure rather than quote a number from here: + +``` +grep -rnoE '(bg|text|border|shadow|ring|from|to|via)-[a-z-]+/[0-9]+' src | wc -l +``` + +That is a starting point, not an answer: it counts every opacity modifier, and the ones on content tokens are the prescribed way to tint. Narrow it to the token you are actually chasing before reporting a figure. + +- **Opacity modifiers on surface tokens.** Not swept because a theme that defines a surface translucent *means* it to be faint, so most sites read as thin rather than broken, and a blanket rewrite would change every theme to repair the handful that break. Fix them when you are already in the file. +- **Hardcoded colours**, in five shapes: `white`/`black` classes, numbered palette classes, raw hex literals, `rgb()`/`rgba()` literals, and arbitrary-value classes. Counting only classes in `.tsx` misses more than half of it — scan `.ts` and raw literals too. A meaningful share of them are content by the test above and must stay as they are; the rest are chrome. +- **Theme stylesheets carrying rules for class names that no longer exist.** A theme file outlives the markup it was written against, so a selector living there is not evidence the class is still used. Grep `src` before trusting one. +- **The semantic class and its raw equivalent both in wide use for the same thing.** New code uses the semantic one. + +--- + +## Testing + +`bun test` only runs on **pure modules**. There is no React testing setup, so a hook or component cannot be rendered in a test. + +When logic is worth covering, extract it into a dependency free module and test that. Precedents: + +- `src/layouts/widgets/layout-engine/` — grid collision maths +- `src/layouts/widgets/pet/utils/pet-movement.ts` — pet movement maths +- `src/common/utils/animation-timing.ts` — shared timing plus the retain predicate + +A test file must not transitively import `@/services/api`; it reads `browser.runtime.getManifest()` at module scope and bun has no `browser` global. That is why timing constants live in their own module rather than next to the hook that uses them. + +Prefer a test that would fail loudly on regression over one that restates the implementation. The layout engine has a timing budget test because the bug it guards was a 112 second freeze. + +--- + +## Git and PR workflow + +**Commit messages** state the problem, the actual cause with the offending code, then the fix. Wrap at ~76 characters. No bullet soup without a lead in. + +**PR bodies** follow: Problem → Root cause with the real snippet → Changes → anything deliberately left out → Testing. Include measurements when you have them. + +**gh CLI** lives at `C:\Program Files\GitHub CLI\gh.exe` and is not on PATH — this is where it's installed for the user `Shak`. Call it by full path, from PowerShell for anything with a multiline body. + +**Conflicts are resolved by blending, never by taking one side.** Every conflict in this session needed both halves. "Accept incoming" would have silently reverted merged work. + +**Do not force push over someone else's commit.** If a colleague pushed to your branch, create a fresh branch at your known good commit and open a new PR. Confirm with `git diff` that the tree is identical before assuming their push broke something; a `git pull` merge often resolves to the same tree. + +--- + +## Tooling notes for this machine + +- Windows. Bash and PowerShell are both available and take their own syntax. +- The Bash tool's heredocs choke on some TSX. Use a Python heredoc with exact string replacement, or the file writing tool. Assert the match count before replacing so a silent no-op is impossible. +- Scoped replacements only. A blanket string replace once rewrote import paths (`@/common/wallpaper.interface` became `@/common/activeWallpaper.interface`). Use word boundaries and limit the region. +- A replacement anchored on the closing quote misses deeper paths, and nothing catches it. Replacing `'@/src/icons'` left `'@/src/icons/types'` behind; the old path still resolved, so `tsc`, biome, the tests and the build all stayed green with one file unconverted. Match on the prefix, or assert the total count against a number you measured first and let the script refuse to write when it disagrees. + +--- + +## Do not break these + +Deliberate solutions that look wrong until you know why. Changing them reintroduces a fixed bug. + +**daisyUI already animates modals, in both directions.** `.modal` transitions `visibility` with `allow-discrete`, and `@starting-style` covers the enter. Do not add your own enter animation on top; several earlier attempts did exactly that and none of them worked, because the real problem was elsewhere. Two consequences: +- The dialog must stay mounted and only toggle `open`. Unmounting it kills the exit. +- `@starting-style` covers `.modal` but **not** `.modal-box`. A dialog that mounts already open skips the slide up, which is why `Modal` renders closed for one frame via `open={isOpen && isMounted}`. That line looks pointless. It is not. + +**Optimisation mode has two independent paths.** framer is handled by the `Motion` and `Presence` wrappers; CSS transitions are handled by the `html.optimal-mode` class and one rule in `index.css`. A new animation needs whichever path it belongs to. Keyframe animations are deliberately left running so spinners and the notification ping still work. + +**`voice-search.portal.tsx` starts the microphone in a mount effect.** Never convert it to always mounted, however tempting it is for animation consistency. + +**`containerType: 'size'`** on widget containers looks like dead config. There are no `@container` queries anywhere, so it is inert, not a hot spot. Removing it can change intrinsic sizing. Leave it unless you verify visually. + +--- + +## Design decisions + +Intentional behaviour. Not bugs, do not "fix" them. + +**Widget canvas collision is push down only, with no compaction.** Gaps between widgets are deliberate and must survive a move. The previous backtracking solver froze the extension for 112 seconds on a single drag; do not reintroduce one. Compaction exists behind an option and is off. + +**Toasts are deliberately always dark**, in every theme. They are a transient layer over the page rather than part of it, so they do not follow the theme tokens and their colours are written literally. The literals sit inside Tailwind arbitrary-value classes, which the class scanner only sees as static text, so they cannot be lifted into constants. Leave them. + +**Pet food rises from below the floor** rather than dropping from above. This was changed once and reverted on request. + +--- + +## Reporting + +Lead with the cause, not the fix. Show the offending code. When a claim can be measured or grepped, do that instead of asserting it. + +Say plainly when a bug predates the current work, when something was left out and why, and when an earlier statement turns out to be wrong. Several fixes in this session were only correct because a wrong first answer got corrected rather than defended. diff --git a/BUILD_INSTRUCTIONS.md b/BUILD_INSTRUCTIONS.md deleted file mode 100644 index 0e4f0caf1..000000000 --- a/BUILD_INSTRUCTIONS.md +++ /dev/null @@ -1,51 +0,0 @@ -# Build Instructions for Widgetify Extension - -## Environment Setup - -This extension is built exclusively via Docker, which pins the exact toolchain used for our releases: -- Node.js 24.2.0 -- npm 11.3.0 - -You do not need Node.js, npm, or any dependencies installed on your host machine -- only Docker. - -## Prerequisites - -- Docker Engine -- Docker Compose (v2, i.e. the `docker compose` command) - -## Steps - -1. Extract the source code to a directory. -2. Navigate to the project directory: -```bash -cd widgetify-extension -``` -3. Build: -```bash -docker compose up --build -``` - -## After the build finishes - -- The built Firefox extension will appear on your local machine in the `local_output` folder (synced via a Docker volume from the container's `.output` directory). -- The zipped extension and matching source archive will be at: - - `local_output/widgetify-webapp--firefox.zip` - - `local_output/widgetify-webapp--sources.zip` - -## Verifying a reproducible build - -Because this build relies on the committed `package-lock.json` and pinned Docker base image, running it twice from the same unmodified source should produce byte-identical output. To verify: -```bash -docker compose up --build -mv local_output/firefox-mv2 /tmp/build1 -docker compose up --build -diff -rq /tmp/build1 local_output/firefox-mv2 -``` -No output from `diff` means the build is reproducible. - -## Notes - -- Always build from a clean, unmodified copy of the source. -- Do not modify source files, `package.json`, or `package-lock.json` before building. -- Do not run `npm install` or `npm update` outside the container -- the lockfile must be installed as-is via `npm ci`, which happens automatically inside the Docker build. -- If you have questions or issues, contact the extension developer. \ No newline at end of file diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 92309b3ba..000000000 --- a/Dockerfile +++ /dev/null @@ -1,9 +0,0 @@ -FROM node:24.2.0-bookworm - -RUN npm install -g npm@11.3.0 - -WORKDIR /app - -COPY . . - -RUN npm install --force \ No newline at end of file diff --git a/background/constants.ts b/background/constants.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/bun.lock b/bun.lock index 0c931d18e..d291b3b5a 100644 --- a/bun.lock +++ b/bun.lock @@ -12,7 +12,6 @@ "@wxt-dev/webextension-polyfill": "1.0.0", "axios": "1.18.0", "canvas-confetti": "1.9.4", - "chart.js": "4.5.1", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "framer-motion": "12.40.0", @@ -23,7 +22,6 @@ "motion": "12.40.0", "ms": "2.1.3", "react": "19.2.7", - "react-chartjs-2": "5.3.1", "react-colorful": "5.7.0", "react-dom": "19.2.7", "react-easy-crop": "6.2.2", @@ -52,6 +50,7 @@ "@wxt-dev/auto-icons": "1.1.1", "@wxt-dev/module-react": "1.2.2", "daisyui": "5.5.23", + "react-scan": "^0.5.7", "tailwindcss": "4.3.1", "terser": "5.48.0", "typescript": "6.0.3", @@ -65,6 +64,8 @@ "@aklinker1/rollup-plugin-visualizer": ["@aklinker1/rollup-plugin-visualizer@5.12.0", "", { "dependencies": { "open": "^8.4.0", "picomatch": "^2.3.1", "source-map": "^0.7.4", "yargs": "^17.5.1" }, "peerDependencies": { "rollup": "2.x || 3.x || 4.x" }, "optionalPeers": ["rollup"], "bin": { "rollup-plugin-visualizer": "dist/bin/cli.js" } }, "sha512-X24LvEGw6UFmy0lpGJDmXsMyBD58XmX1bbwsaMLhNoM+UMQfQ3b2RtC+nz4b/NoRK5r6QJSKJHBNVeUdwqybaQ=="], + "@astrojs/compiler": ["@astrojs/compiler@4.0.0", "", {}, "sha512-eouss7G8ygdZqHuke033VMcVw5HTZUu+PXd/h06DGDUg/jt5btPYPqh66ENWw/mU78rBrf/oeC4oqoBwMtDMNA=="], + "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], @@ -123,6 +124,10 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.0", "", { "os": "win32", "cpu": "x64" }, "sha512-VT/lF+GId+67j8aDfLkxdxNoVApsPSTbyAtB3jJq0IWTrY77WXfbPfpngxq0bA6JCEv/7k8C9qWjDRKRznDlyw=="], + "@cacheable/memory": ["@cacheable/memory@2.2.0", "", { "dependencies": { "@cacheable/utils": "^2.5.0", "@keyv/bigmap": "^1.3.1", "hookified": "^1.15.1", "keyv": "^5.6.0" } }, "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ=="], + + "@cacheable/utils": ["@cacheable/utils@2.5.0", "", { "dependencies": { "hashery": "^1.5.1", "keyv": "^5.6.0" } }, "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA=="], + "@devicefarmer/adbkit": ["@devicefarmer/adbkit@3.3.8", "", { "dependencies": { "@devicefarmer/adbkit-logcat": "^2.1.2", "@devicefarmer/adbkit-monkey": "~1.2.1", "bluebird": "~3.7", "commander": "^9.1.0", "debug": "~4.3.1", "node-forge": "^1.3.1", "split": "~1.0.1" }, "bin": { "adbkit": "bin/adbkit" } }, "sha512-7rBLLzWQnBwutH2WZ0EWUkQdihqrnLYCUMaB44hSol9e0/cdIhuNFcqZO0xNheAU6qqHVA8sMiLofkYTgb+lmw=="], "@devicefarmer/adbkit-logcat": ["@devicefarmer/adbkit-logcat@2.1.3", "", {}, "sha512-yeaGFjNBc/6+svbDeul1tNHtNChw6h8pSHAt5D+JsedUrMTN7tla7B15WLDyekxsuS2XlZHRxpuC6m92wiwCNw=="], @@ -195,6 +200,20 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="], + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.10.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + + "@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="], + + "@eslint/config-helpers": ["@eslint/config-helpers@0.7.0", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw=="], + + "@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="], + + "@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="], + + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.3", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q=="], + "@fastify/deepmerge": ["@fastify/deepmerge@3.2.1", "", {}, "sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA=="], "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], @@ -211,6 +230,18 @@ "@gilbarbara/types": ["@gilbarbara/types@0.2.2", "", { "dependencies": { "type-fest": "^4.1.0" } }, "sha512-QuQDBRRcm1Q8AbSac2W1YElurOhprj3Iko/o+P1fJxUWS4rOGKMVli98OXS7uo4z+cKAif6a+L9bcZFSyauQpQ=="], + "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="], + + "@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="], + + "@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + + "@iarna/toml": ["@iarna/toml@2.2.5", "", {}, "sha512-trnsAYxU3xnS1gPHPyU961coFyLkh4gAD/0zQ5mymY4yOZ+CYvsPqUbOFSw0aDM4y0tV7tiFxL/1XfXPNC6IPg=="], + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], @@ -273,18 +304,156 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.29", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ=="], - "@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="], + "@keyv/bigmap": ["@keyv/bigmap@1.3.1", "", { "dependencies": { "hashery": "^1.4.0", "hookified": "^1.15.0" }, "peerDependencies": { "keyv": "^5.6.0" } }, "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ=="], + + "@keyv/serialize": ["@keyv/serialize@1.1.1", "", {}, "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA=="], "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], + + "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.220.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w=="], + + "@opentelemetry/core": ["@opentelemetry/core@2.11.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-7YP44XH0tV6+Mb54x2YGf84i7yi+31MBZlE8JwvozkxyTvXbSp10X7cI7YE49ChJ3shMJoBmCJF3+1QFBJctGA=="], + + "@opentelemetry/instrumentation": ["@opentelemetry/instrumentation@0.220.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.220.0", "import-in-the-middle": "^3.0.0", "require-in-the-middle": "^8.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw=="], + + "@opentelemetry/resources": ["@opentelemetry/resources@2.11.0", "", { "dependencies": { "@opentelemetry/core": "2.11.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-Ie7+8q8MDF4FAEQCKVMTx3ReUvxiIAgIiiW3c9JdmP8+HMcDy20puT+AHjexnExgnbvBxjQ9fjkFDWrikJ2jQA=="], + + "@opentelemetry/sdk-trace": ["@opentelemetry/sdk-trace@2.11.0", "", { "dependencies": { "@opentelemetry/core": "2.11.0", "@opentelemetry/resources": "2.11.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-fFnTqGm8/G73GQVnxYi7LXa1ZVYEUvgL6XI1LpvV0bPC7WQ/ZGgKxCSl8FnlZBKto9JHHEFTO6s6CUpvvtwFrA=="], + + "@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.11.0", "", { "dependencies": { "@opentelemetry/core": "2.11.0", "@opentelemetry/resources": "2.11.0", "@opentelemetry/sdk-trace": "2.11.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-H19x/TX/LZdqiYOjM7fqtSxwlplC5pgelavqbQdHbhdq0q/AI/TGkM2dfGuuynTXmJPeF2HoZVoPDu+TGoW78A=="], + + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], + + "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.146.0", "", { "os": "android", "cpu": "arm" }, "sha512-jBCZIsff/9J/zdK1KSKZREScXOtRE4wYO+MUjrE+5NHGdsslrP4biJA9QJyKwErwS8lkwxH6Z537HaL4CQr0ow=="], + + "@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.146.0", "", { "os": "android", "cpu": "arm64" }, "sha512-BVwj6sVmgD9j5F+R5B+13pYQKOCmew+3myS19wcG5e+AeZu5ybBLEfcaKueC8MhvjtwL2Suh2tQE/Ywop2Z8rA=="], + + "@oxc-parser/binding-darwin-arm64": ["@oxc-parser/binding-darwin-arm64@0.146.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-jQGIM4zEEvvuypbBcoeoWuigRfP6R8Ow/9gfDzVoYkv5mFyVPbIePNQnV752jUynfty0nDxarIpML+7Q/MyMPg=="], + + "@oxc-parser/binding-darwin-x64": ["@oxc-parser/binding-darwin-x64@0.146.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-quDy3cvW1712l6W+qe1/xWRym6STOuGJmFzRIg5StXVJglsbqrCpHfw9PyqPs0hQeOAjAEO1USrjPaIvHiRKQw=="], + + "@oxc-parser/binding-freebsd-x64": ["@oxc-parser/binding-freebsd-x64@0.146.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-wx/p/9phlX4VbYk671G4WewST3pysXNQfZRx4gx69QIz0/uW2O3MCnnEQh4il6l2lYr0EDAq3kFYovIw68CXHw=="], + + "@oxc-parser/binding-linux-arm-gnueabihf": ["@oxc-parser/binding-linux-arm-gnueabihf@0.146.0", "", { "os": "linux", "cpu": "arm" }, "sha512-W9nzfbWH8Z6cIaBj6Rh/eVgymFkZ2MaOV0rQruKTLYKLAZADo9JlOmBml6Dq2QbChtmuc3DKDL42KDHaQFX8yQ=="], + + "@oxc-parser/binding-linux-arm-musleabihf": ["@oxc-parser/binding-linux-arm-musleabihf@0.146.0", "", { "os": "linux", "cpu": "arm" }, "sha512-S5X1mEvTLBTl+xt7bRlr1n6qI+OTy1EaRkYyd4EQgEZqV7i53fQa1I72OqR0IwSQKVwh6TZPkzDcsN7fS9MynQ=="], + + "@oxc-parser/binding-linux-arm64-gnu": ["@oxc-parser/binding-linux-arm64-gnu@0.146.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Br9UB4nnecVfj/Vp+JiJnJo2wIJtwaOm+uN7MwWKF5cx10P816urucmksG/iG2OeL7hfZ4h5fCmePrLU6TocfQ=="], + + "@oxc-parser/binding-linux-arm64-musl": ["@oxc-parser/binding-linux-arm64-musl@0.146.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-5AP+od9VGhRyCwswTfuD0jSDfIcWptEBC+K0LWmXsUJ9BPVF3KbhsjeoV+9tijX6g5is0w4H8DUV7NkOJoOVVA=="], + + "@oxc-parser/binding-linux-ppc64-gnu": ["@oxc-parser/binding-linux-ppc64-gnu@0.146.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Qz3jc1qgvU9B9vQu3jAJcxKlGX7zYRtKs8LPYtQi8cmirhtYcX6XvXseESaDFbi1L+SorXhjRQ/2npEX85kKBw=="], + + "@oxc-parser/binding-linux-riscv64-gnu": ["@oxc-parser/binding-linux-riscv64-gnu@0.146.0", "", { "os": "linux", "cpu": "none" }, "sha512-Mfmg18lOZ1IWJJJuHi1ZsMLunqNVeLEh3Byuw+Aa8dYo7qOrZOMZQz1gFgAeNga5/Z5jjRuIKuIdo9aO7HYMdg=="], + + "@oxc-parser/binding-linux-riscv64-musl": ["@oxc-parser/binding-linux-riscv64-musl@0.146.0", "", { "os": "linux", "cpu": "none" }, "sha512-6OdXwYnzbwYcnUH8f0LYauLNitjIcgDR/CDA/wcZ2whwJwVflGtCLF3+3HOXgs/SESnpQCWXr7Q/z9muLJ5bmQ=="], + + "@oxc-parser/binding-linux-s390x-gnu": ["@oxc-parser/binding-linux-s390x-gnu@0.146.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-BilV4Ei84A0m6r1v+LmgVrfUMbAz33IXRooVw/BQNDExUvGGNLrk6NWeot+ve99bRidTZdwWuzBHgxWCnSSa9Q=="], + + "@oxc-parser/binding-linux-x64-gnu": ["@oxc-parser/binding-linux-x64-gnu@0.146.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RaMfGIpWOa87u3aD87drbPgisy7Nv6gIdjRFw6msIDKO+3FmcOTw+vpp4hVANhUIzpQta2EAsmYQRrqeprZTmQ=="], + + "@oxc-parser/binding-linux-x64-musl": ["@oxc-parser/binding-linux-x64-musl@0.146.0", "", { "os": "linux", "cpu": "x64" }, "sha512-0bzF60jfQRsJ9TJOIHK+wSUirenJAyWCdP9z3P6LMS0znD4Gf7WYOiv7niONVOpRnlGjzMPd4kqgOJfCDLQAQg=="], + + "@oxc-parser/binding-openharmony-arm64": ["@oxc-parser/binding-openharmony-arm64@0.146.0", "", { "os": "none", "cpu": "arm64" }, "sha512-O1gxUFt5FUgyFVUQK2BhZNwv7jLsqLR8uBANOhpYE5D5q+eCvcry7RvQT3DgKpWP1XRYB5UsbQGHHcwchEKb+w=="], + + "@oxc-parser/binding-win32-arm64-msvc": ["@oxc-parser/binding-win32-arm64-msvc@0.146.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-i46zYS0TTCt+oaXaDx4mKxuXo+h8OBWI12XFatRm5I9/l2WKhp4TQ6VAjw0r1NMRiQQZ9dwQlRyQNhyTkOaINg=="], + + "@oxc-parser/binding-win32-ia32-msvc": ["@oxc-parser/binding-win32-ia32-msvc@0.146.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-v1bb+ma2oKXwh4NHAg2NQwe9ORXFBDSCEkuO1qC4Rzm8EXcQdNhiJHpTNfulZ2P+1QpYUB6QYupW4mgMVom/tA=="], + + "@oxc-parser/binding-win32-x64-msvc": ["@oxc-parser/binding-win32-x64-msvc@0.146.0", "", { "os": "win32", "cpu": "x64" }, "sha512-uT8N1/qdNSGonvW1O2kZYNczqb/VYfnj5dpUy1FmedOPodj3MNFNWhAJkMZlI5u8xuTRtrpTccnr0U5HAMVRjQ=="], + "@oxc-project/types": ["@oxc-project/types@0.133.0", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="], + "@oxc-resolver/binding-android-arm-eabi": ["@oxc-resolver/binding-android-arm-eabi@11.24.2", "", { "os": "android", "cpu": "arm" }, "sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA=="], + + "@oxc-resolver/binding-android-arm64": ["@oxc-resolver/binding-android-arm64@11.24.2", "", { "os": "android", "cpu": "arm64" }, "sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg=="], + + "@oxc-resolver/binding-darwin-arm64": ["@oxc-resolver/binding-darwin-arm64@11.24.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w=="], + + "@oxc-resolver/binding-darwin-x64": ["@oxc-resolver/binding-darwin-x64@11.24.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q=="], + + "@oxc-resolver/binding-freebsd-x64": ["@oxc-resolver/binding-freebsd-x64@11.24.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw=="], + + "@oxc-resolver/binding-linux-arm-gnueabihf": ["@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2", "", { "os": "linux", "cpu": "arm" }, "sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg=="], + + "@oxc-resolver/binding-linux-arm-musleabihf": ["@oxc-resolver/binding-linux-arm-musleabihf@11.24.2", "", { "os": "linux", "cpu": "arm" }, "sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA=="], + + "@oxc-resolver/binding-linux-arm64-gnu": ["@oxc-resolver/binding-linux-arm64-gnu@11.24.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA=="], + + "@oxc-resolver/binding-linux-arm64-musl": ["@oxc-resolver/binding-linux-arm64-musl@11.24.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw=="], + + "@oxc-resolver/binding-linux-ppc64-gnu": ["@oxc-resolver/binding-linux-ppc64-gnu@11.24.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA=="], + + "@oxc-resolver/binding-linux-riscv64-gnu": ["@oxc-resolver/binding-linux-riscv64-gnu@11.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw=="], + + "@oxc-resolver/binding-linux-riscv64-musl": ["@oxc-resolver/binding-linux-riscv64-musl@11.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w=="], + + "@oxc-resolver/binding-linux-s390x-gnu": ["@oxc-resolver/binding-linux-s390x-gnu@11.24.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ=="], + + "@oxc-resolver/binding-linux-x64-gnu": ["@oxc-resolver/binding-linux-x64-gnu@11.24.2", "", { "os": "linux", "cpu": "x64" }, "sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg=="], + + "@oxc-resolver/binding-linux-x64-musl": ["@oxc-resolver/binding-linux-x64-musl@11.24.2", "", { "os": "linux", "cpu": "x64" }, "sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw=="], + + "@oxc-resolver/binding-openharmony-arm64": ["@oxc-resolver/binding-openharmony-arm64@11.24.2", "", { "os": "none", "cpu": "arm64" }, "sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg=="], + + "@oxc-resolver/binding-wasm32-wasi": ["@oxc-resolver/binding-wasm32-wasi@11.24.2", "", { "dependencies": { "@emnapi/core": "1.11.2", "@emnapi/runtime": "1.11.2", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q=="], + + "@oxc-resolver/binding-win32-arm64-msvc": ["@oxc-resolver/binding-win32-arm64-msvc@11.24.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg=="], + + "@oxc-resolver/binding-win32-x64-msvc": ["@oxc-resolver/binding-win32-x64-msvc@11.24.2", "", { "os": "win32", "cpu": "x64" }, "sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw=="], + + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.79.0", "", { "os": "android", "cpu": "arm" }, "sha512-TebFaaMklO/RXzTv7PucaCq9l3X6D1gA+C8H6K4njtjFOV+zWE9MKLpulcJZN9bzytbUbQIY0mZuz12nQ5Kv4Q=="], + + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.79.0", "", { "os": "android", "cpu": "arm64" }, "sha512-KqqnOtAVgNsPPF0YSodkFZA1O80jcKoCZCTu3bgsszxA+MrMP9TLzfXitKjEj1FmrPprKDMdRDMmY3weESO9sg=="], + + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.79.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BVC2nsMzqQzRDPc5RhixkZ+m1p7iH4bxRRvqkbwDXX0PlQKm1BPy8J8cRjnAFafOq2QzI+BfO3vE8w2GZ3CBag=="], + + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.79.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-p6Lm+snmhGuLKL1+CpCV8L6ijkE/qJzK2H2jG9+eKJT0n31RbY4FLsdhexekgP3bLpw4Kgde+9DZuDZQ4yIInA=="], + + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.79.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-qDMm0dXZnoHyRqSL4N4xUq82T4sqK5cbKSjvd/dF/YbMUXc2R1wEPf+vmA5S0qUmi0nwXfNbjXBtZaIqzQLIMg=="], + + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.79.0", "", { "os": "linux", "cpu": "arm" }, "sha512-2od7s0nuKPzqyUZAWk9KkCyGg7eI9dwFPZg+20lB15fKFkVZ0c9ZFxqPfiBAyDTlTkh9stPI0t+JlPCqMbItVA=="], + + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.79.0", "", { "os": "linux", "cpu": "arm" }, "sha512-ZOQUjkzDnvlhSE3+tWC3YXx94MMl+sYMlwH+u1+YGApGHOJP/YAc8ZBRFOXZ6eOBmxtXAWuS/fBcdZr8qqNO1A=="], + + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.79.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-lu158FR4nGqGeRS3BQvtG85wRgU/Fy4MD5Cxp1hzJXizGiLo6u2742wJSCDKh8cFcZntvX7fcxlq4mMmfryH1g=="], + + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.79.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-mbpKQeE2aflTjddaHK7MP8KP/OFbUM++lt5M635ENM8IyIdK0jm2t9pb+2v9mVVIvhF6TqA4l7F79Pll1mi+uw=="], + + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.79.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WpGNua7gaxaHnpSDeog2ji8IDHn/QLPl9LPzwkR/FvVv58vT5BcXjRXnU+wbu3N75cpeha8CdC7ho/U2OIsB4g=="], + + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.79.0", "", { "os": "linux", "cpu": "none" }, "sha512-tK1E93A5LVzISg4ngpKJnfTs7EqtIUceGI7MQ4GyDjJiLi8wPCkEyKlj2xkyKWZ1yzkDJyLHTBJ5/iFWRdnJvg=="], + + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.79.0", "", { "os": "linux", "cpu": "none" }, "sha512-qhQvUIrngXivA2A9pQ+xPCychztn/5qUv7yS3gDwXv3w7Rag+eTeeXWmRyx+t7XsW5x6LuY/8AsTq36UgFIblg=="], + + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.79.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-sv6AaVgU/eE6u+6WFiQVDcPPwTxP6IJMSB9k701W2r/r6Tx465e8vPvVyRxquNH4Vy6KwRNu90mVbxXJN8+5gg=="], + + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.79.0", "", { "os": "linux", "cpu": "x64" }, "sha512-iFZL02deziHslb3jEX9KdqlAkYoo4fGyotchKDzdfK1f5mxlIBeiQeHhvK3iFpuEJSB4ma/qeFn9oxPiwnhUPQ=="], + + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.79.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3DtZR2raqObnh7wXZoFYFd0Fw7skBvcb3f7A+/lkEiDuh8hrE6vv9b/62Qxao1a9/OeHLw/FcXlXzgsW9wTRFg=="], + + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.79.0", "", { "os": "none", "cpu": "arm64" }, "sha512-Oatt4GuA1WJkqzk2ozx4HrWROOi7opV3AKDw/U8qDIqeTqzsjn5K2x3REJMNjU3/KU/Bkq96Zi3CknaiDTaC/Q=="], + + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.79.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-NAgZr9Qp8nIA9rpo0JEvwiabTF/2UVqBNnupBG9X4kxXcQoScJUTi+qHhvabb9s/thgj5wQ4XcIaJvb+ZMgoKw=="], + + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.79.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-+KyXjIvcpaXmWW/j9NNY5yWjrIVxaX18VyIheQy3jwc2GSYgpCr7MGI/HxIGQ/shAL5IWEKbhsqoMpAO5Stiog=="], + + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.79.0", "", { "os": "win32", "cpu": "x64" }, "sha512-mEelcCMMBS57sIXh2veGMNy+pQwuGtcMxHxGIZWQ5Ba9pJ5jCCUFOZB9E2JhBaxGsURe+WGe0zJp4RVre52gpQ=="], + "@pnpm/config.env-replace": ["@pnpm/config.env-replace@1.1.0", "", {}, "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w=="], "@pnpm/network.ca-file": ["@pnpm/network.ca-file@1.0.2", "", { "dependencies": { "graceful-fs": "4.2.10" } }, "sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA=="], "@pnpm/npm-conf": ["@pnpm/npm-conf@2.3.1", "", { "dependencies": { "@pnpm/config.env-replace": "^1.1.0", "@pnpm/network.ca-file": "^1.0.1", "config-chain": "^1.1.11" } }, "sha512-c83qWb22rNRuB0UaVCI0uRPNRr8Z0FWnEIvT47jiHAmOIUHbBOg5XvV7pM5x+rKn9HRpjxquDbXYSXr3fAKFcw=="], + "@preact/signals": ["@preact/signals@2.11.2", "", { "dependencies": { "@preact/signals-core": "^1.14.4" }, "peerDependencies": { "preact": ">= 10.25.0 || >=11.0.0-0" } }, "sha512-rVTRTt/T0HIRgbugwS5FigbfF/kfdEFYFtiqxa+lbpqTajepqnR0firuAS2iNdHyejWB7Yc9p9QePkVNBtTAwg=="], + + "@preact/signals-core": ["@preact/signals-core@1.14.4", "", {}, "sha512-HNB6HYeYKhQbJ1aKl+YRjrS4+QWHLKX6qKoUsfS/m0vqzsVaEBiZiaKbG/e+NKk2ch5ALQr/ihWaMHxiCuuWHA=="], + + "@react-grab/cli": ["@react-grab/cli@0.2.0", "", { "dependencies": { "agent-install": "^0.0.6", "commander": "^14.0.3", "ignore": "^7.0.5", "ora": "^9.4.0", "package-manager-detector": "^1.6.0", "picocolors": "^1.1.1", "prompts": "^2.4.2", "tinyexec": "^1.1.2" }, "bin": { "react-grab": "bin/cli.js" } }, "sha512-LVfA+j5cFT0Szd958WRD8W/B8ucCtz4ViiveDKWLqAwBkZFW9VRW1ZvwOUhp/M3SmVPtjDaRkW12GvAuTArP+Q=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.3", "", { "os": "android", "cpu": "arm64" }, "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw=="], "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA=="], @@ -317,6 +486,8 @@ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], + "@rollup/pluginutils": ["@rollup/pluginutils@5.4.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg=="], + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.45.1", "", { "os": "android", "cpu": "arm" }, "sha512-NEySIFvMY0ZQO+utJkgoMiCAjMrGvnbDLHvcmlA33UXJpYBCvlBEbMMtV837uCkS+plG2umfhn0T5mMAxGrlRA=="], "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.45.1", "", { "os": "android", "cpu": "arm64" }, "sha512-ujQ+sMXJkg4LRJaYreaVx7Z/VMgBBd89wGS4qMrdtfUFZ+TSY5Rs9asgjitLwzeIbhwdEhyj29zhst3L1lKsRQ=="], @@ -357,6 +528,20 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.45.1", "", { "os": "win32", "cpu": "x64" }, "sha512-M/fKi4sasCdM8i0aWJjCSFm2qEnYRR8AMLG2kxp6wD13+tMGA4Z1tVAuHkNRjud5SW2EM3naLuK35w9twvf6aA=="], + "@sentry/conventions": ["@sentry/conventions@0.16.0", "", {}, "sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ=="], + + "@sentry/core": ["@sentry/core@10.73.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0" } }, "sha512-FLO1UgH19RyasVpofu612WCOgb2nEH0dZy+R72d7p65XU9i0wxlMKm3+sgfwKmiSJp1Qhilaaxs4Jg6BbiM5HA=="], + + "@sentry/node": ["@sentry/node@10.73.0", "", { "dependencies": { "@opentelemetry/api": "^1.9.1", "@opentelemetry/instrumentation": "^0.220.0", "@opentelemetry/sdk-trace-base": "^2.9.0", "@sentry/conventions": "^0.16.0", "@sentry/core": "10.73.0", "@sentry/node-core": "10.73.0", "@sentry/opentelemetry": "10.73.0", "@sentry/server-utils": "10.73.0", "import-in-the-middle": "^3.0.0" } }, "sha512-jiMJ6GgXDw6UMGzJY+o0c8OoeA9OfHqZ/xEpHfDqy75hn+9CEkRkbNGCIdMvsc7wW/Se1Os394hHfTpU+YEskg=="], + + "@sentry/node-core": ["@sentry/node-core@10.73.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0", "@sentry/core": "10.73.0", "@sentry/opentelemetry": "10.73.0", "import-in-the-middle": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1", "@opentelemetry/instrumentation": ">=0.57.1 <1", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/core", "@opentelemetry/exporter-trace-otlp-http", "@opentelemetry/instrumentation", "@opentelemetry/sdk-trace-base"] }, "sha512-GHAGUmZPmm6FKfxfv2maVVJ/A99YAmd7oFOuKMDmITI6O/Msl6JB3vPTEpopVAHLW0LYJQBOjddL9C7o+Jt44g=="], + + "@sentry/opentelemetry": ["@sentry/opentelemetry@10.73.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0", "@sentry/core": "10.73.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/core": "^1.30.1 || ^2.1.0", "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" } }, "sha512-fQouPQKsH0CQrw6oAn1k0Z2I+tgyochCovifr5qNS69i0OzjknLa03WJyiZ/IuzXc4AVa5jAKfOeE9slABz8Qw=="], + + "@sentry/server-utils": ["@sentry/server-utils@10.73.0", "", { "dependencies": { "@sentry/conventions": "^0.16.0", "@sentry/core": "10.73.0" } }, "sha512-QskripdKFbM/+gipC6mpa2crLwL7+VbkX84IpHg2z9UlYQ1kNKd3aMT+Qk9NLRSw/zu1rSIAvlbWfx4D3rgNAA=="], + + "@shaderfrog/glsl-parser": ["@shaderfrog/glsl-parser@7.0.1", "", {}, "sha512-8mpfsoPeRhesY3pOrzNZBL8uG6N5GVX1EHLBYbd4gzKs+c7vaEIqpTNK5VrffU33qQN4cwpP2v3u4aPPBU32sw=="], + "@tailwindcss/node": ["@tailwindcss/node@4.3.1", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "5.21.6", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.1" } }, "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A=="], "@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.1", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.1", "@tailwindcss/oxide-darwin-arm64": "4.3.1", "@tailwindcss/oxide-darwin-x64": "4.3.1", "@tailwindcss/oxide-freebsd-x64": "4.3.1", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", "@tailwindcss/oxide-linux-x64-musl": "4.3.1", "@tailwindcss/oxide-wasm32-wasi": "4.3.1", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" } }, "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA=="], @@ -401,6 +586,8 @@ "@types/canvas-confetti": ["@types/canvas-confetti@1.9.0", "", {}, "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg=="], + "@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/filesystem": ["@types/filesystem@0.0.36", "", { "dependencies": { "@types/filewriter": "*" } }, "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA=="], @@ -409,6 +596,8 @@ "@types/har-format": ["@types/har-format@1.2.16", "", {}, "sha512-fluxdy7ryD3MV6h8pTfTYpy/xQzCFC7m89nOH9y94cNqJ1mDIDPut7MnRHI3F6qRmh/cT2fUjG1MLdCNb4hE9A=="], + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + "@types/minimatch": ["@types/minimatch@3.0.5", "", {}, "sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ=="], "@types/moment-hijri": ["@types/moment-hijri@2.1.4", "", { "dependencies": { "moment": ">=2.14.0" } }, "sha512-pGX1DaSducJDkJAC3q8fCuemow0pzI4oa0iKcspwQNPXuwlI55WRgBVrA6NVi+rf8bZN1qjWVsGdUatrLhZk6Q=="], @@ -423,6 +612,8 @@ "@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="], + "@typescript-eslint/types": ["@typescript-eslint/types@8.69.0", "", {}, "sha512-K3VrubUPhlo9VDBS6QdI8YB5j7ClpqLRdefcz6PFrhnwicehBweqQ9Evhl4l+FYz0HdDmMqIiSX0aldGRYtDCA=="], + "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.2", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.0" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg=="], "@webext-core/fake-browser": ["@webext-core/fake-browser@1.3.4", "", { "dependencies": { "lodash.merge": "^4.6.2" } }, "sha512-nZcVWr3JpwpS5E6hKpbAwAMBM/AXZShnfW0F76udW8oLd6Kv0nbW6vFS07md4Na/0ntQonk3hFnlQYGYBAlTrA=="], @@ -443,10 +634,18 @@ "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + "adm-zip": ["adm-zip@0.5.16", "", {}, "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ=="], "agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + "agent-install": ["agent-install@0.0.5", "", { "dependencies": { "@iarna/toml": "^2.2.5", "commander": "^14.0.0", "jsonc-parser": "^3.3.1", "picocolors": "^1.1.1", "prompts": "^2.4.2", "yaml": "^2.8.3" }, "bin": { "agent-install": "bin/agent-install.mjs" } }, "sha512-nHlms9BkP8ZiY79HrwCGiA2DcNaXrAaJrCM/BEqQ7MEsSKyCk+2A76xPGylIfASZSZE0SaU3T0bNSg4rBPIJAQ=="], + + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + "ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="], "ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="], @@ -479,6 +678,8 @@ "baseline-browser-mapping": ["baseline-browser-mapping@2.9.11", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ=="], + "bippy": ["bippy@0.5.43", "", { "peerDependencies": { "react": ">=17.0.1" } }, "sha512-Tvu7b1M7+d8b9/YHaCeODEsi2CgbuoBql+dWSBrNnCuqJ1gMUeY3i0r+319hvjjl5GVBP6FFWxrKnq3fhZER0w=="], + "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], "bluebird": ["bluebird@3.7.2", "", {}, "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="], @@ -503,6 +704,8 @@ "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], + "cacheable": ["cacheable@2.5.0", "", { "dependencies": { "@cacheable/memory": "^2.2.0", "@cacheable/utils": "^2.5.0", "hookified": "^1.15.0", "keyv": "^5.6.0", "qified": "^0.10.1" } }, "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g=="], + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], "camelcase": ["camelcase@8.0.0", "", {}, "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA=="], @@ -515,8 +718,6 @@ "chardet": ["chardet@0.7.0", "", {}, "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA=="], - "chart.js": ["chart.js@4.5.1", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw=="], - "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], "chrome-launcher": ["chrome-launcher@1.2.0", "", { "dependencies": { "@types/node": "*", "escape-string-regexp": "^4.0.0", "is-wsl": "^2.2.0", "lighthouse-logger": "^2.0.1" }, "bin": { "print-chrome-path": "bin/print-chrome-path.cjs" } }, "sha512-JbuGuBNss258bvGil7FT4HKdC3SC2K7UAEUqiPy3ACS3Yxo3hAW6bvFpCu2HsIJLgTqxgEX6BkujvzZfLpUD0Q=="], @@ -525,6 +726,8 @@ "citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="], + "cjs-module-lexer": ["cjs-module-lexer@2.2.1", "", {}, "sha512-Ca8swihM+/4yKecYHY52kgJd300hi2lADU/a1RxNTRe+RJ9jvqQlESpbz9DnG9mowez8qwXHB8qYdIUw9e+F5Q=="], + "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], "cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="], @@ -553,13 +756,15 @@ "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], - "commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], "concat-stream": ["concat-stream@1.6.2", "", { "dependencies": { "buffer-from": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^2.2.2", "typedarray": "^0.0.6" } }, "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw=="], - "confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="], + "conf": ["conf@15.1.0", "", { "dependencies": { "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "atomically": "^2.0.3", "debounce-fn": "^6.0.0", "dot-prop": "^10.0.0", "env-paths": "^3.0.0", "json-schema-typed": "^8.0.1", "semver": "^7.7.2", "uint8array-extras": "^1.5.0" } }, "sha512-Uy5YN9KEu0WWDaZAVJ5FAmZoaJt9rdK6kH+utItPyGsCqCgaTKkrmZx3zoE0/3q6S3bcp3Ihkk+ZqPxWxFK5og=="], + + "confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="], "config-chain": ["config-chain@1.1.13", "", { "dependencies": { "ini": "^1.3.4", "proto-list": "~1.2.1" } }, "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ=="], @@ -571,6 +776,8 @@ "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], @@ -583,10 +790,14 @@ "debounce": ["debounce@1.2.1", "", {}, "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug=="], + "debounce-fn": ["debounce-fn@6.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-rBMW+F2TXryBwB54Q0d8drNEI+TfoS9JpNTAoVpukbWEhjXQq4rySFYLaqXMFXwdv61Zb2OHtj5bviSoimqxRQ=="], + "debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + "default-browser": ["default-browser@5.5.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw=="], "default-browser-id": ["default-browser-id@5.0.0", "", {}, "sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA=="], @@ -613,7 +824,7 @@ "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], - "dot-prop": ["dot-prop@9.0.0", "", { "dependencies": { "type-fest": "^4.18.2" } }, "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ=="], + "dot-prop": ["dot-prop@10.2.0", "", { "dependencies": { "type-fest": "^5.0.0" } }, "sha512-BTJ9aZYL3vCfZlZOBLy9v8TUqWGQ0pzFnygKwFZt5udj6viBoFIBviKPUoZLDCPn1FoXffv6McQFDenrm5Krfw=="], "dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="], @@ -631,6 +842,8 @@ "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="], + "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], "error-ex": ["error-ex@1.3.2", "", { "dependencies": { "is-arrayish": "^0.2.1" } }, "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g=="], @@ -655,7 +868,25 @@ "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], - "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "eslint": ["eslint@10.10.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.7.0", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.3", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "11.1.5 || >11.1.6 <12", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.5", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-NPXn6r5zl4uET1DAVPaOwzX3rut4c0wcmw3dWJAfOsTM5+TogXo0DDjz8pwm/hL8cyVNpHqeK4JpN0NjnyFFNw=="], + + "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.1.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g=="], + + "eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], + + "espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="], + + "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], @@ -665,18 +896,34 @@ "extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + "fast-redact": ["fast-redact@3.5.0", "", {}, "sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A=="], + "fast-uri": ["fast-uri@3.1.7", "", {}, "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg=="], + "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "figures": ["figures@3.2.0", "", { "dependencies": { "escape-string-regexp": "^1.0.5" } }, "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg=="], + "file-entry-cache": ["file-entry-cache@11.1.5", "", { "dependencies": { "flat-cache": "^6.1.23" } }, "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q=="], + "filesize": ["filesize@11.0.17", "", {}, "sha512-oHLTvMLw6imZUl1se/RBQrFlyy50nXce4sU7yGR6Qc0JgCwqnfiFsAnEwotdGmfKLD7SArGUk2/5STU0k8LOBQ=="], + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + "firefox-profile": ["firefox-profile@4.7.0", "", { "dependencies": { "adm-zip": "~0.5.x", "fs-extra": "^11.2.0", "ini": "^4.1.3", "minimist": "^1.2.8", "xml2js": "^0.6.2" }, "bin": { "firefox-profile": "lib/cli.js" } }, "sha512-aGApEu5bfCNbA4PGUZiRJAIU6jKmghV2UVdklXAofnNtiDjqYw0czLS46W7IfFqVKgKhFB8Ao2YoNGHY4BoIMQ=="], + "flat-cache": ["flat-cache@6.1.23", "", { "dependencies": { "cacheable": "^2.5.0", "flatted": "^3.4.2", "hookified": "^1.15.0" } }, "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA=="], + + "flatted": ["flatted@3.4.4", "", {}, "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q=="], + "follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="], "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], @@ -697,7 +944,7 @@ "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], - "get-east-asian-width": ["get-east-asian-width@1.3.0", "", {}, "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ=="], + "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], @@ -709,6 +956,8 @@ "giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="], + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + "glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="], "global-directory": ["global-directory@4.0.1", "", { "dependencies": { "ini": "4.1.1" } }, "sha512-wHTUcDUoZ1H5/0iVqEudYW4/kAlN5cZ3j/bXn0Dpbizl9iaUVeWSHqiOjsgk6OW2bkLclbBjzewBz6weQ1zA2Q=="], @@ -729,12 +978,20 @@ "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + "hashery": ["hashery@1.5.1", "", { "dependencies": { "hookified": "^1.15.0" } }, "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ=="], + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + "hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], + + "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], + "highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="], "hookable": ["hookable@6.1.1", "", {}, "sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ=="], + "hookified": ["hookified@1.15.1", "", {}, "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg=="], + "html-escaper": ["html-escaper@3.0.3", "", {}, "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ=="], "htmlparser2": ["htmlparser2@10.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.2.1", "entities": "^6.0.0" } }, "sha512-TwAZM+zE5Tq3lrEHvOlvwgj1XLWQCtaaibSN11Q+gGBAS7Y1uZSWwXXRe4iF6OXnaq1riyQAPFOBtYc77Mxq0g=="], @@ -747,10 +1004,16 @@ "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + "ignore": ["ignore@7.0.8", "", {}, "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q=="], + "immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="], + "import-in-the-middle": ["import-in-the-middle@3.4.0", "", { "dependencies": { "cjs-module-lexer": "^2.2.0", "es-module-lexer": "^2.2.0", "module-details-from-path": "^1.0.4" } }, "sha512-Xfjwfarhe+LGmoaof+sexeNo3sGRysb5x56WrZLwHtmqT0OilSKtVWkv0lrCcMgSKyKP9oBogBAisHZvtJH0aw=="], + "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="], + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "ini": ["ini@4.1.3", "", {}, "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg=="], @@ -763,8 +1026,12 @@ "is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + "is-in-ci": ["is-in-ci@1.0.0", "", { "bin": { "is-in-ci": "cli.js" } }, "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg=="], "is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="], @@ -789,7 +1056,7 @@ "is-relative": ["is-relative@0.1.3", "", {}, "sha512-wBOr+rNM4gkAZqoLRJI4myw5WzzIdQosFAAbnvfXP5z1LyzgAI3ivOKehC5KfqlQJZoihVhirgtCBj378Eg8GA=="], - "is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], + "is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="], "is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], @@ -803,24 +1070,36 @@ "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], - "js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "json-parse-even-better-errors": ["json-parse-even-better-errors@3.0.2", "", {}, "sha512-fi0NG4bPjCHunUJffmLd0gxssIgkNmArMvis4iNah6Owg1MCJjWhEcDLmsK6iGkJq3tHwbDkTlce70/tmXN4cQ=="], + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + "jsonfile": ["jsonfile@6.1.0", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ=="], "jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="], + "keyv": ["keyv@5.6.0", "", { "dependencies": { "@keyv/serialize": "^1.1.1" } }, "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw=="], + "kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], "ky": ["ky@1.8.2", "", {}, "sha512-XybQJ3d4Ea1kI27DoelE5ZCT3bSJlibYTtQuMsyzKox3TMyayw1asgQdl54WroAm+fIA3ZCr8zXW2RpR7qWVpA=="], "latest-version": ["latest-version@9.0.0", "", { "dependencies": { "package-json": "^10.0.0" } }, "sha512-7W0vV3rqv5tokqkBAFV1LbR7HPOWzXQDpDgEuib/aJ1jsZZx6x3c2mBI+TJhJzOhkGeaLbCKEHXEXLfirtG2JA=="], + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + "lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="], "lighthouse-logger": ["lighthouse-logger@2.0.1", "", { "dependencies": { "debug": "^2.6.9", "marky": "^1.2.2" } }, "sha512-ioBrW3s2i97noEmnXxmUq7cjIcVRjT5HBpAYy8zE11CxU9HqlWHHeRxfeN1tn8F7OEMVPIC9x1f8t3Z7US9ehQ=="], @@ -857,6 +1136,8 @@ "local-pkg": ["local-pkg@1.1.1", "", { "dependencies": { "mlly": "^1.7.4", "pkg-types": "^2.0.1", "quansync": "^0.2.8" } }, "sha512-WunYko2W1NcdfAFpuLUoucsgULmgDBRkdxHxWQ7mK0cQqwPiy8E1enjuRBrhLtZkB5iScJ1XIPdhVEFK8aOLSg=="], + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], "lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="], @@ -899,6 +1180,8 @@ "mlly": ["mlly@1.7.4", "", { "dependencies": { "acorn": "^8.14.0", "pathe": "^2.0.1", "pkg-types": "^1.3.0", "ufo": "^1.5.4" } }, "sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw=="], + "module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="], + "moment": ["moment@2.30.1", "", {}, "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how=="], "moment-hijri": ["moment-hijri@3.0.0", "", { "dependencies": { "moment": "^2.30.1" } }, "sha512-UcBcbHDA8ToVcKjY+vBEa/hI3GZYraueavWUSLY2TdrHDHhx+wUpRw96rssAqsbT4xo/TMimwQVBP4KhJ3EN5A=="], @@ -925,6 +1208,8 @@ "nanospinner": ["nanospinner@1.2.2", "", { "dependencies": { "picocolors": "^1.1.1" } }, "sha512-Zt/AmG6qRU3e+WnzGGLuMCEAO/dAu45stNbHY223tUxldaDAeE+FxSPsd9Q+j+paejmm0ZbrNVs5Sraqy3dRxA=="], + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + "node-fetch-native": ["node-fetch-native@1.6.6", "", {}, "sha512-8Mc2HhqPdlIfedsuZoc3yioPuzp6b+L5jRCRY1QzuWZh2EGJVQrGppC6V6cF0bLdbW0+O2YpqCA25aF/1lvipQ=="], "node-forge": ["node-forge@1.3.1", "", {}, "sha512-dPEtOeMvF9VMcYV/1Wb8CPoVAXtp6MKMlcbAt4ddqmGqUJ6fQZFXkNZNkNlfevtNkGtaSoXf/vNNNSvgrdXwtA=="], @@ -955,14 +1240,30 @@ "open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + "ora": ["ora@6.3.1", "", { "dependencies": { "chalk": "^5.0.0", "cli-cursor": "^4.0.0", "cli-spinners": "^2.6.1", "is-interactive": "^2.0.0", "is-unicode-supported": "^1.1.0", "log-symbols": "^5.1.0", "stdin-discarder": "^0.1.0", "strip-ansi": "^7.0.1", "wcwidth": "^1.0.1" } }, "sha512-ERAyNnZOfqM+Ao3RAvIXkYh5joP220yf59gVe2X/cI6SiCxIdi4c9HZKZD8R6q/RDXEje1THBju6iExiSsgJaQ=="], "os-shim": ["os-shim@0.1.3", "", {}, "sha512-jd0cvB8qQ5uVt0lvCIexBaROw1KyKm5sbulg2fWOHjETisuCzWyt+eTZKEMs8v6HwzoGs8xik26jg7eCM6pS+A=="], "os-tmpdir": ["os-tmpdir@1.0.2", "", {}, "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g=="], + "oxc-parser": ["oxc-parser@0.146.0", "", { "dependencies": { "@oxc-project/types": "^0.146.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.146.0", "@oxc-parser/binding-android-arm64": "0.146.0", "@oxc-parser/binding-darwin-arm64": "0.146.0", "@oxc-parser/binding-darwin-x64": "0.146.0", "@oxc-parser/binding-freebsd-x64": "0.146.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.146.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.146.0", "@oxc-parser/binding-linux-arm64-gnu": "0.146.0", "@oxc-parser/binding-linux-arm64-musl": "0.146.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.146.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.146.0", "@oxc-parser/binding-linux-riscv64-musl": "0.146.0", "@oxc-parser/binding-linux-s390x-gnu": "0.146.0", "@oxc-parser/binding-linux-x64-gnu": "0.146.0", "@oxc-parser/binding-linux-x64-musl": "0.146.0", "@oxc-parser/binding-openharmony-arm64": "0.146.0", "@oxc-parser/binding-win32-arm64-msvc": "0.146.0", "@oxc-parser/binding-win32-ia32-msvc": "0.146.0", "@oxc-parser/binding-win32-x64-msvc": "0.146.0" } }, "sha512-hrt0Ou2mCL8dLuvfvKWFjRlWaJKt0Xs7CfacAgb9mha0gKW1lSisUzNRyALgItMPIfThxkl8jH2ONP6ZbQXpaA=="], + + "oxc-resolver": ["oxc-resolver@11.24.2", "", { "optionalDependencies": { "@oxc-resolver/binding-android-arm-eabi": "11.24.2", "@oxc-resolver/binding-android-arm64": "11.24.2", "@oxc-resolver/binding-darwin-arm64": "11.24.2", "@oxc-resolver/binding-darwin-x64": "11.24.2", "@oxc-resolver/binding-freebsd-x64": "11.24.2", "@oxc-resolver/binding-linux-arm-gnueabihf": "11.24.2", "@oxc-resolver/binding-linux-arm-musleabihf": "11.24.2", "@oxc-resolver/binding-linux-arm64-gnu": "11.24.2", "@oxc-resolver/binding-linux-arm64-musl": "11.24.2", "@oxc-resolver/binding-linux-ppc64-gnu": "11.24.2", "@oxc-resolver/binding-linux-riscv64-gnu": "11.24.2", "@oxc-resolver/binding-linux-riscv64-musl": "11.24.2", "@oxc-resolver/binding-linux-s390x-gnu": "11.24.2", "@oxc-resolver/binding-linux-x64-gnu": "11.24.2", "@oxc-resolver/binding-linux-x64-musl": "11.24.2", "@oxc-resolver/binding-openharmony-arm64": "11.24.2", "@oxc-resolver/binding-wasm32-wasi": "11.24.2", "@oxc-resolver/binding-win32-arm64-msvc": "11.24.2", "@oxc-resolver/binding-win32-x64-msvc": "11.24.2" } }, "sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw=="], + + "oxlint": ["oxlint@1.79.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.79.0", "@oxlint/binding-android-arm64": "1.79.0", "@oxlint/binding-darwin-arm64": "1.79.0", "@oxlint/binding-darwin-x64": "1.79.0", "@oxlint/binding-freebsd-x64": "1.79.0", "@oxlint/binding-linux-arm-gnueabihf": "1.79.0", "@oxlint/binding-linux-arm-musleabihf": "1.79.0", "@oxlint/binding-linux-arm64-gnu": "1.79.0", "@oxlint/binding-linux-arm64-musl": "1.79.0", "@oxlint/binding-linux-ppc64-gnu": "1.79.0", "@oxlint/binding-linux-riscv64-gnu": "1.79.0", "@oxlint/binding-linux-riscv64-musl": "1.79.0", "@oxlint/binding-linux-s390x-gnu": "1.79.0", "@oxlint/binding-linux-x64-gnu": "1.79.0", "@oxlint/binding-linux-x64-musl": "1.79.0", "@oxlint/binding-openharmony-arm64": "1.79.0", "@oxlint/binding-win32-arm64-msvc": "1.79.0", "@oxlint/binding-win32-ia32-msvc": "1.79.0", "@oxlint/binding-win32-x64-msvc": "1.79.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-hVJ9hq9m2unPS+Of4eJJgCPdIeCC+3DHEUX3tkmrPJr3OK2hz7PhXwgC+ZP71ZcYu8cCDEtQrqLxWNvxBppBVg=="], + + "oxlint-plugin-react-doctor": ["oxlint-plugin-react-doctor@0.9.13", "", { "dependencies": { "@shaderfrog/glsl-parser": "^7.0.1", "@typescript-eslint/types": "^8.59.3", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "lightningcss": "^1.33.0", "oxc-parser": "^0.146.0" } }, "sha512-JuGpGSZ2mmzRaZlhSDdDu3+TZZ+d1q9HPyptU3XW3yL7YMDsJqq9QCPZqTwphPohOByvk+sypBI1+kKgovsPcg=="], + + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + "package-json": ["package-json@10.0.1", "", { "dependencies": { "ky": "^1.2.0", "registry-auth-token": "^5.0.2", "registry-url": "^6.0.1", "semver": "^7.6.0" } }, "sha512-ua1L4OgXSBdsu1FPb7F3tYH0F48a6kxvod4pLUlGY9COeJAJQNX/sNH2IiEmsxw7lqYiAwrdHMjz1FctOsyDQg=="], + "package-manager-detector": ["package-manager-detector@1.8.0", "", {}, "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A=="], + "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], "parse-json": ["parse-json@7.1.1", "", { "dependencies": { "@babel/code-frame": "^7.21.4", "error-ex": "^1.3.2", "json-parse-even-better-errors": "^3.0.0", "lines-and-columns": "^2.0.3", "type-fest": "^3.8.0" } }, "sha512-SgOTCX/EZXtZxBE5eJ97P4yGM5n37BwRU+YMsH4vNzFqJV/oWFXXCmwFlgWUM4PrakybVOueJJ6pwHqSVhTFDw=="], @@ -971,6 +1272,10 @@ "parse5-htmlparser2-tree-adapter": ["parse5-htmlparser2-tree-adapter@6.0.1", "", { "dependencies": { "parse5": "^6.0.1" } }, "sha512-qPuWvbLgvDGilKc5BoicRovlT4MtYT6JfJyBOMDsKoiT+GiuP5qyrPCnR9HcPECIJJmZh5jRndyNThnhhb/vlA=="], + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], @@ -989,10 +1294,16 @@ "pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="], + "playwright-core": ["playwright-core@1.63.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg=="], + "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], "powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="], + "preact": ["preact@10.29.8", "", { "peerDependencies": { "preact-render-to-string": ">=5" }, "optionalPeers": ["preact-render-to-string"] }, "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q=="], + + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], "process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="], @@ -1009,8 +1320,12 @@ "pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "pupa": ["pupa@3.1.0", "", { "dependencies": { "escape-goat": "^4.0.0" } }, "sha512-FLpr4flz5xZTSJxSeaheeMKN/EDzMdK7b8PTOC6a5PYFKTucWbdqjgqaEyH0shFiSJrVB1+Qqi4Tk19ccU6Aug=="], + "qified": ["qified@0.10.1", "", { "dependencies": { "hookified": "^2.1.1" } }, "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA=="], + "quansync": ["quansync@0.2.10", "", {}, "sha512-t41VRkMYbkHyCYmOvx/6URnN80H7k4X0lLdBMGsz+maAwrJQYB1djpV6vHrQIBE0WBSGqhtEHrK9U3DWWH8v7A=="], "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], @@ -1021,14 +1336,16 @@ "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], - "react-chartjs-2": ["react-chartjs-2@5.3.1", "", { "peerDependencies": { "chart.js": "^4.1.1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A=="], - "react-colorful": ["react-colorful@5.7.0", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-fuesYIemttah97XmsIHmz4OORDHiSFzyc9HMAIrCHJou2jaRQmL8cFJ76K4zQhhj8jzwOBlOi4BaGTjjOZCfTg=="], + "react-doctor": ["react-doctor@0.9.13", "", { "dependencies": { "@astrojs/compiler": "^4.0.0", "@sentry/node": "^10.54.0", "agent-install": "0.0.5", "bippy": "^0.6.1", "conf": "^15.1.0", "confbox": "^0.2.4", "eslint-plugin-react-hooks": "^7.1.1", "is-unicode-supported": "^2.1.0", "jiti": "^2.7.0", "magicast": "^0.5.3", "oxc-parser": "^0.146.0", "oxc-resolver": "^11.24.2", "oxlint": ">=1.79.0 <1.80.0", "oxlint-plugin-react-doctor": "0.9.13", "playwright-core": "^1.62.1", "prompts": "^2.4.2", "typescript": ">=5.0.4 <6", "yaml": "^2.9.0", "yoga-layout": "~3.2.1" }, "bin": { "react-doctor": "bin/react-doctor.js" } }, "sha512-OGzhgLmH+eFlYa3gOv8Y/yOaR6yswRxiLJ37wWc9/fm57bP8/3n5/TZQLMctv/8izKvqtmblIpW+0SqMYKP7Xg=="], + "react-dom": ["react-dom@19.2.7", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.7" } }, "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ=="], "react-easy-crop": ["react-easy-crop@6.2.2", "", { "dependencies": { "normalize-wheel": "^1.0.1" }, "peerDependencies": { "react": ">=16.4.0", "react-dom": ">=16.4.0" } }, "sha512-b0HOicSvLYoNk1yvZTwjH8sNjF5uD9xLtWrSDnv+fx1dXTbwd8bNLQaP6gjXw//A+tni9Mw5KDmPNOEjKF55NQ=="], + "react-grab": ["react-grab@0.2.0", "", { "dependencies": { "@react-grab/cli": "0.2.0", "bippy": "^0.6.1" }, "peerDependencies": { "react": ">=17.0.0" }, "optionalPeers": ["react"], "bin": { "react-grab": "bin/cli.js" } }, "sha512-ohhsfXD4qN0j6cMQd56aaVJBPDF3kUdviF/Od1Eak/rEIXQ3svQ4gjkwTfuTZwTBnxHRL16p9JqdlVQO5nUHqA=="], + "react-hot-toast": ["react-hot-toast@2.6.0", "", { "dependencies": { "csstype": "^3.1.3", "goober": "^2.1.16" }, "peerDependencies": { "react": ">=16", "react-dom": ">=16" } }, "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg=="], "react-icons": ["react-icons@5.6.0", "", { "peerDependencies": { "react": "*" } }, "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA=="], @@ -1039,6 +1356,8 @@ "react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="], + "react-scan": ["react-scan@0.5.7", "", { "dependencies": { "@babel/core": "^7.29.0", "@babel/types": "^7.29.0", "@preact/signals": "^2.9.0", "@rollup/pluginutils": "^5.3.0", "bippy": "^0.5.39", "commander": "^14.0.0", "picocolors": "^1.1.1", "preact": "^10.29.1", "prompts": "^2.4.2", "react-doctor": "latest", "react-grab": "latest" }, "optionalDependencies": { "unplugin": "^3.0.0" }, "peerDependencies": { "esbuild": ">=0.18.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["esbuild"], "bin": { "react-scan": "bin/cli.js" } }, "sha512-KRlq734yN6q/f2CZmZi9CWHuiqSzoLhPFLtcJOL6XM4lR54myyFcY81pG9QOwj+eBC1hIHm5n+Ntbtqiilu8Rg=="], + "readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], "readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], @@ -1051,6 +1370,10 @@ "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "require-in-the-middle": ["require-in-the-middle@8.0.1", "", { "dependencies": { "debug": "^4.3.5", "module-details-from-path": "^1.0.3" } }, "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ=="], + "restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="], "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], @@ -1089,6 +1412,10 @@ "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "shell-quote": ["shell-quote@1.7.3", "", {}, "sha512-Vpfqwm4EnqGdlsBFNmHhxhElJYrdfcxPThu+ryKS5J8L/fhAwLazFZtq+S+TWZ9ANj2piSQLGj6NQg+lKPmxrw=="], "shellwords": ["shellwords@0.1.1", "", {}, "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww=="], @@ -1131,6 +1458,8 @@ "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], + "tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], "tailwindcss": ["tailwindcss@4.3.1", "", {}, "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q=="], @@ -1155,6 +1484,8 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], "typedarray": ["typedarray@0.0.6", "", {}, "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="], @@ -1165,13 +1496,15 @@ "uhyphen": ["uhyphen@0.2.0", "", {}, "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA=="], + "uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="], + "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], "unimport": ["unimport@5.1.0", "", { "dependencies": { "acorn": "^8.15.0", "escape-string-regexp": "^5.0.0", "estree-walker": "^3.0.3", "local-pkg": "^1.1.1", "magic-string": "^0.30.17", "mlly": "^1.7.4", "pathe": "^2.0.3", "picomatch": "^4.0.2", "pkg-types": "^2.1.1", "scule": "^1.3.0", "strip-literal": "^3.0.0", "tinyglobby": "^0.2.14", "unplugin": "^2.3.5", "unplugin-utils": "^0.2.4" } }, "sha512-wMmuG+wkzeHh2KCE6yiDlHmKelN8iE/maxkUYMbmrS6iV8+n6eP1TH3yKKlepuF4hrkepinEGmBXdfo9XZUvAw=="], "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], - "unplugin": ["unplugin@2.3.5", "", { "dependencies": { "acorn": "^8.14.1", "picomatch": "^4.0.2", "webpack-virtual-modules": "^0.6.2" } }, "sha512-RyWSb5AHmGtjjNQ6gIlA67sHOsWpsbWpwDokLwTcejVdOjEkJZh7QKu14J00gDDVSh8kGH4KYC/TNBceXFZhtw=="], + "unplugin": ["unplugin@3.3.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "picomatch": "^4.0.4", "webpack-virtual-modules": "^0.6.2" }, "peerDependencies": { "@farmfe/core": "*", "@rspack/core": "*", "bun-types-no-globals": "*", "esbuild": "*", "rolldown": "*", "rollup": "*", "unloader": "*", "vite": "*", "webpack": "*" }, "optionalPeers": ["@farmfe/core", "@rspack/core", "bun-types-no-globals", "esbuild", "rolldown", "rollup", "unloader", "vite", "webpack"] }, "sha512-qa66K+crbfyE6JK10GjvbJeRrOsuC/JpbnHctfyp/i4oBTxWOzJfRZyDiOk1PtErMFRu8JhsU/wPvOdBNWe5Rg=="], "unplugin-utils": ["unplugin-utils@0.2.4", "", { "dependencies": { "pathe": "^2.0.2", "picomatch": "^4.0.2" } }, "sha512-8U/MtpkPkkk3Atewj1+RcKIjb5WBimZ/WSLhhR3w6SsIj8XJuKTacSP8g+2JhfSGw0Cb125Y+2zA/IzJZDVbhA=="], @@ -1179,6 +1512,8 @@ "update-notifier": ["update-notifier@7.3.1", "", { "dependencies": { "boxen": "^8.0.1", "chalk": "^5.3.0", "configstore": "^7.0.0", "is-in-ci": "^1.0.0", "is-installed-globally": "^1.0.0", "is-npm": "^6.0.0", "latest-version": "^9.0.0", "pupa": "^3.1.0", "semver": "^7.6.3", "xdg-basedir": "^5.1.0" } }, "sha512-+dwUY4L35XFYEzE+OAL3sarJdUioVovq+8f7lcIJ7wnmnYQV5UD1Y/lcwaMSyaQ6Bj3JMj1XSTjZbNLHn/19yA=="], + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], @@ -1209,6 +1544,8 @@ "winreg": ["winreg@0.0.12", "", {}, "sha512-typ/+JRmi7RqP1NanzFULK36vczznSNN8kWVA9vIqXyv8GhghUlwhGp1Xj3Nms1FsPcNnsQrJOR10N58/nQ9hQ=="], + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + "workbox-cacheable-response": ["workbox-cacheable-response@7.4.1", "", { "dependencies": { "workbox-core": "7.4.1" } }, "sha512-8xaFoJdDc2OjrlbbL3gEeBO1WKcMwRqwLRupgqahYXu75yXajPLuwrbXMrIGZuWYXrQwk0xDjOxZ/ujCy/oJYw=="], "workbox-core": ["workbox-core@7.4.1", "", {}, "sha512-DT+vu46eh/2vRsSHTY4Xmc32Z1rr9PRlQUXr1Dx30ZuXRWwOsvZgGgcwxcasubQLQmbTNYZjv44LkBAQ4tT5tQ=="], @@ -1241,24 +1578,32 @@ "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], "yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="], + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + + "yoctocolors": ["yoctocolors@2.2.0", "", {}, "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg=="], + + "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], + "zip-dir": ["zip-dir@2.0.0", "", { "dependencies": { "async": "^3.2.0", "jszip": "^3.2.2" } }, "sha512-uhlsJZWz26FLYXOD6WVuq+fIcZ3aBPGo/cFdiLlv3KNwpa52IF3ISV8fLhQLiqVu5No3VhlqlgthN6gehil1Dg=="], "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], + "@aklinker1/rollup-plugin-visualizer/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="], "@aklinker1/rollup-plugin-visualizer/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "@aklinker1/rollup-plugin-visualizer/source-map": ["source-map@0.7.4", "", {}, "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA=="], - "@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -1267,12 +1612,26 @@ "@devicefarmer/adbkit/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "@eslint/config-array/minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], + "@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.4", "", {}, "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw=="], "@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.4", "", {}, "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw=="], + "@oxc-resolver/binding-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA=="], + + "@oxc-resolver/binding-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA=="], + + "@oxc-resolver/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q=="], + "@pnpm/network.ca-file/graceful-fs": ["graceful-fs@4.2.10", "", {}, "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA=="], + "@react-grab/cli/agent-install": ["agent-install@0.0.6", "", { "dependencies": { "@iarna/toml": "^2.2.5", "commander": "^14.0.0", "jsonc-parser": "^3.3.1", "picocolors": "^1.1.1", "prompts": "^2.4.2", "yaml": "^2.8.3" }, "bin": { "agent-install": "bin/agent-install.mjs" } }, "sha512-7NRMZ/ZDz2vHevQTgJsocBFpakB1/Wx5ip19YSJuj4VOXpraWztTerViNtdSyARKZT9e2yVwUUB5JXXCE7mNrA=="], + + "@react-grab/cli/ora": ["ora@9.4.1", "", { "dependencies": { "chalk": "^5.6.2", "cli-cursor": "^5.0.0", "cli-spinners": "^3.2.0", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.1.0", "log-symbols": "^7.0.1", "stdin-discarder": "^0.3.2", "string-width": "^8.1.0" } }, "sha512-6VlU9MLXbjVQD04AZCMX28hVtA5bUoadvUqO76MUCVA0ilwJbMiHsITRPfyVm6p/BC0Av/BXMujx39WCe1LEqw=="], + "@types/babel__core/@babel/parser": ["@babel/parser@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.0" }, "bin": "./bin/babel-parser.js" }, "sha512-jVZGvOxOuNSsuQuLRTh13nU0AogFlw32w/MT+LV6D3sP5WdbW61E77RnkbaO2dUvmPAYrBDJXGn5gGS6tH4j8g=="], "@types/babel__core/@babel/types": ["@babel/types@7.28.1", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1" } }, "sha512-x0LvFTekgSX+83TI28Y9wYPUfzrnl2aT5+5QLnO6v7mSJYtEEevuDRN0F0uSHRk1G1IWZC43o00Y0xDDrpBGPQ=="], @@ -1299,6 +1658,8 @@ "boxen/wrap-ansi": ["wrap-ansi@9.0.0", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q=="], + "c12/confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="], + "c12/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "c12/perfect-debounce": ["perfect-debounce@2.0.0", "", {}, "sha512-fkEH/OBiKrqqI/yIgjR92lMfs2K8105zt/VT6+7eTjNwisrsh47CeIED9z58zI7DfKdH3uHAn25ziRZn3kgAow=="], @@ -1317,10 +1678,28 @@ "config-chain/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + "configstore/dot-prop": ["dot-prop@9.0.0", "", { "dependencies": { "type-fest": "^4.18.2" } }, "sha512-1gxPBJpI/pcjQhKgIU91II6Wkay+dLcN3M6rf2uwP8hRur3HtQXjVrdAK3sjC0piaEuxzMwjXChcETiJl47lAQ=="], + + "cross-spawn/which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], + "dot-prop/type-fest": ["type-fest@5.9.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-yANm3Jr3GiJ1qgJlxGAVxTOIcEOk1rhQHamlXtnrCK7EHP4HeM9OGxtMg/W7HFdrVzw/ZWJKGVIJusVH85sLtw=="], + "dotenv-expand/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], + "eslint/ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="], + + "eslint/escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "eslint/minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], + + "eslint-plugin-react-hooks/@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="], + + "espree/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="], + "external-editor/tmp": ["tmp@0.0.33", "", { "dependencies": { "os-tmpdir": "~1.0.2" } }, "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw=="], "figures/escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], @@ -1333,6 +1712,8 @@ "global-directory/ini": ["ini@4.1.1", "", {}, "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g=="], + "import-in-the-middle/es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], + "inquirer/ora": ["ora@5.4.1", "", { "dependencies": { "bl": "^4.1.0", "chalk": "^4.1.0", "cli-cursor": "^3.1.0", "cli-spinners": "^2.5.0", "is-interactive": "^1.0.0", "is-unicode-supported": "^0.1.0", "log-symbols": "^4.1.0", "strip-ansi": "^6.0.0", "wcwidth": "^1.0.1" } }, "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ=="], "is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], @@ -1349,6 +1730,8 @@ "log-symbols/chalk": ["chalk@5.4.1", "", {}, "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w=="], + "log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], + "log-update/ansi-escapes": ["ansi-escapes@7.0.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw=="], "log-update/cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], @@ -1375,8 +1758,14 @@ "ora/cli-cursor": ["cli-cursor@4.0.0", "", { "dependencies": { "restore-cursor": "^4.0.0" } }, "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg=="], + "ora/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], + "ora/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="], + "oxc-parser/@oxc-project/types": ["@oxc-project/types@0.146.0", "", {}, "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA=="], + + "oxlint-plugin-react-doctor/lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], + "package-json/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], "parse-json/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], @@ -1385,16 +1774,28 @@ "parse5-htmlparser2-tree-adapter/parse5": ["parse5@6.0.1", "", {}, "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw=="], + "pkg-types/confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="], + "pkg-types/exsolve": ["exsolve@1.0.7", "", {}, "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw=="], "publish-browser-extension/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="], "publish-browser-extension/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], + "qified/hookified": ["hookified@2.2.0", "", {}, "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA=="], + "rc/ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], "rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], + "react-doctor/bippy": ["bippy@0.6.1", "", { "peerDependencies": { "react": ">=17.0.1" } }, "sha512-ky4m94Y/KfsddjGkKTsV4uFjZqkJjpOjQ2t5gKPdX6XH1MNxMNX5FrVefsxV4lpjemEmEdwe0e0YbzAMNs3oUQ=="], + + "react-doctor/magicast": ["magicast@0.5.4", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "source-map-js": "^1.2.1" } }, "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w=="], + + "react-doctor/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "react-grab/bippy": ["bippy@0.6.1", "", { "peerDependencies": { "react": ">=17.0.1" } }, "sha512-ky4m94Y/KfsddjGkKTsV4uFjZqkJjpOjQ2t5gKPdX6XH1MNxMNX5FrVefsxV4lpjemEmEdwe0e0YbzAMNs3oUQ=="], + "react-hot-toast/csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], "slice-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="], @@ -1403,6 +1804,12 @@ "stdin-discarder/bl": ["bl@5.1.0", "", { "dependencies": { "buffer": "^6.0.3", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-tv1ZJHLfTDnXE6tMHv73YgSJaWR2AFuPwMntBe7XL/GBFHnT0CLnsHMogfk5+GzCDC5ZWarSCYaIGATZt9dNsQ=="], + "strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="], + + "terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="], + + "unimport/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "unimport/magic-string": ["magic-string@0.30.17", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" } }, "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA=="], "unimport/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], @@ -1411,7 +1818,7 @@ "unimport/tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="], - "unplugin/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "unimport/unplugin": ["unplugin@2.3.5", "", { "dependencies": { "acorn": "^8.14.1", "picomatch": "^4.0.2", "webpack-virtual-modules": "^0.6.2" } }, "sha512-RyWSb5AHmGtjjNQ6gIlA67sHOsWpsbWpwDokLwTcejVdOjEkJZh7QKu14J00gDDVSh8kGH4KYC/TNBceXFZhtw=="], "unplugin-utils/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], @@ -1431,6 +1838,24 @@ "@aklinker1/rollup-plugin-visualizer/open/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="], + "@eslint/config-array/minimatch/brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], + + "@oxc-resolver/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], + + "@oxc-resolver/binding-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], + + "@react-grab/cli/ora/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "@react-grab/cli/ora/cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], + + "@react-grab/cli/ora/cli-spinners": ["cli-spinners@3.4.0", "", {}, "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw=="], + + "@react-grab/cli/ora/log-symbols": ["log-symbols@7.0.1", "", { "dependencies": { "is-unicode-supported": "^2.0.0", "yoctocolors": "^2.1.1" } }, "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg=="], + + "@react-grab/cli/ora/stdin-discarder": ["stdin-discarder@0.3.2", "", {}, "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A=="], + + "@react-grab/cli/ora/string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="], + "@types/babel__core/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="], "@types/babel__generator/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="], @@ -1445,6 +1870,8 @@ "boxen/string-width/emoji-regex": ["emoji-regex@10.4.0", "", {}, "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw=="], + "boxen/string-width/get-east-asian-width": ["get-east-asian-width@1.3.0", "", {}, "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ=="], + "boxen/string-width/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="], "boxen/wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="], @@ -1459,8 +1886,18 @@ "cli-truncate/string-width/emoji-regex": ["emoji-regex@10.4.0", "", {}, "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw=="], + "cli-truncate/string-width/get-east-asian-width": ["get-east-asian-width@1.3.0", "", {}, "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ=="], + "cli-truncate/string-width/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="], + "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "eslint-plugin-react-hooks/@babel/parser/@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + + "eslint/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "eslint/minimatch/brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], + "giget/nypm/pkg-types": ["pkg-types@2.2.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-2SM/GZGAEkPp3KWORxQZns4M+WSeXbC2HEvmOIJe3Cmiv6ieAJvdVhDldtHqM5J1Y7MrR1XhkBT/rMlhh9FdqQ=="], "giget/nypm/tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], @@ -1479,6 +1916,8 @@ "listr2/wrap-ansi/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="], + "local-pkg/pkg-types/confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="], + "local-pkg/pkg-types/exsolve": ["exsolve@1.0.7", "", {}, "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw=="], "log-update/cli-cursor/restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], @@ -1501,20 +1940,46 @@ "ora/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="], - "parse-json/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="], + "oxlint-plugin-react-doctor/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], + + "oxlint-plugin-react-doctor/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="], + + "oxlint-plugin-react-doctor/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="], + + "oxlint-plugin-react-doctor/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="], + + "oxlint-plugin-react-doctor/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="], + + "oxlint-plugin-react-doctor/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="], + + "oxlint-plugin-react-doctor/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="], - "parse-json/@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "oxlint-plugin-react-doctor/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="], + + "oxlint-plugin-react-doctor/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="], + + "oxlint-plugin-react-doctor/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="], + + "oxlint-plugin-react-doctor/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], + + "parse-json/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.27.1", "", {}, "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow=="], "publish-browser-extension/open/default-browser": ["default-browser@5.2.1", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg=="], "publish-browser-extension/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], + "react-doctor/magicast/@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="], + + "react-doctor/magicast/@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + "stdin-discarder/bl/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], "stdin-discarder/bl/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], "unimport/magic-string/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.4", "", {}, "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw=="], + "unimport/pkg-types/confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="], + "unimport/pkg-types/exsolve": ["exsolve@1.0.7", "", {}, "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw=="], "unimport/tinyglobby/fdir": ["fdir@6.4.6", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w=="], @@ -1531,8 +1996,16 @@ "widest-line/string-width/emoji-regex": ["emoji-regex@10.4.0", "", {}, "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw=="], + "widest-line/string-width/get-east-asian-width": ["get-east-asian-width@1.3.0", "", {}, "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ=="], + "widest-line/string-width/strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="], + "@eslint/config-array/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "@react-grab/cli/ora/cli-cursor/restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], + + "@react-grab/cli/ora/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], + "boxen/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="], "boxen/wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="], @@ -1541,20 +2014,38 @@ "cli-truncate/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="], + "eslint-plugin-react-hooks/@babel/parser/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "eslint-plugin-react-hooks/@babel/parser/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "eslint/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "giget/nypm/pkg-types/confbox": ["confbox@0.2.2", "", {}, "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ=="], + "giget/nypm/pkg-types/exsolve": ["exsolve@1.0.7", "", {}, "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw=="], "listr2/wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.4.0", "", {}, "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw=="], + "listr2/wrap-ansi/string-width/get-east-asian-width": ["get-east-asian-width@1.3.0", "", {}, "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ=="], + "listr2/wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="], "log-update/cli-cursor/restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], "log-update/cli-cursor/restore-cursor/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "log-update/slice-ansi/is-fullwidth-code-point/get-east-asian-width": ["get-east-asian-width@1.3.0", "", {}, "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ=="], + "log-update/wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.4.0", "", {}, "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw=="], + "log-update/wrap-ansi/string-width/get-east-asian-width": ["get-east-asian-width@1.3.0", "", {}, "sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ=="], + "publish-browser-extension/open/wsl-utils/is-wsl": ["is-wsl@3.1.0", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw=="], + "react-doctor/magicast/@babel/types/@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "react-doctor/magicast/@babel/types/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + "vite-node/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.6", "", { "os": "aix", "cpu": "ppc64" }, "sha512-ShbM/3XxwuxjFiuVBHA+d3j5dyac0aEVVq1oluIDf71hUw0aRF59dV/efUsIwFnR6m8JNM2FjZOzmaZ8yG61kw=="], "vite-node/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.6", "", { "os": "android", "cpu": "arm" }, "sha512-S8ToEOVfg++AU/bHwdksHNnyLyVM+eMVAOf6yRKFitnwnbwwPNqKr3srzFRe7nzV69RQKb5DgchIX5pt3L53xg=="], @@ -1610,5 +2101,11 @@ "vite-node/vite/postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "widest-line/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="], + + "@react-grab/cli/ora/cli-cursor/restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], + + "@react-grab/cli/ora/cli-cursor/restore-cursor/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "@react-grab/cli/ora/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="], } } diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index a6832ecc1..000000000 --- a/docker-compose.yml +++ /dev/null @@ -1,14 +0,0 @@ -services: - build-extension: - build: - context: . - dockerfile: Dockerfile - environment: - - NODE_ENV=production - - VITE_API=https://api.widgetify.ir - volumes: - - ./local_output:/app/.output - command: > - sh -c "npm run clean && - npm run build:firefox && - npm run zip:firefox:clean" diff --git a/package.json b/package.json index 26289578c..ca12f2df9 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "zip:firefox": "wxt zip -b firefox", "zip:firefox:clean": "npm run clean && wxt zip -b firefox", "compile": "tsc --noEmit", + "test": "bun test", "test:pre-push": ".git/hooks/pre-push.bat", "postinstall": "wxt prepare" }, @@ -25,7 +26,6 @@ "@wxt-dev/webextension-polyfill": "1.0.0", "axios": "1.18.0", "canvas-confetti": "1.9.4", - "chart.js": "4.5.1", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "framer-motion": "12.40.0", @@ -36,7 +36,6 @@ "motion": "12.40.0", "ms": "2.1.3", "react": "19.2.7", - "react-chartjs-2": "5.3.1", "react-colorful": "5.7.0", "react-dom": "19.2.7", "react-easy-crop": "6.2.2", @@ -65,6 +64,7 @@ "@wxt-dev/auto-icons": "1.1.1", "@wxt-dev/module-react": "1.2.2", "daisyui": "5.5.23", + "react-scan": "^0.5.7", "tailwindcss": "4.3.1", "terser": "5.48.0", "typescript": "6.0.3", diff --git a/src/assets/animals/backgrounds/autumn.png b/src/assets/animals/backgrounds/autumn.png new file mode 100644 index 000000000..5e21a68de Binary files /dev/null and b/src/assets/animals/backgrounds/autumn.png differ diff --git a/src/assets/animals/backgrounds/beach.png b/src/assets/animals/backgrounds/beach.png new file mode 100644 index 000000000..3eb318ef6 Binary files /dev/null and b/src/assets/animals/backgrounds/beach.png differ diff --git a/src/assets/animals/backgrounds/forest.png b/src/assets/animals/backgrounds/forest.png new file mode 100644 index 000000000..8bacc6d4d Binary files /dev/null and b/src/assets/animals/backgrounds/forest.png differ diff --git a/src/assets/images/pro-preview.jpg b/src/assets/images/pro-preview.jpg new file mode 100644 index 000000000..5ad8c3a75 Binary files /dev/null and b/src/assets/images/pro-preview.jpg differ diff --git a/src/common/constant/config.key.ts b/src/common/constants/config.key.ts similarity index 75% rename from src/common/constant/config.key.ts rename to src/common/constants/config.key.ts index b3287554e..3a36d4918 100644 --- a/src/common/constant/config.key.ts +++ b/src/common/constants/config.key.ts @@ -1,4 +1,4 @@ export enum ConfigKey { - VERSION_NAME = 'موج', + VERSION_NAME = 'کوروش', WIG_COIN_ICON = 'https://cdn.widgetify.ir/extension/wig-icon.png', } diff --git a/src/common/constant/habit-options.ts b/src/common/constants/habit-options.ts similarity index 100% rename from src/common/constant/habit-options.ts rename to src/common/constants/habit-options.ts diff --git a/src/common/constant/moods.ts b/src/common/constants/moods.ts similarity index 82% rename from src/common/constant/moods.ts rename to src/common/constants/moods.ts index f873d26b3..884e08bbc 100644 --- a/src/common/constant/moods.ts +++ b/src/common/constants/moods.ts @@ -9,13 +9,6 @@ export const moodOptions = [ { value: 'normal', emoji: '😴', - label: '', - colorClass: 'warning', - borderClass: 'border-yellow-400/50', - }, - { - value: 'tired', - emoji: '😴', label: 'خستم', colorClass: 'warning', borderClass: 'border-yellow-400/50', diff --git a/src/common/constant/priority_options.ts b/src/common/constants/priority-options.ts similarity index 100% rename from src/common/constant/priority_options.ts rename to src/common/constants/priority-options.ts diff --git a/src/common/constant/store.key.ts b/src/common/constants/store.key.ts similarity index 86% rename from src/common/constant/store.key.ts rename to src/common/constants/store.key.ts index bf1832b6d..165d53aa1 100644 --- a/src/common/constant/store.key.ts +++ b/src/common/constants/store.key.ts @@ -1,24 +1,23 @@ import type { CurrencyColorMode } from '@/context/currency.context' import type { WidgetItem } from '@/context/widget-visibility.context' import type { Bookmark } from '@/layouts/bookmark/types/bookmark.types' -import type { PetSettings } from '@/layouts/widgetify-card/pets/pet.context' -import type { ComboTabType } from '@/layouts/widgets/combo-widget/combo-widget.layout' +import type { PetSettings } from '@/layouts/widgets/pet/types' +import type { ComboTabType } from '@/layouts/widgets/combo-widget/combo-widget.widget' import type { WigiNewsSetting } from '@/layouts/widgets/news/rss.interface' import type { PomodoroSession, PomodoroSettings, } from '@/layouts/widgets/tools/pomodoro/types' -import type { ToolsTabType } from '@/layouts/widgets/tools/tools.layout' +import type { ToolsTabType } from '@/layouts/widgets/tools/tools.widget' import type { FetchedForecast, FetchedWeather, WeatherSettings, } from '@/layouts/widgets/weather/weather.interface' -import type { ClockSettings } from '@/layouts/widgets/wigi-pad/clock-display/clock-setting.interface' -import type { WigiPadDateSetting } from '@/layouts/widgets/wigi-pad/date-display/date-setting.interface' -import type { ExtensionConfigResponse } from '@/services/config-data/config_data-api' +import type { ClockSettings } from '@/layouts/widgets/clock/clock-setting.interface' +import type { ExtensionConfigResponse } from '@/services/config-data/config-data.api' import type { FetchedCurrency } from '@/services/hooks/currency/get-currency-by-code.hook' -import type { RecommendedSite, TrendItem } from '@/services/hooks/trends/get-trends' +import type { RecommendedSite, TrendItem } from '@/services/hooks/trends/get-trends.hook' import type { UserProfile } from '@/services/hooks/user/user-service.hook' import type { StoredWallpaper, Wallpaper } from '../wallpaper.interface' import type { Todo } from '@/services/hooks/todo/todo.interface' @@ -36,6 +35,7 @@ export interface StorageKV { bookmarks: Bookmark[] deletedBookmarkIds: string[] showWelcomeModal: boolean + compact_currencies: any weatherSettings: WeatherSettings hasSeenTour: boolean [key: `currency:${string}`]: FetchedCurrency @@ -47,6 +47,8 @@ export interface StorageKV { refresh_token: string | null profile: UserProfile activeWidgets: WidgetItem[] + storedWidgets: import('@/layouts/widgets/layout-engine/types').StoredWidget[] + widgetLayoutMigrationVersion: number search_trends: TrendItem[] recommended_sites: RecommendedSite[] deletedTodos: Todo[] @@ -62,7 +64,6 @@ export interface StorageKV { recent_searches: any pets: PetSettings clock: ClockSettings - wigiPadDate: WigiPadDateSetting configData: ExtensionConfigResponse toolsTab: ToolsTabType comboTabs: ComboTabType @@ -78,7 +79,6 @@ export interface StorageKV { name: string } pendingOrders: any - petState: boolean showNewBadgeForReOrderWidgets: boolean navbarVisible: boolean todoFilter: string diff --git a/src/common/storage.ts b/src/common/storage.ts index e6f9f4e01..1fa871365 100644 --- a/src/common/storage.ts +++ b/src/common/storage.ts @@ -1,5 +1,5 @@ import { storage } from 'wxt/utils/storage' -import type { StorageKV } from './constant/store.key' +import type { StorageKV } from './constants/store.key' export async function setToStorage( key: K, @@ -58,6 +58,21 @@ export async function removeFromStorage(key: K) { await storage.removeItem(`local:${key}`) } +export const DEPRECATED_STORAGE_KEYS = ['petState'] as const + +export async function purgeDeprecatedStorageKeys() { + await Promise.all( + DEPRECATED_STORAGE_KEYS.map((key) => storage.removeItem(`local:${key}`)) + ) +} + +export function watchStorage( + key: K, + callback: (newValue: StorageKV[K] | null, oldValue: StorageKV[K] | null) => void +) { + return storage.watch(`local:${key}`, callback) +} + export async function setWithExpiry( key: K, value: StorageKV[K], diff --git a/src/common/toast.tsx b/src/common/toast.tsx index 6c442edbe..e68a94ca1 100644 --- a/src/common/toast.tsx +++ b/src/common/toast.tsx @@ -1,12 +1,22 @@ -import React, { ReactNode } from 'react' +import type React from 'react' +import type { ReactNode } from 'react' import toast from 'react-hot-toast' import { playAlarm } from './play-alarm' import { translateError } from '@/common/utils/translate-error' import { Icon } from '../icons' +import { cn } from '@/common/utils/cn' -type ToastType = 'success' | 'error' | 'info' | 'warning' +function raiseToastLayer() { + if (typeof document === 'undefined') return + const toaster = document.querySelector('[data-rht-toaster]') as HTMLElement | null + if (toaster) { + toaster.style.zIndex = '99999999' + } +} + +export type ToastType = 'success' | 'error' | 'info' | 'warning' -interface ToastOptions { +export interface ToastOptions { duration?: number position?: | 'top-left' @@ -16,129 +26,279 @@ interface ToastOptions { | 'bottom-center' | 'bottom-right' alarmSound?: boolean + sound?: boolean title?: string + actionText?: string + onAction?: () => void +} + +let audioCtx: AudioContext | null = null + +function getAudioContext(): AudioContext | null { + if (typeof window === 'undefined') return null + try { + const AudioContextClass = + window.AudioContext || + (window as unknown as { webkitAudioContext: typeof AudioContext }) + .webkitAudioContext + if (!AudioContextClass) return null + if (!audioCtx || audioCtx.state === 'closed') { + audioCtx = new AudioContextClass() + } + if (audioCtx.state === 'suspended') { + audioCtx.resume().catch(() => {}) + } + return audioCtx + } catch { + return null + } +} + +export const TOAST_SOUND_VOLUME = 0.55 + +function playTone( + ctx: AudioContext, + freq: number, + start: number, + duration: number, + vol: number, + type: OscillatorType = 'sine', + endFreq?: number +) { + const osc = ctx.createOscillator() + const gain = ctx.createGain() + + osc.type = type + osc.frequency.setValueAtTime(Math.max(1, freq), start) + if (endFreq && endFreq > 0) { + osc.frequency.exponentialRampToValueAtTime(Math.max(1, endFreq), start + duration) + } + + gain.gain.setValueAtTime(0.0001, start) + gain.gain.linearRampToValueAtTime(vol, start + 0.008) + gain.gain.exponentialRampToValueAtTime(0.0001, start + duration) + + osc.connect(gain) + gain.connect(ctx.destination) + + osc.start(start) + osc.stop(start + duration) } -const TT: Record< +export function playNativeToastSound( + type: ToastType, + volume: number = TOAST_SOUND_VOLUME +) { + try { + const ctx = getAudioContext() + if (!ctx) return + + const now = ctx.currentTime + + if (type === 'success') { + playTone(ctx, 784, now, 0.28, volume * 0.65, 'sine') + playTone(ctx, 1568, now, 0.22, volume * 0.18, 'triangle') + playTone(ctx, 1046.5, now + 0.08, 0.38, volume * 0.8, 'sine') + playTone(ctx, 1318.5, now + 0.08, 0.32, volume * 0.3, 'sine') + playTone(ctx, 2093, now + 0.08, 0.26, volume * 0.18, 'triangle') + } else if (type === 'error') { + playTone(ctx, 200, now, 0.1, volume * 0.6, 'sine', 150) + playTone(ctx, 150, now + 0.09, 0.14, volume * 0.7, 'sine', 100) + } else if (type === 'warning') { + playTone(ctx, 587.33, now, 0.18, volume * 0.55, 'sine') + playTone(ctx, 880, now + 0.07, 0.26, volume * 0.7, 'sine') + playTone(ctx, 1760, now + 0.07, 0.18, volume * 0.14, 'triangle') + } else { + playTone(ctx, 1100, now, 0.035, volume * 0.5, 'sine', 740) + playTone(ctx, 784, now + 0.015, 0.2, volume * 0.65, 'sine') + playTone(ctx, 1568, now + 0.015, 0.14, volume * 0.14, 'triangle') + } + } catch {} +} + +const TOAST_THEMES: Record< ToastType, - { borderColor: string; iconBg: string; iconColor: string; icon: ReactNode } + { + container: string + icon: ReactNode + defaultTitle: string + defaultActionText: string + messageClass: string + } > = { - success: { - borderColor: 'border-r-success border-br-success', - iconBg: 'bg-success/10', - iconColor: 'text-success', - icon: , + info: { + container: 'bg-[#18181b]/95 border-white/10 text-white', + icon: ( +
+ +
+ ), + defaultTitle: 'نکته', + defaultActionText: 'متوجه شدم', + messageClass: 'text-neutral-300', }, error: { - borderColor: 'border-r-error', - iconBg: 'bg-error/10', - iconColor: 'text-error', - icon: , + container: 'bg-[#2a1317]/95 border-red-500/25 text-white', + icon: ( +
+ +
+ ), + defaultTitle: 'خطا', + defaultActionText: 'تلاش مجدد', + messageClass: 'text-red-200/85', }, - info: { - borderColor: 'border-r-info', - iconBg: 'bg-info/10', - iconColor: 'text-info', - icon: , + success: { + container: 'bg-[#142618]/95 border-emerald-500/25 text-white', + icon: ( +
+ +
+ ), + defaultTitle: 'موفقیت آمیز', + defaultActionText: 'تایید', + messageClass: 'text-emerald-200/85', }, warning: { - borderColor: 'border-r-warning', - iconBg: 'bg-warning/10', - iconColor: 'text-warning', - icon: , + container: 'bg-[#2b2210]/95 border-amber-500/25 text-white', + icon: ( +
+ +
+ ), + defaultTitle: 'هشدار', + defaultActionText: 'متوجه شدم', + messageClass: 'text-amber-200/85', }, } -export function showToast(message: string, type: ToastType, options?: ToastOptions) { - const tt = TT[type] +export function showToast( + message: string, + type: ToastType = 'info', + options?: ToastOptions +) { + const theme = TOAST_THEMES[type] || TOAST_THEMES.info + const title = options?.title ?? theme.defaultTitle + const actionText = options?.actionText ?? theme.defaultActionText - const myToast = toast.custom( - (t) => ( -
+ if (options?.sound !== false) { + playNativeToastSound(type) + } + + return toast.custom( + (t) => { + const handleAction = () => { + toast.remove(t.id, t.toasterId) + if (options?.onAction) { + options.onAction() + } + } + + return (
- {tt.icon} -
-
-

- {options?.title} -

-

{message}

+
+ {theme.icon} +
+

+ {title} +

+ {message && ( +

+ {message} +

+ )} +
+
+ +
- -
- ), - { duration: options?.duration ?? 5000, position: options?.position } + ) + }, + { + duration: options?.duration ?? 5000, + position: options?.position ?? 'top-center', + } ) - - if (options?.alarmSound) playAlarm('success') - - return myToast } export function showCustomToast( message: React.ReactNode | string, options?: ToastOptions ) { - const myToast = toast.custom(() => <>{message}, { - duration: options?.duration ?? 5000, - position: options?.position, - }) + raiseToastLayer() - if (options?.alarmSound) playAlarm('success') + if (options?.sound !== false) { + playNativeToastSound('info') + } + if (options?.alarmSound) { + playAlarm('success') + } - return myToast + return toast.custom(() => <>{message}, { + duration: options?.duration ?? 5000, + position: options?.position ?? 'top-center', + }) } export function showPreviewToast(itemName: string, onCancel: () => void): string { + raiseToastLayer() + const id = `preview-${Date.now()}` + playNativeToastSound('info') + toast.custom( (t) => (
-
-
- - - - - +
+
+
-
-

+

+

حالت پیش‌نمایش

-

+

{itemName}

+
), diff --git a/src/common/utils/animation-timing.ts b/src/common/utils/animation-timing.ts new file mode 100644 index 000000000..f2b399f42 --- /dev/null +++ b/src/common/utils/animation-timing.ts @@ -0,0 +1,5 @@ +export const EXIT_ANIMATION_MS = 360 + +export function isRetainableValue(value: unknown): boolean { + return value !== null && value !== undefined && value !== false +} diff --git a/src/common/utils/call-event.ts b/src/common/utils/call-event.ts index 5a0f5ffa8..b340939c0 100644 --- a/src/common/utils/call-event.ts +++ b/src/common/utils/call-event.ts @@ -1,26 +1,45 @@ import type { CurrencyColorMode } from '@/context/currency.context' import type { Bookmark } from '@/layouts/bookmark/types/bookmark.types' -import type { PetTypes } from '@/layouts/widgetify-card/pets/pet.context' +import type { PetBackgroundId, PetTypes } from '@/layouts/widgets/pet/types' import type { WigiNewsSetting } from '@/layouts/widgets/news/rss.interface' import type { WeatherSettings } from '@/layouts/widgets/weather/weather.interface' -import type { ClockSettings } from '@/layouts/widgets/wigi-pad/clock-display/clock-setting.interface' -import type { WigiPadDateSetting } from '@/layouts/widgets/wigi-pad/date-display/date-setting.interface' -import type { WidgetTabKeys } from '@/layouts/widgets-settings/constant/tab-keys' -import type { StoredWallpaper } from '../wallpaper.interface' +import type { ClockSettings } from '@/layouts/widgets/clock/clock-setting.interface' +import type { WidgetTabKeys } from '@/layouts/widgets-settings/tab-keys' +import type { StoredWallpaper, Wallpaper } from '../wallpaper.interface' import type { Todo } from '@/services/hooks/todo/todo.interface' import type { Page } from '@/context/page.context' export interface EventName { - openSettings: 'account' | 'profile' | 'wallpapers' | 'general' | null + openSettings: + | 'account' + | 'profile' + | 'platforms' + | 'vip' + | 'pro' + | 'tasks' + | 'friends' + | 'general' + | 'access' + | 'appearance' + | 'wallpapers' + | 'shortcuts' + | 'about' + | null todosChanged: Todo[] wallpaper_change: StoredWallpaper - openWidgetsSettings: { tab: WidgetTabKeys | null } + custom_wallpaper_sync: Wallpaper | null + openWidgetsSettings: { + tab: WidgetTabKeys | null + instanceId?: string + size?: { w: number; h: number } + } bookmarksChanged: Bookmark[] updatedPetSettings: { + instanceId?: string petName?: string petType: PetTypes + background?: PetBackgroundId } - updatedPetState: boolean theme_change: { theme: string sync: boolean @@ -34,16 +53,16 @@ export interface EventName { } // setting keys - wigiPadDateSettingsChanged: WigiPadDateSetting - wigiPadClockSettingsChanged: ClockSettings + clockSettingsChanged: ClockSettings currencies_updated: { currencies: string[] colorMode: CurrencyColorMode + compactCurrencies?: Record } wigiNewsSettingsChanged: WigiNewsSetting weatherSettingsChanged: WeatherSettings closeAllDropdowns: null - openProfile?: 'friends' | 'platforms' + openProfile?: 'friends' | 'platforms' | 'vip' | 'pro' openMarketModal: null font_change: { font: string @@ -60,6 +79,12 @@ export interface EventName { toggle_miniApp_fullScreen: boolean close_friends_bottomSheet: null resetWallpaper: null + openAddCustomWidgetModal: + | { instanceId?: string; widgetId?: string } + | null + | undefined + openPresetLayoutsModal: null + cancelWidgetDrag: null } export function callEvent(eventName: K, data?: EventName[K]) { diff --git a/src/common/utils/canvas.ts b/src/common/utils/canvas.ts new file mode 100644 index 000000000..7fb2de2c2 --- /dev/null +++ b/src/common/utils/canvas.ts @@ -0,0 +1,113 @@ +import { showToast } from '@/common/toast' + +export function drawRoundedRect( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + w: number, + h: number, + r: number +) { + const radius = Math.min(r, w / 2, h / 2) + + ctx.beginPath() + ctx.moveTo(x + radius, y) + ctx.lineTo(x + w - radius, y) + ctx.arcTo(x + w, y, x + w, y + radius, radius) + ctx.lineTo(x + w, y + h - radius) + ctx.arcTo(x + w, y + h, x + w - radius, y + h, radius) + ctx.lineTo(x + radius, y + h) + ctx.arcTo(x, y + h, x, y + h - radius, radius) + ctx.lineTo(x, y + radius) + ctx.arcTo(x, y, x + radius, y, radius) + ctx.closePath() +} + +export function hexToRgb(hex: string) { + const normalized = hex.replace('#', '') + + if (normalized.length !== 6) { + return { r: 83, g: 109, b: 254 } + } + + return { + r: Number.parseInt(normalized.slice(0, 2), 16), + g: Number.parseInt(normalized.slice(2, 4), 16), + b: Number.parseInt(normalized.slice(4, 6), 16), + } +} + +export function rgba(color: string, alpha: number) { + const { r, g, b } = hexToRgb(color) + return `rgba(${r}, ${g}, ${b}, ${alpha})` +} + +export function fitText(ctx: CanvasRenderingContext2D, text: string, maxWidth: number) { + let result = text + + while (result.length > 0 && ctx.measureText(result).width > maxWidth) { + result = result.slice(0, -1) + } + + if (result !== text) { + return `${result.trim()}…` + } + + return result +} + +export async function copyCanvasToClipboard( + canvas: HTMLCanvasElement | null +): Promise { + if (!canvas) return false + + return new Promise((resolve) => { + canvas.toBlob( + async (blob) => { + if (!blob) { + showToast('خطا در ایجاد تصویر', 'error') + resolve(false) + return + } + + try { + await navigator.clipboard.write([ + new ClipboardItem({ + 'image/png': blob, + }), + ]) + showToast('تصویر در کلیپ‌بورد کپی شد', 'success') + resolve(true) + } catch { + showToast( + 'امکان کپی خودکار در مرورگر وجود ندارد، تصویر را ذخیره کن', + 'warning' + ) + resolve(false) + } + }, + 'image/png', + 1 + ) + }) +} + +export function downloadCanvasAsImage( + canvas: HTMLCanvasElement | null, + filename: string +): boolean { + if (!canvas) return false + + try { + const dataUrl = canvas.toDataURL('image/png') + const link = document.createElement('a') + link.download = filename.endsWith('.png') ? filename : `${filename}.png` + link.href = dataUrl + link.click() + showToast('تصویر با موفقیت ذخیره شد', 'success') + return true + } catch { + showToast('خطا در دانلود تصویر', 'error') + return false + } +} diff --git a/src/common/utils/get-timezone-label.ts b/src/common/utils/get-timezone-label.ts new file mode 100644 index 000000000..c15a8790c --- /dev/null +++ b/src/common/utils/get-timezone-label.ts @@ -0,0 +1,11 @@ +export function getTimeZoneLabel(timezone: string): string { + if (timezone.length === 3) { + return timezone + } + + if (timezone.split('/')[1]) { + return timezone.split('/')[1].replace('_', ' ').toUpperCase() + } + + return timezone +} diff --git a/src/common/utils/translate-error.ts b/src/common/utils/translate-error.ts index f1ed6776e..10a02c40c 100644 --- a/src/common/utils/translate-error.ts +++ b/src/common/utils/translate-error.ts @@ -1,160 +1,178 @@ const errorTranslations: Record = { - ACTIVITY_ALREADY_EXISTS: 'فقط یک نوشته میتونی داشته باشی!', - MAX_SHARED_USERS_EXCEEDED: 'تعداد کاربران قابل اشتراک این تسک بیشتر از حد مجازهه!', - ACTIVITY_NOT_FOUND: 'نوشته پیدا نشد!!', + ACTIVITY_ALREADY_EXISTS: 'فقط یک نوشته می‌تونی داشته باشی', + MAX_SHARED_USERS_EXCEEDED: 'تعداد کاربران قابل اشتراک این تسک بیشتر از حد مجازه', + ACTIVITY_NOT_FOUND: 'نوشته پیدا نشد', // Authentication errors INVALID_PASS_MAIL: 'ایمیل یا رمز عبور اشتباه است', INVALID_CREDENTIALS: 'اطلاعات ورود نامعتبر است', - EMAIL_ALREADY_EXISTS: 'این ایمیل قبلا ثبت شده است', - USER_NOT_FOUND: 'کاربر یافت نشد', - TOKEN_EXPIRED: 'نشست شما منقضی شده است. لطفا دوباره وارد شوید', + EMAIL_ALREADY_EXISTS: 'این ایمیل از قبل ثبت شده', + USER_NOT_FOUND: 'کاربر پیدا نشد', + TOKEN_EXPIRED: 'نشست شما منقضی شده، لطفا دوباره وارد شو', INVALID_TOKEN: 'توکن احراز هویت نامعتبر است', - UNAUTHORIZED: 'شما مجوز دسترسی به این بخش را ندارید', - FORBIDDEN: 'دسترسی به این بخش محدود شده است', + UNAUTHORIZED: 'مجوز دسترسی به این بخش رو نداری', + FORBIDDEN: 'دسترسی به این بخش محدود شده', - // Password reset errors + // Rate-limit & OTP errors + OTP_RATE_LIMIT: 'کد تایید به تازگی ارسال شده، کمی صبر کن', FORGOT_PASSWORD_REQUEST_LIMIT: - 'تعداد درخواست‌های بازیابی رمز عبور به حد مجاز رسیده است. لطفا بعدا تلاش کنید', - RESET_TOKEN_EXPIRED: - 'لینک بازیابی رمز عبور منقضی شده است. لطفا درخواست جدید ارسال کنید', + 'تعداد درخواست‌های بازیابی رمز عبور بیش از حد مجازه، لطفا بعدا امتحان کن', + RESET_TOKEN_EXPIRED: 'لینک بازیابی رمز عبور منقضی شده، درخواست جدید ارسال کن', INVALID_RESET_TOKEN: 'لینک بازیابی رمز عبور نامعتبر است', // Validation errors - WEAK_PASSWORD: 'رمز عبور ضعیف است. از حروف، اعداد و نمادها استفاده کنید', - PASSWORD_TOO_SHORT: 'رمز عبور باید حداقل 8 کاراکتر باشد', + WEAK_PASSWORD: 'رمز عبور ضعیفه، از حروف، اعداد و نمادها استفاده کن', + PASSWORD_TOO_SHORT: 'رمز عبور باید حداقل ۸ کاراکتر باشه', INVALID_EMAIL_FORMAT: 'فرمت ایمیل نامعتبر است', NAME_REQUIRED: 'نام کاربری الزامی است', INVALID_INPUTS: 'اطلاعات وارد شده نامعتبر است', // HTTP status errors - INTERNAL_SERVER_ERROR: 'خطای داخلی سرور رخ داده است', - SERVICE_UNAVAILABLE: 'سرویس در حال حاضر در دسترس نیست', - TOO_MANY_REQUESTS: 'تعداد درخواست‌ها بیش از حد مجاز است. لطفا کمی صبر کنید', + INTERNAL_SERVER_ERROR: 'خطای داخلی سرور رخ داده', + SERVICE_UNAVAILABLE: 'سرویس در دسترس نیست', + TOO_MANY_REQUESTS: 'تعداد درخواست‌ها بیش از حد مجازه، کمی صبر کن', BAD_REQUEST: 'درخواست نامعتبر است', - NOT_FOUND: 'منبع درخواستی یافت نشد', + NOT_FOUND: 'موردی پیدا نشد', ACTIVITY_UPDATE_RATE_LIMIT_EXCEEDED: - 'تعداد درخواست‌ها برای بروزرسانی "وضعیت" بیش از حد مجاز است. لطفا کمی صبر کنید', + 'تعداد درخواست‌ها برای بروزرسانی وضعیت بیش از حد مجازه، کمی صبر کن', // Friend-related errors - CANT_REQUEST_YOURSELF: 'نمی‌توانید به خودتان درخواست دوستی ارسال کنید', - FRIEND_REQUEST_ALREADY_SENT: 'درخواست دوستی قبلا ارسال شده است', - FRIEND_REQUEST_ALREADY_EXISTS: 'درخواست دوستی قبلا وجود دارد', + CANT_REQUEST_YOURSELF: 'نمی‌تونی به خودت درخواست دوستی بفرستی', + FRIEND_REQUEST_ALREADY_SENT: 'درخواست دوستی قبلا ارسال شده', + FRIEND_REQUEST_ALREADY_EXISTS: 'درخواست دوستی از قبل وجود داره', FAILED_TO_FETCH_FRIENDS: 'خطا در دریافت لیست دوستان', FAILED_TO_SEND_REQUEST: 'خطا در ارسال درخواست دوستی', FAILED_TO_ACCEPT_REQUEST: 'خطا در پذیرش درخواست دوستی', FAILED_TO_REMOVE_FRIEND: 'خطا در حذف دوست', FRIEND_REQUEST_SENT: 'درخواست دوستی ارسال شد', - FRIEND_REQUEST_NOT_FOUND: 'درخواست دوستی یافت نشد', - SET_USERNAME_FIRST: 'لطفا ابتدا نام کاربری خود را تنظیم کنید', + FRIEND_REQUEST_NOT_FOUND: 'درخواست دوستی پیدا نشد', + SET_USERNAME_FIRST: 'اول نام کاربریت رو تنظیم کن', // Translate-related errors - SOURCE_AND_TARGET_LANG_MUST_BE_DIFFERENT: 'زبان مبدأ و مقصد نمی‌توانند یکسان باشند', - TARGET_LANG_CANNOT_BE_AUTO: 'زبان مقصد نمی‌تواند تشخیص خودکار باشد', + SOURCE_AND_TARGET_LANG_MUST_BE_DIFFERENT: 'زبان مبدأ و مقصد نمی‌تونن یکسان باشن', + TARGET_LANG_CANNOT_BE_AUTO: 'زبان مقصد نمی‌تونه تشخیص خودکار باشه', TRANSLATION_FAILED: 'خطا در ترجمه متن', FAILED_TO_FETCH_LANGUAGES: 'خطا در دریافت لیست زبان‌ها', INVALID_LANGUAGE_CODE: 'کد زبان نامعتبر است', - TEXT_TOO_LONG: 'متن برای ترجمه خیلی طولانی است', - EMPTY_TEXT: 'متن برای ترجمه نمی‌تواند خالی باشد', - TRANSLATION_QUOTA_EXCEEDED: 'سهمیه ترجمه شما به پایان رسیده است', + TEXT_TOO_LONG: 'متن برای ترجمه خیلی طولانیه', + EMPTY_TEXT: 'متن برای ترجمه نمی‌تونه خالی باشه', + TRANSLATION_QUOTA_EXCEEDED: 'سهمیه ترجمه به پایان رسیده', // Success messages SUCCESS: 'عملیات با موفقیت انجام شد', - // Widget-related errors - WIDGET_NOT_FOUND: 'ویجت مورد نظر یافت نشد', - WIDGET_ALREADY_EXISTS: 'این ویجت قبلا اضافه شده است', - INVALID_WIDGET_POSITION: 'موقعیت ویجت نامعتبر است', - MAX_WIDGETS_REACHED: 'به حداکثر تعداد مجاز ویجت رسیده‌اید', + // Widget-related messages + WIDGET_NOT_FOUND: 'ویجت پیدا نشد', + WIDGET_DELETED: 'ویجت حذف شد', + WIDGET_DUPLICATED: 'ویجت تکرار شد', + WIDGET_ALREADY_EXISTS: 'این ویجت از قبل به صفحه اضافه شده', + INVALID_WIDGET_POSITION: 'موقعیت قرارگیری ویجت درست نیست', + STORAGE_QUOTA_EXCEEDED: 'فضای ذخیره‌سازی عکس‌های ویجت پر شده', + WIDGET_LIMIT_EXCEEDED: 'تعداد ویجت‌های مجاز پر شده', + MAX_WIDGETS_REACHED: + 'به سقف تعداد ویجت‌ها رسیدی، یکی از قبلی‌ها رو حذف کن تا جا باز بشه', + NO_SPACE_FOR_WIDGET: + 'روی صفحه جا نیست، چند تا ویجت رو جابه‌جا یا حذف کن تا جا باز بشه', + NO_SPACE_FOR_DUPLICATE: 'روی صفحه جا نیست، برای تکرار ویجت کمی فضا باز کن', + + // Bookmark-related messages + BOOKMARK_DELETED: 'نشانک حذف شد', + BOOKMARK_ADDED: 'نشانک اضافه شد', + BOOKMARK_UPDATED: 'نشانک ویرایش شد', + BOOKMARK_PARENT_NOT_FOUND: 'پوشه پیدا نشد', + FILE_SIZE_EXCEEDED: 'حجم فایل بیشتر از حد مجازه', // Network errors - NETWORK_ERROR: 'خطای شبکه. لطفا اتصال اینترنت خود را بررسی کنید', - CONNECTION_TIMEOUT: 'زمان اتصال به پایان رسید. لطفا مجددا تلاش کنید', - CONNECTION_REFUSED: 'اتصال رد شد. لطفا بعدا تلاش کنید', + NETWORK_ERROR: 'خطای شبکه، اتصال اینترنتت رو بررسی کن', + CONNECTION_TIMEOUT: 'زمان اتصال به پایان رسید، دوباره امتحان کن', + CONNECTION_REFUSED: 'اتصال برقرار نشد، بعدا امتحان کن', - FIRST_VERIFY_YOUR_ACCOUNT: 'اول باید حساب‌ کاربری رو تایید کنی!', - USERNAME_ALREADY_EXISTS: 'این نام کاربری قبلا وجود داشته است', + FIRST_VERIFY_YOUR_ACCOUNT: 'اول باید حساب کاربریت رو تایید کنی', + USERNAME_ALREADY_EXISTS: 'این نام کاربری از قبل وجود داره', INVALID_FILE_TYPE: 'نوع فایل نامعتبر است', - NOT_ENOUGH_COINS: 'ویج‌کوین‌هات کافی نیست 😕', + NOT_ENOUGH_COINS: 'ویج‌کوین‌هات کافی نیست', INVALID_REFERRAL_CODE: 'کد دعوت نامعتبر است', - ITEM_ALREADY_EXISTS: 'این رو قبلا با ویج‌کوین گرفتی — نیازی به خرید دوباره نیست.', + ITEM_ALREADY_EXISTS: 'این رو از قبل با ویج‌کوین گرفتی — نیازی به خرید دوباره نیست', - INVALID_ID: 'آیدی نامعتبر است', + INVALID_ID: 'شناسه نامعتبر است', - DATE_OUT_OF_RANGE: 'تاریخ انتخاب شده خارج از محدوده مجاز است', + DATE_OUT_OF_RANGE: 'تاریخ انتخاب شده خارج از محدوده مجازه', - ITEM_NOT_FOUND: 'آیتم مورد نظر یافت نشد', - TODO_NOT_FOUND: 'تسک مورد نظر یافت نشد', - INVALID_OTP_CODE: 'کد تایید نامعتبر است', - USE_EMAIL_FOR_OTP: 'لطفا موقتاََ از ایمیل برای دریافت کد تایید استفاده کنید', - USE_PHONE_FOR_OTP: 'لطفا موقتاََ از شماره موبایل برای دریافت کد تایید استفاده کنید', + ITEM_NOT_FOUND: 'مورد پیدا نشد', + TODO_NOT_FOUND: 'تسک پیدا نشد', + INVALID_OTP_CODE: 'کد تایید اشتباهه، دوباره امتحان کن', + USE_EMAIL_FOR_OTP: 'لطفا فعلا از ایمیل برای دریافت کد تایید استفاده کن', + USE_PHONE_FOR_OTP: 'لطفا فعلا از شماره موبایل برای دریافت کد تایید استفاده کن', - INVALID_OCCUPATION_ID: 'شغل نامعتبری انتخاب کردی!', + INVALID_OCCUPATION_ID: 'شغل نامعتبری انتخاب کردی', ONE_OR_MORE_INVALID_INTEREST_IDS: - 'یک یا چندتا از علاقه‌مندی‌هایی که انتخاب کردی نامعتبر هستن!', + 'یک یا چند تا از علاقه‌مندی‌هایی که انتخاب کردی نامعتبر هستن', - TOO_MANY_ATTEMPTS: 'بیش از حد', - OTP_EXPIRED: 'کد منقضی شده!', - INVALID_PHONE_NUMBER_FORMAT: 'فرمت شماره وارد شده نامعتبره!', - CANNOT_CHANGE_PHONE_NUMBER: 'نمیتونی شماره موبایل رو تغییر بدی!', + TOO_MANY_ATTEMPTS: 'تعداد تلاش‌ها بیش از حد مجازه', + OTP_EXPIRED: 'کد منقضی شده', + INVALID_PHONE_NUMBER_FORMAT: 'فرمت شماره وارد شده نامعتبره', + CANNOT_CHANGE_PHONE_NUMBER: 'نمی‌تونی شماره موبایل رو تغییر بدی', - SAME_PHONE_NUMBER_ERROR: 'شماره موبایل تکراریه!', - PHONE_NUMBER_ALREADY_EXISTS: 'این شماره موبایل قبلا رزرو شده!', - INVALID_VERIFICATION_CODE: 'کد تایید نامعتبره!', - CANNOT_CHANGE_EMAIL: 'نمیتونی ایمیل رو تغییر بدی!', - SAME_EMAIL_ERROR: 'ایمیل تکراریه!', - FIRST_SET_EMAIL: 'هنوز ایمیل ست نکردی!', + SAME_PHONE_NUMBER_ERROR: 'شماره موبایل تکراریه', + PHONE_NUMBER_ALREADY_EXISTS: 'این شماره موبایل از قبل ثبت شده', + INVALID_VERIFICATION_CODE: 'کد تایید نامعتبره', + CANNOT_CHANGE_EMAIL: 'نمی‌تونی ایمیل رو تغییر بدی', + SAME_EMAIL_ERROR: 'ایمیل تکراریه', + FIRST_SET_EMAIL: 'هنوز ایمیل ثبت نکردی', - PACKAGE_NOT_FOUND: 'پکیج مورد نظر یافت نشد', - PAYMENT_FAILED: 'پرداخت ناموفق بود. لطفا دوباره تلاش کنید', - PAYMENT_ALREADY_PROCESSED: 'این پرداخت قبلا پردازش شده است', - PAYMENT_NOT_FOUND: 'پرداخت مورد نظر یافت نشد', + PACKAGE_NOT_FOUND: 'بسته پیدا نشد', + PAYMENT_FAILED: 'پرداخت ناموفق بود، دوباره امتحان کن', + PAYMENT_ALREADY_PROCESSED: 'این پرداخت از قبل پردازش شده', + PAYMENT_NOT_FOUND: 'پرداخت پیدا نشد', - TRY_NEXT_TIME: 'چیزی اشتباه پیش رفت، لطفا بعدا دوباره تلاش کنید', + TRY_NEXT_TIME: 'مشکلی پیش اومد، لطفا بعدا دوباره امتحان کن', - TOO_MANY_ATTEMPTS_HABIT: 'بیش از حد نمیتونید بسازید.', + TOO_MANY_ATTEMPTS_HABIT: 'بیش از حد مجاز نمی‌تونی بسازی', - FOLDER_STRUCTURE_TOO_DEEP: 'FOLDER_STRUCTURE_TOO_DEEP', - BULK_IMPORT_LIMIT_EXCEEDED: `تعداد آیتم درون‌ریزی بیش‌از حد هست`, + FOLDER_STRUCTURE_TOO_DEEP: 'عمق ساختار پوشه‌ها بیش از حد مجازه', + BULK_IMPORT_LIMIT_EXCEEDED: `تعداد آیتم‌های درون‌ریزی بیش از حد مجازه`, NO_VALID_ITEMS_TO_IMPORT: 'هیچ آیتم معتبری برای درون‌ریزی وجود نداره', - BIRTHDATE_CANNOT_BE_CHANGED: 'به تازگی تاریخ تولدت رو عوض کردی!', + BIRTHDATE_CANNOT_BE_CHANGED: 'به تازگی تاریخ تولدت رو عوض کردی', + VIP_REQUIRED: 'این قابلیت فقط برای کاربران پرو در دسترسه', + UPLOAD_IN_PROGRESS: 'فایل در حال آپلوده، کمی صبر کن', + CUSTOM_WALLPAPER_REMOVED: 'تصویر پس‌زمینه حذف شد', + UPLOAD_FAILED: 'خطا در آپلود فایل، دوباره امتحان کن', } const validationTranslations: Record = { 'password must be longer than or equal to 8 characters': - 'رمز عبور باید حداقل 8 کاراکتر باشد', + 'رمز عبور باید حداقل ۸ کاراکتر باشه', 'password must contain at least 1 uppercase letter': - 'رمز عبور باید حداقل شامل یک حرف بزرگ باشد', + 'رمز عبور باید حداقل یک حرف بزرگ داشته باشه', 'password must contain at least 1 lowercase letter': - 'رمز عبور باید حداقل شامل یک حرف کوچک باشد', - 'password must contain at least 1 number': 'رمز عبور باید حداقل شامل یک عدد باشد', + 'رمز عبور باید حداقل یک حرف کوچک داشته باشه', + 'password must contain at least 1 number': 'رمز عبور باید حداقل یک عدد داشته باشه', 'password must contain at least 1 symbol': - 'رمز عبور باید حداقل شامل یک نماد (مانند @#$%) باشد', - 'password must be a string': 'رمز عبور باید متن باشد', - 'password should not be empty': 'رمز عبور نمی‌تواند خالی باشد', + 'رمز عبور باید حداقل یک نماد (مانند @#$%) داشته باشه', + 'password must be a string': 'رمز عبور باید متن باشه', + 'password should not be empty': 'رمز عبور نمی‌تونه خالی باشه', 'email must be an email': 'فرمت ایمیل نامعتبر است', - 'email should not be empty': 'ایمیل نمی‌تواند خالی باشد', - 'email must be a string': 'ایمیل باید متن باشد', + 'email should not be empty': 'ایمیل نمی‌تونه خالی باشه', + 'email must be a string': 'ایمیل باید متن باشه', - 'name should not be empty': 'نام کاربری نمی‌تواند خالی باشد', - 'name must be a string': 'نام کاربری باید متن باشد', + 'name should not be empty': 'نام کاربری نمی‌تونه خالی باشه', + 'name must be a string': 'نام کاربری باید متن باشه', 'name must be longer than or equal to 3 characters': - 'نام کاربری باید حداقل 3 کاراکتر باشد', + 'نام کاربری باید حداقل ۳ کاراکتر باشه', 'name must be shorter than or equal to 50 characters': - 'نام کاربری باید حداکثر 50 کاراکتر باشد', + 'نام کاربری باید حداکثر ۵۰ کاراکتر باشه', // Widget-specific validation messages - 'widget title should not be empty': 'عنوان ویجت نمی‌تواند خالی باشد', + 'widget title should not be empty': 'عنوان ویجت نمی‌تونه خالی باشه', 'widget position must be valid': 'موقعیت ویجت نامعتبر است', 'widget size must be valid': 'اندازه ویجت نامعتبر است', // Friend-related validation messages - 'username should not be empty': 'نام کاربری نمی‌تواند خالی باشد', - 'username does not exist': 'این نام کاربری وجود ندارد', - 'cannot send friend request to yourself': - 'نمی‌توانید به خودتان درخواست دوستی ارسال کنید', - 'friend request already sent': 'درخواست دوستی قبلا ارسال شده است', + 'username should not be empty': 'نام کاربری نمی‌تونه خالی باشه', + 'username does not exist': 'این نام کاربری وجود نداره', + 'cannot send friend request to yourself': 'نمی‌تونی به خودت درخواست دوستی بفرستی', + 'friend request already sent': 'درخواست دوستی قبلا ارسال شده', 'name must be longer than or equal to 2 characters': 'وارد کردن نام کاربری الزامی است', - CONTENT_CONTAINS_PROFANITY: 'محتوا شامل کلمات یا عبارات نامناسب است.', + CONTENT_CONTAINS_PROFANITY: 'محتوا شامل کلمات یا عبارات نامناسب است', } export function translateValidationMessage(message: string): string { @@ -162,7 +180,7 @@ export function translateValidationMessage(message: string): string { } export function translateError(error: any): string | Record { - const defaultMessage = 'خطایی رخ داده است. لطفا دوباره تلاش کنید' + const defaultMessage = 'خطایی رخ داده، لطفا دوباره امتحان کن' if (!error) return defaultMessage diff --git a/src/common/utils/vip-expiry.ts b/src/common/utils/vip-expiry.ts new file mode 100644 index 000000000..882e14f0e --- /dev/null +++ b/src/common/utils/vip-expiry.ts @@ -0,0 +1,40 @@ +import moment from 'jalali-moment' + +export function getVipRemainingDays(vipExpiresAt?: string | null): number { + if (!vipExpiresAt) return 0 + const target = moment(vipExpiresAt) + const now = moment() + return Math.max(0, target.diff(now, 'days')) +} + +export function formatVipRemaining(vipExpiresAt?: string | null): string { + if (!vipExpiresAt) return '' + const target = moment(vipExpiresAt) + const now = moment() + const diffDays = target.diff(now, 'days') + const diffHours = target.diff(now, 'hours') + + if (target.isBefore(now)) { + return 'منقضی‌شده' + } + + const fmt = new Intl.NumberFormat('fa-IR').format + if (diffDays > 0) { + return `${fmt(diffDays)} روز` + } + if (diffHours > 0) { + return `${fmt(diffHours)} ساعت` + } + return 'کمتر از ۱ ساعت' +} + +export function formatVipExpiryDate(vipExpiresAt?: string | null): string { + if (!vipExpiresAt) return '' + try { + const target = moment(vipExpiresAt) + if (!target.isValid()) return '' + return target.locale('fa').format('jD jMMMM jYYYY') + } catch { + return '' + } +} diff --git a/src/components/auth/auth-required-modal.tsx b/src/components/auth/auth-required-modal.tsx index 93c287663..24ee7fc6a 100644 --- a/src/components/auth/auth-required-modal.tsx +++ b/src/components/auth/auth-required-modal.tsx @@ -1,7 +1,7 @@ import { callEvent } from '@/common/utils/call-event' import { Modal } from '@/components/ui' import { Button } from '@/components/ui' -import { Icon } from '@/src/icons' +import { Icon } from '@/icons' interface AuthRequiredModalProps { isOpen: boolean @@ -16,9 +16,9 @@ export function AuthRequiredModal({ isOpen, onClose, title = 'ورود به حساب کاربری', - message = 'برای دسترسی به این بخش یا انجام این عملیات، ابتدا وارد حساب کاربری خود شوید.', + message = 'برای دسترسی به این بخش اول وارد حسابت شو', loginButtonText = 'ورود به حساب', - cancelButtonText = 'بعدا', + cancelButtonText = 'فعلا نه', }: AuthRequiredModalProps) { function triggerAccountTabDisplay() { onClose() @@ -31,29 +31,29 @@ export function AuthRequiredModal({ isOpen={isOpen} onClose={onClose} direction="rtl" - title={'نیاز ورود به حساب کاربری'} closeOnBackdropClick={true} + showCloseButton={true} + title=" " > -
-
-
- +
+
+
- {title &&

{title}

} -

{message}

-
+
+

{title}

+

+ {message} +

+
+ +
@@ -61,7 +61,8 @@ export function AuthRequiredModal({ onClick={onClose} size="md" variant="outline" - className="w-32 rounded-2xl" + className="text-xs w-28" + rounded={'2xl'} > {cancelButtonText} diff --git a/src/components/auth/require-auth.tsx b/src/components/auth/require-auth.tsx index e43ce02dd..49972b8fe 100644 --- a/src/components/auth/require-auth.tsx +++ b/src/components/auth/require-auth.tsx @@ -49,7 +49,7 @@ export const RequireAuth = ({ children, fallback, mode = 'block' }: RequireAuthP +
+ )} + +
+ {isVipUnlocked ? ( +
+ + {asset.price > 0 && ( + + )} +
+ ) : ( +
+ + +
+ )} +
+
+ + ) +} diff --git a/src/components/gallery/gallery-bookmark-icon-item.tsx b/src/components/gallery/gallery-bookmark-icon-item.tsx new file mode 100644 index 000000000..e61ca386f --- /dev/null +++ b/src/components/gallery/gallery-bookmark-icon-item.tsx @@ -0,0 +1,111 @@ +import { useState, useRef } from 'react' +import { Icon } from '@/icons' +import { VipBadge } from '@/components/ui' +import { UserCoin } from '@/components/user-coin' +import { useLazyLoad } from '@/hooks/use-lazy-load' +import type { GalleryAsset } from '@/services/hooks/gallery/get-gallery-assets.hook' + +interface GalleryBookmarkIconItemProps { + asset: GalleryAsset + isSelected: boolean + onClick: () => void +} + +export function GalleryBookmarkIconItem({ + asset, + isSelected, + onClick, +}: GalleryBookmarkIconItemProps) { + const [loaded, setLoaded] = useState(false) + const [error, setError] = useState(false) + const imgRef = useRef(null) + + const loadContent = () => { + if (imgRef.current) { + imgRef.current.src = asset.previewUrl || asset.url + } + } + + const elementRef = useLazyLoad(loadContent) + + const itemOutlineStyle = isSelected + ? 'ring-2 ring-primary ring-offset-2 ring-offset-base-100 border-primary' + : 'border-base-content/10 hover:border-primary/50 hover:bg-base-300/40' + + return ( +
+
+ {!loaded && ( +
+
+
+ )} + {error && ( +
+ +

خطا در بارگذاری

+
+ )} +
+ {asset.title { + setLoaded(true) + setError(false) + }} + onError={() => { + setLoaded(true) + setError(true) + }} + className="object-contain w-full h-full max-w-[85%] max-h-[85%] drop-shadow-sm transition-transform duration-200 group-hover:scale-108" + style={{ opacity: loaded && !error ? 1 : 0 }} + /> +
+ {loaded && !error && ( + <> + {asset.title && ( +
+ + {asset.title} + +
+ )} + {asset.price > 0 && !asset.isOwned && ( +
+ +
+ )} + + {isSelected && ( +
+ +
+ )} + {asset.accessVip && !asset.isOwned && ( +
+ +
+ )} + {asset.isOwned && !isSelected && ( +
+ + خریداری شده +
+ )} + + )} +
+ ) +} diff --git a/src/components/gallery/gallery-photo-item.tsx b/src/components/gallery/gallery-photo-item.tsx new file mode 100644 index 000000000..f68fe5c86 --- /dev/null +++ b/src/components/gallery/gallery-photo-item.tsx @@ -0,0 +1,101 @@ +import { useState, useRef } from 'react' +import { Icon } from '@/icons' +import { VipBadge } from '@/components/ui' +import { UserCoin } from '@/components/user-coin' +import { useLazyLoad } from '@/hooks/use-lazy-load' +import type { GalleryAsset } from '@/services/hooks/gallery/get-gallery-assets.hook' + +interface GalleryPhotoItemProps { + asset: GalleryAsset + isSelected: boolean + onClick: () => void +} + +export function GalleryPhotoItem({ asset, isSelected, onClick }: GalleryPhotoItemProps) { + const [loaded, setLoaded] = useState(false) + const [error, setError] = useState(false) + const imgRef = useRef(null) + + const loadContent = () => { + if (imgRef.current) { + imgRef.current.src = asset.previewUrl || asset.url + } + } + + const elementRef = useLazyLoad(loadContent) + + const itemOutlineStyle = isSelected + ? 'ring-2 ring-primary ring-offset-2 ring-offset-base-100' + : 'ring-1 ring-base-content/10 hover:ring-primary/70' + + return ( +
+ {!loaded && ( +
+
+
+ )} + + {error && ( +
+ +

خطا در بارگذاری

+
+ )} + + {asset.title { + setLoaded(true) + setError(false) + }} + onError={() => { + setLoaded(true) + setError(true) + }} + className="object-cover w-full h-auto transition-transform duration-300 group-hover:scale-103" + style={{ opacity: loaded && !error ? 1 : 0 }} + /> + + {loaded && !error && ( + <> +
+ + {asset.title || ''} + + + {asset.price > 0 && !asset.isOwned ? ( +
+ +
+ ) : null} +
+ + {isSelected && ( +
+ +
+ )} + + {asset.accessVip && !asset.isOwned && ( +
+ +
+ )} + + {asset.isOwned && !isSelected && ( +
+ + خریداری شده +
+ )} + + )} +
+ ) +} diff --git a/src/components/gallery/gallery-picker-modal.tsx b/src/components/gallery/gallery-picker-modal.tsx new file mode 100644 index 000000000..264f03af8 --- /dev/null +++ b/src/components/gallery/gallery-picker-modal.tsx @@ -0,0 +1,190 @@ +import { useState } from 'react' +import { Modal, Chip } from '@/components/ui' +import { Icon } from '@/icons' +import { playNativeToastSound, showToast } from '@/common/toast' +import { useAuth } from '@/context/auth.context' +import { callEvent } from '@/common/utils/call-event' +import { + useGetGalleryAssets, + useGetGalleryCategories, + type GalleryAsset, + type GalleryAssetType, +} from '@/services/hooks/gallery/get-gallery-assets.hook' +import { GalleryAssetPurchaseModal } from './gallery-asset-purchase-modal' +import { GalleryPhotoItem } from './gallery-photo-item' +import { GalleryBookmarkIconItem } from './gallery-bookmark-icon-item' + +interface GalleryPickerModalProps { + isOpen: boolean + onClose: () => void + type: GalleryAssetType + title?: string + onSelect: (asset: GalleryAsset) => void + selectedAssetUrl?: string +} + +export function GalleryPickerModal({ + isOpen, + onClose, + type, + title = 'گالری تصاویر', + onSelect, + selectedAssetUrl, +}: GalleryPickerModalProps) { + const [selectedCategory, setSelectedCategory] = useState('ALL') + const [purchasingAsset, setPurchasingAsset] = useState(null) + + const { user, isVip } = useAuth() + const { data: categories = [] } = useGetGalleryCategories(type, isOpen) + + const { data, isLoading } = useGetGalleryAssets( + { + type, + limit: 100, + category: selectedCategory === 'ALL' ? undefined : selectedCategory, + }, + isOpen + ) + + const assets = data?.data?.assets ?? [] + + const handleItemClick = (asset: GalleryAsset) => { + if (asset.isOwned || asset.price === 0) { + onSelect(asset) + onClose() + playNativeToastSound('success') + return + } + + if (!user) { + callEvent('openProfile') + showToast('برای استفاده از این مورد اول وارد حسابت شو', 'error') + return + } + + if (asset.accessVip && !isVip && asset.price === 0) { + callEvent('openSettings', 'vip') + return + } + + setPurchasingAsset(asset) + } + + const handlePurchaseSuccess = (asset: GalleryAsset) => { + setPurchasingAsset(null) + onSelect({ ...asset, isOwned: true, isUnlocked: true }) + onClose() + } + + const handleSelectDirectly = (asset: GalleryAsset) => { + setPurchasingAsset(null) + onSelect(asset) + onClose() + } + + return ( + <> + +
+ {categories.length > 0 && ( +
+ setSelectedCategory('ALL')} + className="text-xs" + > + همه + + {categories.map((cat) => ( + setSelectedCategory(cat)} + className="text-xs" + > + {cat} + + ))} +
+ )} + +
+ {isLoading ? ( + type === 'BOOKMARK_ICON' || type === 'AVATAR' ? ( +
+ {[...Array(18)].map((_, idx) => ( +
+ ))} +
+ ) : ( +
+ {[...Array(8)].map((_, idx) => ( +
+ ))} +
+ ) + ) : assets.length === 0 ? ( +
+ +

هیچ تصویری پیدا نشد

+
+ ) : type === 'BOOKMARK_ICON' || type === 'AVATAR' ? ( +
+ {assets.map((asset) => ( + handleItemClick(asset)} + /> + ))} +
+ ) : ( +
+ {assets.map((asset) => ( + handleItemClick(asset)} + /> + ))} +
+ )} +
+
+ + + setPurchasingAsset(null)} + asset={purchasingAsset} + userCoins={user?.coins ?? 0} + isVip={Boolean(isVip)} + onPurchaseSuccess={handlePurchaseSuccess} + onSelectDirectly={handleSelectDirectly} + /> + + ) +} diff --git a/src/components/gallery/index.ts b/src/components/gallery/index.ts new file mode 100644 index 000000000..7f5760e43 --- /dev/null +++ b/src/components/gallery/index.ts @@ -0,0 +1,4 @@ +export * from './gallery-picker-modal' +export * from './gallery-asset-purchase-modal' +export * from './gallery-photo-item' +export * from './gallery-bookmark-icon-item' diff --git a/src/components/text-input.tsx b/src/components/text-input.tsx deleted file mode 100644 index b71e3848f..000000000 --- a/src/components/text-input.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import { useCallback, useEffect, useRef, memo } from 'react' - -enum TextInputSize { - XS = 'xs', - SM = 'sm', - MD = 'md', - LG = 'lg', - XL = 'xl', -} - -interface TextInputProps { - id?: string - value?: string - defaultValue?: string - onChange: (value: string) => void - placeholder?: string - onFocus?: () => void - onKeyDown?: (e: React.KeyboardEvent) => void - disabled?: boolean - name?: string - type?: string - direction?: 'rtl' | 'ltr' | 'auto' | '' - className?: string - ref?: React.RefObject - debounce?: boolean - debounceTime?: number - maxLength?: number - size?: TextInputSize - min?: number - max?: number - autoComplete?: 'on' | 'off' -} - -const sizes: Record = { - [TextInputSize.XS]: 'input-xs', - [TextInputSize.SM]: 'input-sm', - [TextInputSize.MD]: 'input-md', - [TextInputSize.LG]: 'input-lg', - [TextInputSize.XL]: 'input-xl', -} - -/** - * @deprecated Use `import { TextInput } from '@/components/ui'`. - * - * The replacement is source-compatible (no call site passes `size`, and the - * unexported `TextInputSize` enum is now a string union), so migration is a - * pure import swap — done in batches so each screen gets looked at. - */ -export const TextInput = memo(function TextInput({ - onChange, - value, - defaultValue, - placeholder, - onFocus, - onKeyDown, - disabled = false, - name, - id, - type = 'text', - direction = '', - className = '', - ref, - debounce = false, - debounceTime = 150, - maxLength = 1000, - size = TextInputSize.MD, - min, - max, - autoComplete = 'off', -}: TextInputProps) { - const debounceTimerRef = useRef(null) - const isControlled = value !== undefined - - const handleChange = useCallback( - (e: React.ChangeEvent) => { - const newValue = e.target.value - - if (!debounce) { - onChange(newValue) - return - } - - if (debounceTimerRef.current) { - clearTimeout(debounceTimerRef.current) - } - - debounceTimerRef.current = setTimeout( - () => { - onChange(newValue) - }, - type === 'color' ? 50 : debounceTime - ) - }, - [onChange, debounce, type, debounceTime] - ) - - useEffect(() => { - return () => { - if (debounceTimerRef.current) { - clearTimeout(debounceTimerRef.current) - } - } - }, []) - - const inputProps = { - ref, - id, - type, - name, - disabled, - onFocus, - onKeyDown, - dir: direction, - placeholder: placeholder || '', - className: `input bg-base-200 w-full ring-0! text-[14px] ${sizes[size]} rounded-xl !outline-none transition-all duration-200 focus:ring-1 focus:ring-primary/20 focus:border-primary font-light focus:placeholder:text-base-content/20 ${className}`, - onChange: handleChange, - maxLength, - autoComplete, - min, - max, - } - - if (isControlled) { - return - } - - return -}) - -TextInput.displayName = 'TextInput' diff --git a/src/components/ui/avatar/avatar.tsx b/src/components/ui/avatar/avatar.tsx index 056366f36..afecac4ee 100644 --- a/src/components/ui/avatar/avatar.tsx +++ b/src/components/ui/avatar/avatar.tsx @@ -9,6 +9,7 @@ export interface AvatarProps extends VariantProps { placeholder?: string className?: string onClick?: () => void + isPro?: boolean } export function AvatarComponent({ @@ -18,6 +19,7 @@ export function AvatarComponent({ size, className, onClick, + isPro, }: AvatarProps) { const [imageError, setImageError] = useState(false) const [previewUrl, setPreviewUrl] = useState(null) @@ -48,7 +50,7 @@ export function AvatarComponent({ const shouldShowImage = displayUrl && !imageError return ( -
+
{shouldShowImage ? ( = ({ + name, + src, + rotate, + scale = 1, + glowColor = 'rgba(255, 255, 255, 0.4)', +}) => { + return ( + +
+ {name} +
+
+ ) +} diff --git a/src/components/ui/badge/vip-badge.tsx b/src/components/ui/badge/vip-badge.tsx new file mode 100644 index 000000000..357bdb20b --- /dev/null +++ b/src/components/ui/badge/vip-badge.tsx @@ -0,0 +1,58 @@ +import type React from 'react' +import { Icon } from '@/icons' +import { cn } from '@/common/utils/cn' +import { vipBadgeVariants, type VipBadgeVariantProps } from './vip-badge.variants' + +const ICON_SIZES: Record, number> = { + xs: 8, + sm: 10, + md: 12, + lg: 14, +} + +const ICON_ONLY_SIZES: Record, string> = { + xs: 'w-3.5 h-3.5 p-0', + sm: 'w-4.5 h-4.5 p-0', + md: 'w-5.5 h-5.5 p-0', + lg: 'w-6.5 h-6.5 p-0', +} + +export interface VipBadgeProps + extends React.HTMLAttributes, + VipBadgeVariantProps { + iconOnly?: boolean + text?: string +} + +export function VipBadge({ + size = 'sm', + variant = 'indigo', + rounded = 'full', + iconOnly = false, + text = 'پرو', + className, + ...props +}: VipBadgeProps) { + const currentSize = size || 'sm' + const iconSize = ICON_SIZES[currentSize] || 10 + + return ( + + + {!iconOnly && {text}} + + ) +} + +export const ProBadge = VipBadge +export type ProBadgeProps = VipBadgeProps diff --git a/src/components/ui/badge/vip-badge.variants.ts b/src/components/ui/badge/vip-badge.variants.ts new file mode 100644 index 000000000..ce77374d2 --- /dev/null +++ b/src/components/ui/badge/vip-badge.variants.ts @@ -0,0 +1,37 @@ +import { cva, type VariantProps } from 'class-variance-authority' + +export const vipBadgeVariants = cva( + 'inline-flex items-center justify-center font-bold select-none shrink-0 transition-transform hover:scale-105 active:scale-95 cursor-default', + { + variants: { + variant: { + indigo: 'bg-vip text-vip-content shadow-sm shadow-vip/25', + 'indigo-subtle': 'bg-vip/15 text-vip border border-vip/20', + white: 'bg-white/20 text-white backdrop-blur-md border border-white/30 shadow-sm', + }, + size: { + xs: 'text-[9px] px-2 py-0.5 gap-1', + sm: 'text-[10px] px-2 py-1 gap-1', + md: 'text-xs px-2.5 py-1 gap-1.5', + lg: 'text-sm px-3 py-1.5 gap-1.5', + }, + rounded: { + sm: 'rounded-sm', + md: 'rounded-md', + lg: 'rounded-lg', + xl: 'rounded-xl', + full: 'rounded-full', + }, + }, + defaultVariants: { + variant: 'indigo', + size: 'sm', + rounded: 'full', + }, + } +) + +export type VipBadgeVariantProps = VariantProps + +export const proBadgeVariants = vipBadgeVariants +export type ProBadgeVariantProps = VipBadgeVariantProps diff --git a/src/components/ui/bottom-sheet/bottom-sheet.tsx b/src/components/ui/bottom-sheet/bottom-sheet.tsx index 066f6018f..08b21f59f 100644 --- a/src/components/ui/bottom-sheet/bottom-sheet.tsx +++ b/src/components/ui/bottom-sheet/bottom-sheet.tsx @@ -1,7 +1,7 @@ import { useState, type ReactNode } from 'react' import { Presence, Motion as motion } from '@/common/motion' import { Portal } from '../portal/portal' -import { Icon } from '@/src/icons' +import { Icon } from '@/icons' type SheetSize = 'small' | 'medium' | 'large' | 'full' | 'screen' @@ -13,7 +13,6 @@ interface BottomSheetProps { showBack?: boolean onClickBack?: () => void children: ReactNode - showCloseButton?: boolean closeOnBackdrop?: boolean dragThreshold?: number } @@ -24,7 +23,6 @@ export function BottomSheet({ size = 'medium', title, children, - showCloseButton = true, closeOnBackdrop = true, dragThreshold = 100, onClickBack, @@ -52,86 +50,87 @@ export function BottomSheet({ {isOpen && ( - <> - + + )} + + {isOpen && ( + setIsDragging(true)} + onDragEnd={handleDragEnd} + dragMomentum={false} + > +
+ +
+ + {title && ( +
+ {showBack && ( + + )} +

{title}

+
+ )} - setIsDragging(true)} - onDragEnd={handleDragEnd} - dragMomentum={false} > -
- -
- - {title && ( -
- {showBack && ( - - )} -

- {title} -

-
- )} - -
- {children} -
-
- + {children} +
+ )} diff --git a/src/components/ui/button/button.tsx b/src/components/ui/button/button.tsx index 8143cd734..201101915 100644 --- a/src/components/ui/button/button.tsx +++ b/src/components/ui/button/button.tsx @@ -1,6 +1,6 @@ import type React from 'react' import { cn } from '@/common/utils/cn' -import { Icon } from '@/src/icons' +import { Icon } from '@/icons' import { type ButtonVariantProps, buttonVariants } from './button.variants' export interface ButtonProps @@ -9,17 +9,15 @@ export interface ButtonProps loading?: boolean loadingText?: React.ReactNode icon?: React.ReactNode - /** @deprecated Use `variant="primary"` instead. */ - isPrimary?: boolean } export function Button({ className, variant, + color, size, rounded, fullWidth, - isPrimary, loading, loadingText, icon, @@ -27,13 +25,11 @@ export function Button({ children, ...rest }: ButtonProps) { - const resolvedVariant = variant ?? (isPrimary ? 'primary' : 'default') - return ( diff --git a/src/components/ui/chip/chip.variants.ts b/src/components/ui/chip/chip.variants.ts index ba02b2d3a..13a24a93d 100644 --- a/src/components/ui/chip/chip.variants.ts +++ b/src/components/ui/chip/chip.variants.ts @@ -11,6 +11,8 @@ export const chipVariants = cva( 'border-2', 'transition-all', 'active:scale-95', + 'disabled:cursor-not-allowed', + 'disabled:active:scale-none!', ], { variants: { @@ -18,9 +20,11 @@ export const chipVariants = cva( true: ['bg-primary', 'border-primary', 'text-white'], false: [ 'bg-base-100', + 'bg-glass', 'border-base-300/30', 'text-base-content/80', - 'hover:border-primary/30', + 'enabled:hover:border-primary/30', + 'disabled:opacity-80', ], }, }, diff --git a/src/components/ui/color-picker/color-picker.tsx b/src/components/ui/color-picker/color-picker.tsx index efa624637..a561e26c5 100644 --- a/src/components/ui/color-picker/color-picker.tsx +++ b/src/components/ui/color-picker/color-picker.tsx @@ -1,88 +1,128 @@ +import type React from 'react' import { useEffect, useRef, useState } from 'react' -import { RgbaStringColorPicker } from 'react-colorful' -import { createPortal } from 'react-dom' +import { HexColorPicker, RgbaStringColorPicker } from 'react-colorful' +import { Portal } from '../portal/portal' -interface PopoverColorPickerProps { +export interface ColorPickerProps { color: string onChange: (color: string) => void + className?: string + mode?: 'hex' | 'rgba' } -export const ColorPicker: React.FC = ({ color, onChange }) => { +export const ColorPicker: React.FC = ({ + color, + onChange, + className = '', + mode = 'hex', +}) => { + const [isOpen, setIsOpen] = useState(false) const triggerRef = useRef(null) - const popoverRef = useRef(null) - const [isOpen, setIsOpen] = useState(false) - const [position, setPosition] = useState({ top: 0, left: 0 }) + const popupRef = useRef(null) + const [coords, setCoords] = useState<{ top: number; left: number }>({ + top: 0, + left: 0, + }) - useEffect(() => { - if (isOpen && triggerRef.current) { - const rect = triggerRef.current.getBoundingClientRect() - setPosition({ - top: rect.bottom + window.scrollY, - left: rect.right - 20 + window.scrollX, - }) + const displayColor = color || '#000000' + + const updatePosition = () => { + if (!triggerRef.current) return + const rect = triggerRef.current.getBoundingClientRect() + const popupWidth = 220 + const popupHeight = 220 + + let left = rect.left + if (left + popupWidth > window.innerWidth - 16) { + left = window.innerWidth - popupWidth - 16 } - }, [isOpen]) + if (left < 16) { + left = 16 + } + + let top = rect.bottom + 8 + if (top + popupHeight > window.innerHeight - 16) { + top = rect.top - popupHeight - 8 + } + + setCoords({ top, left }) + } useEffect(() => { + if (!isOpen) return + + updatePosition() + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Node if ( - popoverRef.current && - !popoverRef.current.contains(event.target as Node) && triggerRef.current && - !triggerRef.current.contains(event.target as Node) + !triggerRef.current.contains(target) && + popupRef.current && + !popupRef.current.contains(target) ) { setIsOpen(false) } } - const handleScroll = () => { - if (triggerRef.current && isOpen) { - const rect = triggerRef.current.getBoundingClientRect() - setPosition({ - top: rect.bottom + window.scrollY, - left: rect.right - 200 + window.scrollX, - }) + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setIsOpen(false) } } - document.addEventListener('mousedown', handleClickOutside) - window.addEventListener('scroll', handleScroll) - window.addEventListener('resize', handleScroll) + const handleScroll = () => { + updatePosition() + } + + const timer = setTimeout(() => { + document.addEventListener('mousedown', handleClickOutside) + document.addEventListener('keydown', handleKeyDown) + window.addEventListener('scroll', handleScroll, true) + window.addEventListener('resize', handleScroll) + }, 0) return () => { + clearTimeout(timer) document.removeEventListener('mousedown', handleClickOutside) - window.removeEventListener('scroll', handleScroll) + document.removeEventListener('keydown', handleKeyDown) + window.removeEventListener('scroll', handleScroll, true) window.removeEventListener('resize', handleScroll) } }, [isOpen]) - const displayColor = color || '#000000' - return ( - <> +
setIsOpen((prev) => !prev)} + className="w-8 h-8 p-1 transition-transform border-0 shadow-xs cursor-pointer rounded-xl hover:scale-105 active:scale-95" style={{ backgroundColor: displayColor }} - onClick={() => setIsOpen(!isOpen)} /> - {isOpen && - createPortal( + {isOpen && ( +
- -
, - document.body - )} - + {mode === 'hex' ? ( + + ) : ( + + )} +
+ + )} +
) } diff --git a/src/components/ui/date-picker/date-picker.tsx b/src/components/ui/date-picker/date-picker.tsx index f3cb33e56..b9bc57897 100644 --- a/src/components/ui/date-picker/date-picker.tsx +++ b/src/components/ui/date-picker/date-picker.tsx @@ -1,5 +1,5 @@ import { twMerge } from 'tailwind-merge' -import { Icon } from '@/src/icons' +import { Icon } from '@/icons' import jalaliMoment from 'jalali-moment' import { useState } from 'react' @@ -117,7 +117,7 @@ export function DatePicker({ const getDayTextStyle = () => { if (isDaySelected) { - return 'bg-primary text-white font-medium' + return 'bg-primary text-primary-content font-medium' } if (isFriday) { diff --git a/src/components/ui/dropdown/dropdown-item.tsx b/src/components/ui/dropdown/dropdown-item.tsx new file mode 100644 index 000000000..c3cbd2129 --- /dev/null +++ b/src/components/ui/dropdown/dropdown-item.tsx @@ -0,0 +1,53 @@ +import { cn } from '@/common/utils/cn' +import { + dropdownItemIconVariants, + dropdownItemVariants, + type DropdownItemVariantProps, +} from './dropdown-item.variants' + +export interface DropdownItemProps extends DropdownItemVariantProps { + icon?: React.ReactNode + label: React.ReactNode + badge?: React.ReactNode + onClick?: () => void + disabled?: boolean + className?: string +} + +export function DropdownItem({ + icon, + label, + badge, + onClick, + variant, + disabled = false, + className, +}: DropdownItemProps) { + return ( + + ) +} + +export function DropdownDivider() { + return
+} diff --git a/src/components/ui/dropdown/dropdown-item.variants.ts b/src/components/ui/dropdown/dropdown-item.variants.ts new file mode 100644 index 000000000..cad510838 --- /dev/null +++ b/src/components/ui/dropdown/dropdown-item.variants.ts @@ -0,0 +1,50 @@ +import { cva, type VariantProps } from 'class-variance-authority' + +export const dropdownItemVariants = cva( + [ + 'flex', + 'items-center', + 'justify-between', + 'w-full', + 'px-3.5', + 'py-2', + 'text-xs', + 'text-right', + 'transition-colors', + 'cursor-pointer', + 'group', + 'rounded-xl', + 'select-none', + 'disabled:opacity-50', + 'disabled:pointer-events-none', + 'disabled:cursor-not-allowed', + ], + { + variants: { + variant: { + default: + 'text-content hover:bg-primary/10 hover:text-primary! active:bg-primary/20!', + danger: 'text-error hover:bg-error/10 active:bg-error/20', + primary: 'text-primary bg-primary/10 hover:bg-primary/20', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +) + +export const dropdownItemIconVariants = cva(['transition-colors', 'shrink-0'], { + variants: { + variant: { + default: 'text-muted group-hover:text-primary!', + danger: 'text-error', + primary: 'text-primary', + }, + }, + defaultVariants: { + variant: 'default', + }, +}) + +export type DropdownItemVariantProps = VariantProps diff --git a/src/components/ui/dropdown/dropdown.tsx b/src/components/ui/dropdown/dropdown.tsx index 99f7f7333..cc7633d11 100644 --- a/src/components/ui/dropdown/dropdown.tsx +++ b/src/components/ui/dropdown/dropdown.tsx @@ -3,6 +3,7 @@ import type { ReactNode } from 'react' import { Portal } from '../portal/portal' import { useDropdown } from './use-dropdown' import { useState, useLayoutEffect, useRef } from 'react' +import { Motion, Presence } from '@/common/motion' export interface DropdownOption { id: string @@ -57,13 +58,16 @@ export function Dropdown({ } } + const onCloseRef = useRef(onClose) + onCloseRef.current = onClose + useLayoutEffect(() => { if (!isOpen) { setIsReady(false) positionCalculatedRef.current = false - onClose?.() + onCloseRef.current?.() } - }, [isOpen, onClose]) + }, [isOpen]) useLayoutEffect(() => { if (!isOpen || !dropdownRef.current || !dropdownContentRef.current) { @@ -184,13 +188,7 @@ export function Dropdown({ key={option.id} onClick={() => handleOptionClick(option)} disabled={option.disabled} - className={` - w-full text-left px-3 py-2 text-sm transition-colors - hover:bg-primary/10 hover:text-primary - disabled:opacity-50 disabled:cursor-not-allowed - disabled:hover:bg-transparent disabled:hover:text-muted - focus:outline-none focus:bg-primary/10 focus:text-primary - `} + className="w-full px-3 py-2 text-sm text-left transition-colors hover:bg-primary/10 hover:text-primary disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-transparent disabled:hover:text-muted focus:outline-none focus:bg-primary/10 focus:text-primary" > {option.label} @@ -207,12 +205,24 @@ export function Dropdown({ {trigger}
- {isOpen && !disabled && ( - -
-
+ {isOpen && !disabled && ( + + -
+
{dropdownContent}
-
-
- - )} + + + )} +
) } diff --git a/src/components/ui/dropdown/use-dropdown.tsx b/src/components/ui/dropdown/use-dropdown.tsx index 985277241..6299e91b6 100644 --- a/src/components/ui/dropdown/use-dropdown.tsx +++ b/src/components/ui/dropdown/use-dropdown.tsx @@ -1,9 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { listenEvent } from '@/common/utils/call-event' -/** - * Hook for managing dropdown state with click outside functionality - */ export const useDropdown = () => { const [isOpen, setIsOpen] = useState(false) const dropdownRef = useRef(null) @@ -14,6 +11,8 @@ export const useDropdown = () => { const open = useCallback(() => setIsOpen(true), []) useEffect(() => { + if (!isOpen) return + const handleClickOutside = (event: MouseEvent) => { const target = event.target as Node @@ -33,40 +32,15 @@ export const useDropdown = () => { } } - const handleScroll = (event: Event) => { - if ( - event.target && - dropdownContentRef.current && - (dropdownContentRef.current.contains(event.target as Node) || - dropdownContentRef.current === event.target) - ) { - return - } - - close() - } + const timeoutId = setTimeout(() => { + document.addEventListener('mousedown', handleClickOutside) + document.addEventListener('keydown', handleEscape) + }, 0) - const handleResize = () => { - if (isOpen) { - close() - } - } - - if (isOpen) { - const timeoutId = setTimeout(() => { - document.addEventListener('mousedown', handleClickOutside) - document.addEventListener('keydown', handleEscape) - window.addEventListener('scroll', handleScroll, true) - window.addEventListener('resize', handleResize) - }, 100) - - return () => { - clearTimeout(timeoutId) - document.removeEventListener('mousedown', handleClickOutside) - document.removeEventListener('keydown', handleEscape) - window.removeEventListener('scroll', handleScroll, true) - window.removeEventListener('resize', handleResize) - } + return () => { + clearTimeout(timeoutId) + document.removeEventListener('mousedown', handleClickOutside) + document.removeEventListener('keydown', handleEscape) } }, [isOpen, close]) @@ -78,7 +52,7 @@ export const useDropdown = () => { return () => { ev() } - }, []) + }, [close]) return { isOpen, diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index 66863a124..ddeff1c10 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -2,6 +2,9 @@ export * from './avatar/avatar' export * from './avatar/avatar.variants' export * from './badge/badge' +export * from './badge/vip-badge' +export * from './badge/vip-badge.variants' +export * from './badge/floating-badge' export * from './bottom-sheet/bottom-sheet' @@ -21,6 +24,8 @@ export * from './context-menu/context-menu' export * from './date-picker/date-picker' export * from './dropdown/dropdown' +export * from './dropdown/dropdown-item' +export * from './dropdown/dropdown-item.variants' export * from './filter-tooltip/filter-tooltip' @@ -40,6 +45,8 @@ export * from './offline-indicator/offline-indicator' export * from './pagination/pagination' +export * from './popover-menu' + export * from './portal/portal' export * from './section-panel/section-panel' diff --git a/src/components/ui/input/input.tsx b/src/components/ui/input/input.tsx index 3c8bc0f5d..fa5f4c497 100644 --- a/src/components/ui/input/input.tsx +++ b/src/components/ui/input/input.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useEffect, useRef } from 'react' +import { memo, useCallback, useEffect, useRef, useState } from 'react' import { cn } from '@/common/utils/cn' import { type TextInputSize, textInputVariants } from './input.variants' @@ -9,6 +9,7 @@ export interface TextInputProps { onChange: (value: string) => void placeholder?: string onFocus?: () => void + onBlur?: (e: React.FocusEvent) => void onKeyDown?: (e: React.KeyboardEvent) => void disabled?: boolean name?: string @@ -32,6 +33,7 @@ export const TextInput = memo(function TextInput({ defaultValue, placeholder, onFocus, + onBlur, onKeyDown, disabled = false, name, @@ -51,10 +53,18 @@ export const TextInput = memo(function TextInput({ }: TextInputProps) { const debounceTimerRef = useRef(null) const isControlled = value !== undefined + const [internalValue, setInternalValue] = useState(value ?? defaultValue ?? '') + + useEffect(() => { + if (value !== undefined) { + setInternalValue(value) + } + }, [value]) const handleChange = useCallback( (e: React.ChangeEvent) => { const newValue = e.target.value + setInternalValue(newValue) if (!debounce) { onChange(newValue) @@ -68,6 +78,7 @@ export const TextInput = memo(function TextInput({ debounceTimerRef.current = setTimeout( () => { onChange(newValue) + debounceTimerRef.current = null }, type === 'color' ? 50 : debounceTime ) @@ -75,6 +86,18 @@ export const TextInput = memo(function TextInput({ [onChange, debounce, type, debounceTime] ) + const handleBlur = useCallback( + (e: React.FocusEvent) => { + if (debounce && debounceTimerRef.current) { + clearTimeout(debounceTimerRef.current) + debounceTimerRef.current = null + onChange(internalValue) + } + onBlur?.(e) + }, + [debounce, internalValue, onBlur, onChange] + ) + useEffect(() => { return () => { if (debounceTimerRef.current) { @@ -90,6 +113,7 @@ export const TextInput = memo(function TextInput({ name, disabled, onFocus, + onBlur: handleBlur, onKeyDown, dir: direction, placeholder: placeholder || '', @@ -102,7 +126,7 @@ export const TextInput = memo(function TextInput({ } if (isControlled) { - return + return } return diff --git a/src/components/ui/item-selector/item-selector.tsx b/src/components/ui/item-selector/item-selector.tsx index 6621e0468..3a95f04b3 100644 --- a/src/components/ui/item-selector/item-selector.tsx +++ b/src/components/ui/item-selector/item-selector.tsx @@ -26,10 +26,11 @@ export function ItemSelector({ } return ( -
{isActive && ( )}
- {label} + {label}
{description && ( -
{description}
+
{description}
)} -
+ ) } diff --git a/src/components/ui/loading/loading.tsx b/src/components/ui/loading/loading.tsx index 8d018d66b..224f389c2 100644 --- a/src/components/ui/loading/loading.tsx +++ b/src/components/ui/loading/loading.tsx @@ -1,5 +1,5 @@ import { cn } from '@/common/utils/cn' -import { Icon } from '@/src/icons' +import { Icon } from '@/icons' import { Tooltip } from '../tooltip/tooltip' interface IconLoadingProps { diff --git a/src/components/ui/modal/confirmation-modal.tsx b/src/components/ui/modal/confirmation-modal.tsx index e604343c8..62b197359 100644 --- a/src/components/ui/modal/confirmation-modal.tsx +++ b/src/components/ui/modal/confirmation-modal.tsx @@ -2,12 +2,9 @@ import type { ReactNode } from 'react' import { Button } from '../button/button' import { Modal } from './modal' import { IconLoading } from '../loading/loading' -import { Icon } from '@/src/icons' +import { Icon } from '@/icons' import { cn } from '@/common/utils/cn' -import { - confirmationConfirmButtonVariants, - confirmationIconVariants, -} from './confirmation-modal.variants' +import { confirmationIconVariants } from './confirmation-modal.variants' interface ConfirmationModalProps { isOpen: boolean @@ -23,6 +20,13 @@ interface ConfirmationModalProps { direction?: 'rtl' | 'ltr' } +const confirmButtonColor = { + danger: 'danger', + warning: 'warning', + info: 'info', + primary: 'primary', +} as const + const variantIcon = { danger: , warning: , @@ -93,7 +97,8 @@ export function ConfirmationModal({ onClick={handleCancel} size="md" disabled={isLoading} - className="bg-transparent border-none text-muted hover:text-content hover:bg-base-300/40 rounded-2xl" + variant="ghost" + rounded="2xl" > {cancelText} @@ -104,14 +109,13 @@ export function ConfirmationModal({ loading={isLoading} loadingText={
- + در حال انجام...
} - className={cn( - confirmationConfirmButtonVariants({ variant }), - 'w-fit px-8 border-none rounded-2xl' - )} + color={confirmButtonColor[variant]} + rounded="2xl" + className="w-fit px-8" > {confirmText} diff --git a/src/components/ui/modal/confirmation-modal.variants.ts b/src/components/ui/modal/confirmation-modal.variants.ts index d473dc7f7..9d50596e2 100644 --- a/src/components/ui/modal/confirmation-modal.variants.ts +++ b/src/components/ui/modal/confirmation-modal.variants.ts @@ -27,16 +27,4 @@ export const confirmationIconVariants = cva( } ) -export const confirmationConfirmButtonVariants = cva([], { - variants: { - variant: { - danger: ['bg-error', 'hover:bg-error/90!', 'text-error-content'], - warning: ['bg-warning', 'hover:bg-warning/90!', 'text-warning-content'], - info: ['bg-info/80', 'hover:bg-info/90!', 'text-info-content'], - primary: ['bg-primary/80', 'hover:bg-primary/90!', 'text-white'], - }, - }, - defaultVariants: { variant: 'danger' }, -}) - export type ConfirmationVariant = typeof confirmationAccentBarVariants diff --git a/src/components/ui/modal/modal.tsx b/src/components/ui/modal/modal.tsx index 8c9563ed9..fd6309cc6 100644 --- a/src/components/ui/modal/modal.tsx +++ b/src/components/ui/modal/modal.tsx @@ -1,9 +1,17 @@ import type { VariantProps } from 'class-variance-authority' -import React, { type ReactNode, useEffect, useRef } from 'react' +import React, { type ReactNode, useEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { cn } from '@/common/utils/cn' -import { Icon } from '@/src/icons' -import { modalBoxVariants, modalScrollVariants } from './modal.variants' +import { useGeneralSetting } from '@/context/general-setting.context' +import { Icon } from '@/icons' +import { EXIT_ANIMATION_MS, useDelayedUnmount } from '@/hooks/use-delayed-unmount' +import { + modalBoxVariants, + modalDialogVariants, + modalScrollVariants, +} from './modal.variants' + +export const MODAL_EXIT_MS = EXIT_ANIMATION_MS export type ModalProps = VariantProps & { isOpen: boolean @@ -14,8 +22,12 @@ export type ModalProps = VariantProps & { closeOnBackdropClick?: boolean showCloseButton?: boolean className?: string + zIndex?: number } +let globalModalCounter = 0 +const BASE_MODAL_Z_INDEX = 1000 + export function Modal({ isOpen, onClose, @@ -26,69 +38,80 @@ export function Modal({ direction = 'ltr', showCloseButton = true, className, + zIndex: customZIndex, }: ModalProps) { - const modalRef = useRef(null) - - // Lock body scroll when modal is open - useEffect(() => { + const dialogRef = useRef(null) + const [assignedZIndex, setAssignedZIndex] = useState(() => { if (isOpen) { - document.documentElement.classList.add('modal-isActive') - document.body.style.overflow = 'hidden' - } else { - document.documentElement.classList.remove('modal-isActive') - document.body.style.overflow = '' - } - return () => { - document.documentElement.classList.remove('modal-isActive') - document.body.style.overflow = '' + globalModalCounter += 1 + return customZIndex !== undefined + ? customZIndex + : BASE_MODAL_Z_INDEX + globalModalCounter * 20 } - }, [isOpen]) + return customZIndex !== undefined ? customZIndex : BASE_MODAL_Z_INDEX + }) + const { isOptimalMode } = useGeneralSetting() + + const modalDurationMs = isOptimalMode ? 0 : MODAL_EXIT_MS - // Handle keyboard events useEffect(() => { - if (!isOpen) return + const dialog = dialogRef.current + if (!dialog) return - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - e.preventDefault() - onClose() - } + if (isOpen) { + globalModalCounter += 1 + const computedZ = + customZIndex !== undefined + ? customZIndex + : BASE_MODAL_Z_INDEX + globalModalCounter * 20 + setAssignedZIndex(computedZ) + dialog.setAttribute('open', '') + } else { + dialog.removeAttribute('open') } + }, [isOpen, customZIndex]) - document.addEventListener('keydown', handleKeyDown) - return () => document.removeEventListener('keydown', handleKeyDown) - }, [isOpen, onClose]) - - // Focus management useEffect(() => { - if (isOpen && modalRef.current) { - const focusableElements = modalRef.current.querySelectorAll( - 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' - ) - const firstElement = focusableElements[0] as HTMLElement - firstElement?.focus() + const dialog = dialogRef.current + if (!dialog) return + const handleCancel = (e: Event) => { + if (e.target !== dialog) return + e.preventDefault() // keep it mounted so the exit animation can play + onClose() } - }, [isOpen]) + dialog.addEventListener('cancel', handleCancel) + return () => dialog.removeEventListener('cancel', handleCancel) + }, [onClose]) const modalBoxClasses = cn(modalBoxVariants({ size }), className) - - if (!isOpen) return null + const shouldRenderContent = useDelayedUnmount(isOpen, MODAL_EXIT_MS) return createPortal( closeOnBackdropClick && onClose()} - className="flex items-center justify-center p-2 transition-opacity duration-200 opacity-100 modal modal-middle md:p-4" + onClick={(e) => { + if (closeOnBackdropClick && e.target === dialogRef.current) onClose() + }} + onContextMenu={(e) => e.stopPropagation()} + className={cn('flex items-center justify-center', modalDialogVariants())} + style={ + { + '--modal-duration': `${modalDurationMs}ms`, + zIndex: + assignedZIndex ?? + (customZIndex !== undefined ? customZIndex : BASE_MODAL_Z_INDEX), + } as React.CSSProperties + } >
e.stopPropagation()} - className={cn(modalBoxClasses, 'animate-modal-in')} + onContextMenu={(e) => e.stopPropagation()} + className={modalBoxClasses} > - {(title || showCloseButton) && ( + {shouldRenderContent && (title || showCloseButton) && (
{title && (

@@ -115,7 +138,9 @@ export function Modal({ )}

)} -
{children}
+
+ {shouldRenderContent && children} +
, document.body diff --git a/src/components/ui/modal/modal.variants.ts b/src/components/ui/modal/modal.variants.ts index f45d5d6ed..ff6917b0c 100644 --- a/src/components/ui/modal/modal.variants.ts +++ b/src/components/ui/modal/modal.variants.ts @@ -4,12 +4,12 @@ export const modalBoxVariants = cva( [ 'modal-box', 'overflow-hidden', + 'max-h-[calc(100dvh-1rem)]', + 'md:max-h-[calc(100dvh-2rem)]', 'rounded-widget', 'p-3', 'md:p-4', 'elevation-lg', - 'transition-all', - 'duration-200', ], { variants: { @@ -18,6 +18,7 @@ export const modalBoxVariants = cva( md: ['w-[calc(100vw-2rem)]', 'max-w-md', 'min-h-[200px]'], lg: ['w-[calc(100vw-2rem)]', 'max-w-lg', 'min-h-[240px]'], xl: ['w-[calc(100vw-2rem)]', 'max-w-4xl', 'min-h-[280px]'], + '2xl': ['w-[calc(100vw-2rem)]', 'max-w-5xl', 'min-h-[320px]'], full: [ 'w-[calc(100vw-1rem)]', 'max-w-5xl', @@ -35,19 +36,31 @@ export const modalBoxVariants = cva( export type ModalBoxVariant = typeof modalBoxVariants -/** - * The scrollable content area. This is what carries the per-size max-height — - * `full` stretches to the box instead of capping. - */ export const modalScrollVariants = cva( - ['overflow-y-auto', 'overflow-x-hidden', 'pr-0.5', 'md:pr-1'], + ['overflow-y-auto', 'overflow-x-hidden', 'px-0.5', 'md:px-1'], { variants: { size: { - sm: ['max-h-[calc(100vh-4rem)]', 'md:max-h-[560px]'], - md: ['max-h-[calc(100vh-4rem)]', 'md:max-h-[640px]'], - lg: ['max-h-[calc(100vh-4rem)]', 'md:max-h-[720px]'], - xl: ['max-h-[calc(100vh-4rem)]', 'md:max-h-[800px]'], + sm: [ + 'max-h-[calc(100dvh-6rem)]', + 'md:max-h-[min(560px,calc(100dvh-8rem))]', + ], + md: [ + 'max-h-[calc(100dvh-6rem)]', + 'md:max-h-[min(640px,calc(100dvh-8rem))]', + ], + lg: [ + 'max-h-[calc(100dvh-6rem)]', + 'md:max-h-[min(720px,calc(100dvh-8rem))]', + ], + xl: [ + 'max-h-[calc(100dvh-6rem)]', + 'md:max-h-[min(800px,calc(100dvh-8rem))]', + ], + '2xl': [ + 'max-h-[calc(100dvh-6rem)]', + 'md:max-h-[min(850px,calc(100dvh-8rem))]', + ], full: ['h-full'], }, }, @@ -58,3 +71,12 @@ export const modalScrollVariants = cva( ) export type ModalScrollVariant = typeof modalScrollVariants + +export const modalDialogVariants = cva([ + 'modal', + 'modal-middle', + 'p-2', + 'md:p-4', +]) + +export type ModalDialogVariant = typeof modalDialogVariants diff --git a/src/components/ui/offline-indicator/offline-indicator.tsx b/src/components/ui/offline-indicator/offline-indicator.tsx index 97d94e3ed..2b3813190 100644 --- a/src/components/ui/offline-indicator/offline-indicator.tsx +++ b/src/components/ui/offline-indicator/offline-indicator.tsx @@ -1,4 +1,4 @@ -import { Icon } from '@/src/icons' +import { Icon } from '@/icons' type OfflineIndicatorMode = 'badge' | 'status' | 'notification' diff --git a/src/components/ui/pagination/pagination.tsx b/src/components/ui/pagination/pagination.tsx index f4c5638bd..a3b7bd340 100644 --- a/src/components/ui/pagination/pagination.tsx +++ b/src/components/ui/pagination/pagination.tsx @@ -1,5 +1,5 @@ import { twMerge } from 'tailwind-merge' -import { Icon } from '@/src/icons' +import { Icon } from '@/icons' import { Button } from '../button/button' export interface PaginationProps { @@ -34,14 +34,10 @@ export function Pagination({ onClick={onPrevPage} disabled={currentPage === 1 || isLoading} size="xs" - className="btn-soft rounded-2xl" - isPrimary={currentPage !== 1} + rounded="2xl" + color={currentPage !== 1 ? 'primary' : 'base'} > - + @@ -51,11 +47,11 @@ export function Pagination({
) diff --git a/src/components/ui/popover-menu/index.ts b/src/components/ui/popover-menu/index.ts new file mode 100644 index 000000000..376fa7f08 --- /dev/null +++ b/src/components/ui/popover-menu/index.ts @@ -0,0 +1,3 @@ +export * from './popover-menu' +export * from './popover-menu-item' +export * from './popover-menu.variants' diff --git a/src/components/ui/popover-menu/popover-menu-item.tsx b/src/components/ui/popover-menu/popover-menu-item.tsx new file mode 100644 index 000000000..a24409e07 --- /dev/null +++ b/src/components/ui/popover-menu/popover-menu-item.tsx @@ -0,0 +1,54 @@ +import { cn } from '@/common/utils/cn' +import { popoverMenuItemVariants } from './popover-menu.variants' + +export interface PopoverMenuItemProps { + icon?: React.ReactNode + label: string + badge?: React.ReactNode + onClick?: () => void + variant?: 'default' | 'danger' | 'primary' + disabled?: boolean + className?: string +} + +export function PopoverMenuItem({ + icon, + label, + badge, + onClick, + variant = 'default', + disabled = false, + className, +}: PopoverMenuItemProps) { + return ( + + ) +} + +export function PopoverMenuDivider() { + return
+} + +export function PopoverMenuHeader({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ) +} diff --git a/src/components/ui/popover-menu/popover-menu.tsx b/src/components/ui/popover-menu/popover-menu.tsx new file mode 100644 index 000000000..3fb4c4640 --- /dev/null +++ b/src/components/ui/popover-menu/popover-menu.tsx @@ -0,0 +1,143 @@ +import { useEffect, useRef, useState } from 'react' +import { Portal } from '@/components/ui/portal/portal' +import { cn } from '@/common/utils/cn' +import { popoverMenuVariants } from './popover-menu.variants' + +export interface PopoverMenuProps { + isOpen: boolean + onClose: () => void + position?: { x: number; y: number } | null + triggerRef?: React.RefObject + children: React.ReactNode + className?: string + width?: number | string + placement?: + | 'bottom-start' + | 'bottom-end' + | 'bottom-center' + | 'top-start' + | 'top-end' + | 'top-center' + offset?: number +} + +export function PopoverMenu({ + isOpen, + onClose, + position, + triggerRef, + children, + className, + width = 208, + placement = 'bottom-center', + offset = 8, +}: PopoverMenuProps) { + const menuRef = useRef(null) + const [coords, setCoords] = useState<{ top: number; left: number } | null>(null) + + useEffect(() => { + if (!isOpen) { + setCoords(null) + return + } + + const computePosition = () => { + if (position) { + const numericWidth = typeof width === 'number' ? width : 208 + const left = Math.min( + Math.max(12, position.x), + window.innerWidth - numericWidth - 12 + ) + const top = Math.min(Math.max(12, position.y), window.innerHeight - 260) + setCoords({ top, left }) + return + } + + if (triggerRef?.current) { + const rect = triggerRef.current.getBoundingClientRect() + const numericWidth = typeof width === 'number' ? width : 208 + let left = rect.left + rect.width / 2 - numericWidth / 2 + let top = rect.bottom + offset + + if (placement === 'bottom-start') { + left = rect.left + } else if (placement === 'bottom-end') { + left = rect.right - numericWidth + } else if (placement.startsWith('top')) { + top = rect.top - offset - 100 + if (placement === 'top-start') left = rect.left + if (placement === 'top-end') left = rect.right - numericWidth + } + + left = Math.min(Math.max(12, left), window.innerWidth - numericWidth - 12) + top = Math.min(Math.max(12, top), window.innerHeight - 180) + + setCoords({ top, left }) + } + } + + computePosition() + + const handleClickOutside = (e: MouseEvent) => { + if ( + menuRef.current && + !menuRef.current.contains(e.target as Node) && + triggerRef?.current && + !triggerRef.current.contains(e.target as Node) + ) { + onClose() + } else if ( + menuRef.current && + !menuRef.current.contains(e.target as Node) && + !triggerRef + ) { + onClose() + } + } + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose() + } + } + + const handleScroll = () => { + onClose() + } + + document.addEventListener('mousedown', handleClickOutside) + document.addEventListener('keydown', handleKeyDown) + window.addEventListener('scroll', handleScroll, true) + window.addEventListener('resize', computePosition) + + return () => { + document.removeEventListener('mousedown', handleClickOutside) + document.removeEventListener('keydown', handleKeyDown) + window.removeEventListener('scroll', handleScroll, true) + window.removeEventListener('resize', computePosition) + } + }, [isOpen, position, triggerRef, width, placement, offset, onClose]) + + if (!isOpen || !coords) return null + + return ( + +
e.stopPropagation()} + onContextMenu={(e) => e.preventDefault()} + > + {children} +
+
+ ) +} diff --git a/src/components/ui/popover-menu/popover-menu.variants.ts b/src/components/ui/popover-menu/popover-menu.variants.ts new file mode 100644 index 000000000..9223a3e40 --- /dev/null +++ b/src/components/ui/popover-menu/popover-menu.variants.ts @@ -0,0 +1,50 @@ +import { cva } from 'class-variance-authority' + +export const popoverMenuVariants = cva([ + 'bg-content', + 'bg-glass', + 'rounded-3xl', + 'shadow-2xl', + 'border', + 'border-base-content/10', + 'p-2', + 'text-right', + 'text-xs', + 'flex', + 'flex-col', + 'gap-1', + 'animate-in', + 'fade-in', + 'zoom-in-95', + 'duration-150', +]) + +export const popoverMenuItemVariants = cva( + [ + 'flex', + 'items-center', + 'justify-between', + 'w-full', + 'px-2.5', + 'py-2', + 'rounded-xl', + 'font-medium', + 'transition-colors', + 'text-right', + 'cursor-pointer', + 'disabled:opacity-40', + 'disabled:cursor-not-allowed', + ], + { + variants: { + variant: { + default: 'text-content hover:bg-base-content/10 active:bg-base-300', + danger: 'text-error hover:bg-error/10 active:bg-error/20', + primary: 'text-primary hover:bg-primary/10 active:bg-primary/20', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +) diff --git a/src/components/ui/portal/portal.tsx b/src/components/ui/portal/portal.tsx index 06072988b..15b548c44 100644 --- a/src/components/ui/portal/portal.tsx +++ b/src/components/ui/portal/portal.tsx @@ -1,12 +1,107 @@ -import type { ReactNode } from 'react' +import { + useEffect, + useLayoutEffect, + useState, + type CSSProperties, + type ReactNode, +} from 'react' import { createPortal } from 'react-dom' export interface PortalProps { children: ReactNode container?: Element | null + topLayer?: boolean + id?: string + className?: string + style?: CSSProperties } -export function Portal({ children, container = document.body }: PortalProps) { +const TOP_LAYER_HOST_CSS = + 'position:fixed;inset:0;width:auto;height:auto;max-width:none;max-height:none;margin:0;padding:0;border:none;background:transparent;overflow:visible;color:inherit;pointer-events:none;' + +function topmostModalDialog(): HTMLElement | null { + let topmost: HTMLElement | null = null + + try { + for (const dialog of document.querySelectorAll('dialog[open]')) { + if (dialog.matches(':modal')) topmost = dialog as HTMLElement + } + } catch { + return null + } + + return topmost +} + +export function raiseToTopLayer(host: HTMLElement | null) { + if (!host) return + + const parent = topmostModalDialog() ?? document.body + + if (host.parentElement !== parent) { + parent.appendChild(host) + } + + if (typeof host.showPopover !== 'function') return + + try { + if (!host.matches(':popover-open')) { + host.showPopover() + } + } catch { + try { + host.hidePopover() + host.showPopover() + } catch {} + } +} + +type TopLayerHostProps = Pick + +function TopLayerHost({ children, id, className, style }: TopLayerHostProps) { + const [host] = useState(() => { + const el = document.createElement('div') + el.setAttribute('popover', 'manual') + return el + }) + + useLayoutEffect(() => { + if (id) host.id = id + host.className = className ?? '' + host.style.cssText = TOP_LAYER_HOST_CSS + if (style) Object.assign(host.style, style) + }) + + useEffect(() => { + raiseToTopLayer(host) + + return () => { + try { + host.hidePopover() + } catch {} + host.remove() + } + }, [host]) + + return createPortal(children, host) +} + +export function Portal({ + children, + container = document.body, + topLayer, + id, + className, + style, +}: PortalProps) { + if (topLayer) { + return ( + + {children} + + ) + } + if (!container) return null return createPortal(children, container) diff --git a/src/components/ui/tooltip/clickable-tooltip.tsx b/src/components/ui/tooltip/clickable-tooltip.tsx index 2a5cbd527..8a5e54d4e 100644 --- a/src/components/ui/tooltip/clickable-tooltip.tsx +++ b/src/components/ui/tooltip/clickable-tooltip.tsx @@ -1,7 +1,7 @@ import { Motion as motion, Presence } from '@/common/motion' import { type ReactNode, useEffect, useRef, useState, type RefObject } from 'react' -import ReactDOM from 'react-dom' +import { Portal } from '../portal/portal' type Position = | 'top' @@ -208,12 +208,12 @@ const ClickableTooltip = ({ return ( <> - {ReactDOM.createPortal( + {isOpen && ( )} - , - document.body - )} + + ) } diff --git a/src/components/ui/tooltip/tooltip.tsx b/src/components/ui/tooltip/tooltip.tsx index 5df9cddb5..125234b2a 100644 --- a/src/components/ui/tooltip/tooltip.tsx +++ b/src/components/ui/tooltip/tooltip.tsx @@ -1,7 +1,7 @@ import { twMerge } from 'tailwind-merge' import { Motion as motion, Presence } from '@/common/motion' import { type ReactNode, useEffect, useRef, useState } from 'react' -import ReactDOM from 'react-dom' +import { Portal } from '../portal/portal' type Position = | 'top' @@ -217,12 +217,12 @@ export const Tooltip = ({ {children}
- {(alwaysShow || isVisible) && - ReactDOM.createPortal( + {(alwaysShow || isVisible) && ( + - , - document.body - )} + + + )} ) } diff --git a/src/components/update-release-notes-modal.tsx b/src/components/update-release-notes-modal.tsx index 7ed9eab3f..980374cd0 100644 --- a/src/components/update-release-notes-modal.tsx +++ b/src/components/update-release-notes-modal.tsx @@ -1,10 +1,13 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { Button, Modal } from '@/components/ui' +import { Icon } from '@/icons' +import { ConfigKey } from '@/common/constants/config.key' +import { useFreeWidgets } from '@/context/free-widget/free-widget.context' type UpdateReleaseNotesModalProps = { isOpen: boolean onClose: () => void - counterValue: number | null + counterValue?: number | null } export const UpdateReleaseNotesModal = ({ @@ -14,9 +17,11 @@ export const UpdateReleaseNotesModal = ({ }: UpdateReleaseNotesModalProps) => { const [counter, setCounter] = useState(0) const videoRef = useRef(null) + const { setCanvasMode } = useFreeWidgets() + useEffect(() => { - if (isOpen && counterValue !== null) { - setCounter(counterValue === null ? 10 : counterValue) + if (isOpen && counterValue) { + setCounter(counterValue) const interval = setInterval(() => { setCounter((prev) => { if (prev <= 1) { @@ -27,10 +32,10 @@ export const UpdateReleaseNotesModal = ({ }) }, 1000) return () => clearInterval(interval) - } else { - setCounter(0) } - }, [isOpen]) + + setCounter(0) + }, [isOpen, counterValue]) useEffect(() => { if (isOpen && videoRef.current) { @@ -38,81 +43,107 @@ export const UpdateReleaseNotesModal = ({ } }, [isOpen]) + const handlePersonalize = () => { + onClose() + setCanvasMode('edit') + } + return ( -
-
-
- -
+
+
+
-
-
-

- موج، روان‌تر از همیشه! 🌊 -

+
+
+
+
- -

- این بار بیشتر از اضافه کردن قابلیت‌های جدید، تمرکزمان روی{' '} - - سریع‌تر، روان‌تر و لذت‌بخش‌تر شدن ویجتیفای - {' '} - بوده تا تجربه بهتری از هر بار باز کردن تب جدید داشته باشی. -

- -

- از حالا می‌تونی{' '} - - بوکمارک‌های مرورگرت رو مستقیما هنگام ساخت بوکمارک درون‌ریزی - (import) کنی +

+ + جابجایی آزاد در صفحه + + + ویجت‌ها رو با درگ و دراپ به هر جای صفحه ببر و چیدمان + دلخواهت رو بساز - ؛ بدون اینکه لازم باشه دوباره همه چیز رو از اول اضافه کنی. -

-

- همچنین قابلیت{' '} - حالت بهینه هم - اضافه شده که می‌تونی از بخش{' '} - - تنظیمات ← عمومی - {' '} - فعال یا غیرفعالش کنی. با فعال بودن این حالت، انیمیشن‌ها و - افکت‌ها کاهش پیدا می‌کنن تا ویجتیفای روان‌تر اجرا بشه. -

+
+
-

- در کنار این‌ها،{' '} - - بهبودهای ظاهری و بهینه‌سازی‌های متعددی - {' '} - انجام شده تا همه چیز مثل یک موج، نرم و بدون وقفه پیش بره 😅 -

+
+
+ +
+
+ + تنوع اندازه ویجت‌ها + + + با کلیک‌راست روی هر ویجت اندازه‌ش رو تغییر بده و از مدل‌های + مختلف استفاده کن + +
-
- 💙دمت گرم که همراه مایی + +
+
+ +
+
+ + چیدمان‌های آماده با ۱ کلیک + + + اگه دوست داری سریع شروع کنی، از قالب‌ها و چیدمان‌های آماده + استفاده کن + +
-
+
+ +
diff --git a/src/components/updater/updater.tsx b/src/components/updater/updater.tsx deleted file mode 100644 index 5ebd046f3..000000000 --- a/src/components/updater/updater.tsx +++ /dev/null @@ -1,209 +0,0 @@ -import { useEffect, useState } from 'react' -import { Button, Modal } from '@/components/ui' -import { getMainClient } from '@/services/api' -import { Icon } from '@/src/icons' - -let updateInfo = null - -async function checkForUpdates() { - try { - const client = getMainClient() - const { data } = await client.get('/extension/version') - - const currentVersion = browser.runtime.getManifest().version - const latestVersion = data.chrome.version - - if (latestVersion !== currentVersion) { - updateInfo = { - hasUpdate: true, - currentVersion, - latestVersion, - force: data.chrome.force, - updateUrl: data.chrome.updateUrl, - downloadUrl: data.chrome.downloadUrl, - notes: data.chrome.notes, - versionName: data.chrome.versionName, - } - - return updateInfo - } else { - return null - } - } catch (error) { - console.error('Failed to check for updates:', error) - } -} - -interface UpdateInfo { - hasUpdate: boolean - currentVersion: string - latestVersion: string - force: boolean - updateUrl: string - downloadUrl: string - notes: string[] - versionName: string -} - -export function UpdateChecker() { - const [updateInfo, setUpdateInfo] = useState(null) - const [showModal, setShowModal] = useState(false) - const [showNotification, setShowNotification] = useState(false) - - const checkUpdateStatus = async () => { - try { - const result = await checkForUpdates() - if (result) { - setUpdateInfo(result) - setShowNotification(true) - } - } catch (error) { - console.error('Failed to check update status:', error) - } - } - - useEffect(() => { - checkUpdateStatus() - }, []) - - const handleOpenModal = () => { - setShowModal(true) - setShowNotification(false) - browser.storage.local.remove('updateInfo') - } - - const handleDismissNotification = (e: React.MouseEvent) => { - e.stopPropagation() - setShowNotification(false) - browser.storage.local.remove('updateInfo') - } - - const handleUpdate = () => { - if (updateInfo?.downloadUrl) { - window.open(updateInfo.downloadUrl, '_blank') - } - } - - if (!updateInfo?.hasUpdate) return null - - return ( - <> - {showNotification && ( -
-
-
- -
- نسخه جدید رسید! -
-
-
- -
-
- - -
-
- -
-
-

- نسخه جدید رسیده! -

-

- برای مشاهده جزئیات کلیک کنید -

-
-
- -
-
-
-
- )} - - !updateInfo.force && setShowModal(false)} - title={'📦 نسخه جدید رسید'} - direction="rtl" - size="md" - closeOnBackdropClick={!updateInfo.force} - showCloseButton={!updateInfo.force} - > -
-
-

- بروزرسانی جدید ویجتیفای منتشر شد! -

-
- "{updateInfo.versionName}" -
-
- - {updateInfo.force && ( -
-

- ⚠️ این به‌روزرسانی اجباری است و باید حتما نصب شود -

-
- )} - -
-

چرا باید آپدیت کنم؟

-
    - {updateInfo.notes.map((f) => ( -
  • • {f}
  • - ))} -
-
- -
- - {!updateInfo.force && ( - - )} -
-
-
- - ) -} diff --git a/src/layouts/setting/tabs/account/components/user-coin.tsx b/src/components/user-coin.tsx similarity index 94% rename from src/layouts/setting/tabs/account/components/user-coin.tsx rename to src/components/user-coin.tsx index 409bb7e53..076d5240e 100644 --- a/src/layouts/setting/tabs/account/components/user-coin.tsx +++ b/src/components/user-coin.tsx @@ -1,4 +1,4 @@ -import { ConfigKey } from '@/common/constant/config.key' +import { ConfigKey } from '@/common/constants/config.key' import { Tooltip } from '@/components/ui' interface Prop { diff --git a/src/context/appearance.context.tsx b/src/context/appearance.context.tsx index 3a121bbee..81de4e5ce 100644 --- a/src/context/appearance.context.tsx +++ b/src/context/appearance.context.tsx @@ -1,8 +1,11 @@ import type React from 'react' -import { createContext, useContext, useEffect, useState } from 'react' +import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' import Analytics from '@/analytics' import { getMultipleFromStorage, setToStorage } from '@/common/storage' -import { useChangeFont, useChangeUI } from '@/services/hooks/extension/update-setting.hook' +import { + useChangeFont, + useChangeUI, +} from '@/services/hooks/extension/update-setting.hook' import { useAuth } from './auth.context' import { safeAwait } from '@/services/api' import { showToast } from '@/common/toast' @@ -10,8 +13,10 @@ import { translateError } from '@/common/utils/translate-error' import { listenEvent } from '@/common/utils/call-event' export enum UI { - SIMPLE = 'SIMPLE', + DEFAULT = 'CUSTOM', ADVANCED = 'ADVANCED', + SIMPLE = 'SIMPLE', + CUSTOM = 'CUSTOM', } export interface AppearanceData { fontFamily: string @@ -30,11 +35,16 @@ interface AppearanceContextContextType extends AppearanceData { toggleCanReOrderWidget: () => void ui: UI setContentAlignment: (value: 'center' | 'top') => void + canvasMode: 'normal' | 'edit' + setCanvasMode: (mode: 'normal' | 'edit') => void + selectedInstanceId: string | null + setSelectedInstanceId: (id: string | null) => void + toggleCanvasMode: () => void } const DEFAULT_SETTINGS: AppearanceData = { fontFamily: 'Vazir', - ui: UI.ADVANCED, + ui: UI.CUSTOM, contentAlignment: 'top', } @@ -44,10 +54,22 @@ export function AppearanceProvider({ children }: { children: React.ReactNode }) const [settings, setSettings] = useState(DEFAULT_SETTINGS) const [isInitialized, setIsInitialized] = useState(false) const [canReOrderWidget, setCanReOrderWidget] = useState(false) + const [canvasMode, setCanvasMode] = useState<'normal' | 'edit'>('normal') + const [selectedInstanceId, setSelectedInstanceId] = useState(null) const { mutateAsync: changeFontAsync } = useChangeFont() const { mutateAsync: changeUIAsync } = useChangeUI() const { isAuthenticated } = useAuth() + const toggleCanvasMode = useCallback(() => { + setCanvasMode((prev) => { + const next = prev === 'normal' ? 'edit' : 'normal' + if (next === 'normal') { + setSelectedInstanceId(null) + } + return next + }) + }, []) + useEffect(() => { async function loadSettings() { const { appearance, browserTitle } = await getMultipleFromStorage([ @@ -58,6 +80,7 @@ export function AppearanceProvider({ children }: { children: React.ReactNode }) setSettings({ ...DEFAULT_SETTINGS, ...appearance, + ui: UI.CUSTOM, }) if (browserTitle) document.title = browserTitle.template } @@ -91,27 +114,6 @@ export function AppearanceProvider({ children }: { children: React.ReactNode }) } }, []) - useEffect(() => { - const handleKeyDown = (event: KeyboardEvent) => { - if ( - event.altKey && - event.key.toLowerCase() === 'y' && - (event.ctrlKey || event.metaKey) - ) { - event.preventDefault() - setUI( - settings.ui === UI.ADVANCED ? UI.SIMPLE : UI.ADVANCED, - isAuthenticated - ) - } - } - - window.addEventListener('keydown', handleKeyDown) - return () => { - window.removeEventListener('keydown', handleKeyDown) - } - }, [isAuthenticated, settings]) - const updateSetting = ( key: K, value: AppearanceData[K] @@ -173,22 +175,39 @@ export function AppearanceProvider({ children }: { children: React.ReactNode }) } }, [isInitialized, settings.fontFamily]) + const contextValue = useMemo( + () => ({ + fontFamily: settings.fontFamily, + contentAlignment: settings.contentAlignment, + updateSetting, + setFontFamily, + canReOrderWidget, + ui: settings.ui, + setUI: (val) => setUI(val, isAuthenticated), + toggleCanReOrderWidget, + setContentAlignment, + canvasMode, + setCanvasMode, + selectedInstanceId, + setSelectedInstanceId, + toggleCanvasMode, + }), + [ + settings.fontFamily, + settings.contentAlignment, + settings.ui, + canReOrderWidget, + canvasMode, + selectedInstanceId, + isAuthenticated, + toggleCanvasMode, + ] + ) + if (!isInitialized) { return null } - const contextValue: AppearanceContextContextType = { - fontFamily: settings.fontFamily, - contentAlignment: settings.contentAlignment, - updateSetting, - setFontFamily, - canReOrderWidget, - ui: settings.ui, - setUI: (val) => setUI(val, isAuthenticated), - toggleCanReOrderWidget, - setContentAlignment, - } - return ( {children} @@ -205,3 +224,5 @@ export function useAppearanceSetting() { return context } + +export const useAppearance = useAppearanceSetting diff --git a/src/context/auth.context.tsx b/src/context/auth.context.tsx index 8314b2882..312c2856c 100644 --- a/src/context/auth.context.tsx +++ b/src/context/auth.context.tsx @@ -11,6 +11,7 @@ interface AuthContextType { isAuthenticated: boolean token: string | null user: UserProfile | null + isVip: boolean isLoadingUser: boolean profilePercentage: number isSuccessFetchingUser: boolean @@ -23,6 +24,7 @@ const AuthContext = createContext(undefined) export function AuthProvider({ children }: { children: ReactNode }) { const [token, setToken] = useState(null) + const [cachedUser, setCachedUser] = useState(null) const queryClient = useQueryClient() const [initialLoading, setInitialLoading] = useState(true) @@ -35,22 +37,31 @@ export function AuthProvider({ children }: { children: ReactNode }) { enabled: !!token, }) + const activeUser = userProfile || cachedUser + const logout = async () => { await clearStorage() setToken(null) + setCachedUser(null) queryClient.invalidateQueries({ queryKey: ['userProfile'] }) } useEffect(() => { - async function loadToken() { - const savedToken = await getFromStorage('auth_token') + async function loadAuth() { + const [savedToken, savedProfile] = await Promise.all([ + getFromStorage('auth_token'), + getFromStorage('profile'), + ]) if (savedToken) { setToken(savedToken) } + if (savedProfile) { + setCachedUser(savedProfile) + } setInitialLoading(false) } - loadToken() + loadAuth() const logoutEvent = listenEvent('auth_logout', async () => { logout() @@ -83,12 +94,13 @@ export function AuthProvider({ children }: { children: ReactNode }) { value={{ isAuthenticated: !!token, token, - user: userProfile || null, - isLoadingUser: initialLoading || (!!token && isLoading), + user: activeUser || null, + isVip: Boolean(activeUser?.isVip ?? false), + isLoadingUser: initialLoading || (!!token && isLoading && !cachedUser), login, logout, profilePercentage: - calculateProgressPercentage(userProfile?.progressbar || []) || 0, + calculateProgressPercentage(activeUser?.progressbar || []) || 0, refetchUser, isSuccessFetchingUser: isSuccess, }} diff --git a/src/context/currency.context.tsx b/src/context/currency.context.tsx index 4d95e9d1b..c3ef04018 100644 --- a/src/context/currency.context.tsx +++ b/src/context/currency.context.tsx @@ -8,6 +8,9 @@ export interface StoreContext { currencyColorMode: CurrencyColorMode | null setCurrencyColorMode: (mode: CurrencyColorMode) => void reorderCurrencies: (currencies: Array) => void + compactCurrencies: Record + setCompactCurrency: (instanceId: string, code: string) => void + getCompactCurrency: (instanceId?: string) => string } export enum CurrencyColorMode { @@ -21,6 +24,9 @@ export const currencyContext = createContext({ currencyColorMode: null, setCurrencyColorMode: () => {}, reorderCurrencies: () => {}, + compactCurrencies: {}, + setCompactCurrency: () => {}, + getCompactCurrency: () => 'USD', }) export const CurrencyProvider: React.FC<{ children: React.ReactNode }> = ({ @@ -30,22 +36,31 @@ export const CurrencyProvider: React.FC<{ children: React.ReactNode }> = ({ const [currencyColorMode, setCurrencyColorMode] = useState( null ) + const [compactCurrencies, setCompactCurrencies] = useState>({}) useEffect(() => { async function load() { - const [storedCurrencies, currencyColorMode] = await Promise.all([ - getFromStorage('currencies'), - getFromStorage('currencyColorMode'), - ]) + const [storedCurrencies, storedColorMode, storedCompactCurrencies] = + await Promise.all([ + getFromStorage('currencies'), + getFromStorage('currencyColorMode'), + getFromStorage('compact_currencies'), + ]) setSelectedCurrencies(storedCurrencies ?? ['USD', 'EUR', 'GRAM']) - setCurrencyColorMode(currencyColorMode || CurrencyColorMode.NORMAL) + setCurrencyColorMode(storedColorMode || CurrencyColorMode.NORMAL) + setCompactCurrencies(storedCompactCurrencies || {}) } const listen = listenEvent( 'currencies_updated', - (data: { currencies: string[]; colorMode: CurrencyColorMode }) => { - setSelectedCurrencies(data.currencies) - setCurrencyColorMode(data.colorMode) + (data: { + currencies: string[] + colorMode: CurrencyColorMode + compactCurrencies?: Record + }) => { + if (data.currencies) setSelectedCurrencies(data.currencies) + if (data.colorMode) setCurrencyColorMode(data.colorMode) + if (data.compactCurrencies) setCompactCurrencies(data.compactCurrencies) } ) @@ -72,10 +87,40 @@ export const CurrencyProvider: React.FC<{ children: React.ReactNode }> = ({ save() }, [currencyColorMode]) + useEffect(() => { + async function save() { + await setToStorage('compact_currencies', compactCurrencies) + } + if (compactCurrencies && Object.keys(compactCurrencies).length > 0) { + save() + } + }, [compactCurrencies]) + const reorderCurrencies = (currencies: Array) => { setSelectedCurrencies(currencies) } + const setCompactCurrency = (instanceId: string, code: string) => { + setCompactCurrencies((prev) => { + const updated = { ...prev, [instanceId]: code } + setToStorage('compact_currencies', updated) + return updated + }) + } + + const getCompactCurrency = (instanceId?: string): string => { + if (instanceId && compactCurrencies[instanceId]) { + return compactCurrencies[instanceId] + } + if (compactCurrencies.default) { + return compactCurrencies.default + } + if (selectedCurrencies && selectedCurrencies.length > 0) { + return selectedCurrencies[0] + } + return 'USD' + } + return ( = ({ setCurrencyColorMode, currencyColorMode, reorderCurrencies, + compactCurrencies, + setCompactCurrency, + getCompactCurrency, }} > {children} diff --git a/src/context/date.context.tsx b/src/context/date.context.tsx index 983a1a353..9a2c57dcf 100644 --- a/src/context/date.context.tsx +++ b/src/context/date.context.tsx @@ -3,8 +3,11 @@ import { createContext, useContext, useEffect, useState } from 'react' import { convertShamsiToHijri, getCurrentDate, + getHijriEvents, + getShamsiEvents, type WidgetifyDate, -} from '@/layouts/widgets/calendar/utils' +} from '@/layouts/widgets/calendar/utils/date-events' +import { useGetEvents } from '@/services/hooks/date/get-events.hook' import { useGeneralSetting } from './general-setting.context' interface DateContextType { @@ -29,7 +32,6 @@ export const DateProvider: React.FC<{ children: React.ReactNode }> = ({ children const [selectedDate, setSelectedDate] = useState(activeDate) const [today, setToday] = useState(activeDate) - // Update today date every minute to ensure it stays current useEffect(() => { const interval = setInterval(() => { setToday(getCurrentDate(timezone.value)) @@ -64,7 +66,13 @@ export const DateProvider: React.FC<{ children: React.ReactNode }> = ({ children return `${hijriDate.iYear()}/${hijriDate.iMonth() + 1}/${hijriDate.iDate()}` } - const todayIsHoliday = activeDate.day() === 5 + const { data: events } = useGetEvents() + + const todayEvents = events + ? [...getShamsiEvents(events, today), ...getHijriEvents(events, today)] + : [] + + const todayIsHoliday = today.day() === 5 || todayEvents.some((e) => e.isHoliday) return ( = ({ children export const useDate = (): DateContextType => { const context = useContext(DateContext) + const generalSetting = useGeneralSetting() + const timezone = generalSetting?.selected_timezone?.value || 'Asia/Tehran' if (!context) { - throw new Error('useDate must be used within a DateProvider') + const activeDate = getCurrentDate(timezone) + return { + currentDate: activeDate, + selectedDate: activeDate, + today: activeDate, + todayIsHoliday: activeDate.day() === 5, + setCurrentDate: () => {}, + setSelectedDate: () => {}, + goToToday: () => {}, + isToday: (date: WidgetifyDate) => { + return ( + date.jDate() === activeDate.jDate() && + date.jMonth() === activeDate.jMonth() && + date.jYear() === activeDate.jYear() + ) + }, + getHijriDate: (date: WidgetifyDate) => { + const hijriDate = convertShamsiToHijri(date) + return `${hijriDate.iYear()}/${hijriDate.iMonth() + 1}/${hijriDate.iDate()}` + }, + } } return context diff --git a/src/context/free-widget/free-widget.context.tsx b/src/context/free-widget/free-widget.context.tsx new file mode 100644 index 000000000..04e4680d9 --- /dev/null +++ b/src/context/free-widget/free-widget.context.tsx @@ -0,0 +1,298 @@ +import type React from 'react' +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import { + DEFAULT_CELL_HEIGHT, + DEFAULT_COLS, + DEFAULT_GAP, + GRID_BREAKPOINTS, + MIN_CELL_WIDTH, +} from '@/layouts/widgets/layout-engine' +import { type StoredWidget, WidgetKeys } from '@/layouts/widgets/layout-engine/types' +import { useAppearance } from '../appearance.context' +import { useAuth } from '../auth.context' +import { reflowForColumns } from './widget-layout-helpers' +import { useWidgetSync } from './use-widget-sync' +import { useWidgetOperations } from './use-widget-operations' +import { useWidgetDrag } from './use-widget-drag' +import type { + FreeWidgetActions, + FreeWidgetContextType, + FreeWidgetDerivedState, + FreeWidgetLayoutState, +} from './free-widget.types' + +export type { + FreeWidgetActions, + FreeWidgetContextType, + FreeWidgetDerivedState, + FreeWidgetLayoutState, +} + +export const FreeWidgetLayoutContext = createContext(null) +export const FreeWidgetActionsContext = createContext(null) +export const FreeWidgetDerivedContext = createContext(null) + +export function FreeWidgetProvider({ children }: { children: React.ReactNode }) { + const { isAuthenticated, isVip, token } = useAuth() + const { canvasMode, setCanvasMode, selectedInstanceId, setSelectedInstanceId } = + useAppearance() + + const [isLoaded, setIsLoaded] = useState(false) + const [savedLayout, setSavedLayout] = useState([]) + const [runtimeLayout, setRuntimeLayout] = useState([]) + const [cols, setCols] = useState(DEFAULT_COLS) + const [cellHeight, setCellHeight] = useState(DEFAULT_CELL_HEIGHT) + const [gap, setGap] = useState(DEFAULT_GAP) + const [cellWidth, setCellWidth] = useState(100) + const [isListFallback, setIsListFallback] = useState(false) + + const savedLayoutRef = useRef([]) + const colsRef = useRef(DEFAULT_COLS) + const runtimeLayoutRef = useRef([]) + const isVipRef = useRef(isVip) + const selectedInstanceIdRef = useRef(selectedInstanceId) + const maxRowsRef = useRef(0) + + useEffect(() => { + isVipRef.current = isVip + }, [isVip]) + useEffect(() => { + selectedInstanceIdRef.current = selectedInstanceId + }, [selectedInstanceId]) + useEffect(() => { + runtimeLayoutRef.current = runtimeLayout + }, [runtimeLayout]) + + const applyRuntimeLayout = useCallback( + (next: StoredWidget[] | ((prev: StoredWidget[]) => StoredWidget[])) => { + setRuntimeLayout((prev) => { + const value = typeof next === 'function' ? next(prev) : next + runtimeLayoutRef.current = value + return value + }) + }, + [] + ) + + const setMaxRows = useCallback((maxRows: number) => { + maxRowsRef.current = maxRows + }, []) + + const getGridBounds = useCallback( + () => ({ cols: colsRef.current, maxRows: maxRowsRef.current }), + [] + ) + + const { persistLayout, triggerServerSync, markLocalEdit } = useWidgetSync({ + isAuthenticated, + token, + setSavedLayout, + savedLayoutRef, + colsRef, + applyRuntimeLayout, + setIsLoaded, + }) + + const { + commitMutation, + resizeWidget, + moveWidget, + updateWidgetVariant, + addWidget, + duplicateWidget, + removeWidget, + updateWidgetSettings, + applyPresetLayout, + } = useWidgetOperations({ + runtimeLayoutRef, + savedLayoutRef, + colsRef, + isVipRef, + selectedInstanceIdRef, + isAuthenticated, + persistLayout, + triggerServerSync, + applyRuntimeLayout, + setSavedLayout, + setRuntimeLayout, + setSelectedInstanceId, + markLocalEdit, + }) + + const { startDragPreview, updateDragPreview, endDragPreview } = useWidgetDrag({ + runtimeLayoutRef, + colsRef, + applyRuntimeLayout, + commitMutation, + }) + + const updateContainerWidth = useCallback( + (containerWidth: number) => { + if (containerWidth <= 0) return + + let matched = GRID_BREAKPOINTS[0] + for (const bp of GRID_BREAKPOINTS) { + if (containerWidth >= bp.minContainerWidth) { + matched = bp + break + } + } + + const computedCellWidth = + (containerWidth - (matched.cols - 1) * matched.gap) / matched.cols + const fallback = computedCellWidth < MIN_CELL_WIDTH + + colsRef.current = matched.cols + setCols(matched.cols) + setCellHeight(matched.cellHeight) + setGap(matched.gap) + setCellWidth(computedCellWidth) + setIsListFallback(fallback) + + setSavedLayout((currentSaved) => { + const base = + currentSaved.length > 0 ? currentSaved : savedLayoutRef.current + if (base.length === 0) return currentSaved + if (fallback) { + applyRuntimeLayout(base) + return currentSaved + } + applyRuntimeLayout(reflowForColumns(base, matched.cols)) + return currentSaved + }) + }, + [applyRuntimeLayout, savedLayoutRef] + ) + + const layoutValue = useMemo( + () => ({ + savedLayout, + runtimeLayout, + cols, + cellWidth, + cellHeight, + gap, + isListFallback, + isLoaded, + canvasMode, + selectedInstanceId, + }), + [ + savedLayout, + runtimeLayout, + cols, + cellWidth, + cellHeight, + gap, + isListFallback, + isLoaded, + canvasMode, + selectedInstanceId, + ] + ) + + const primaryBookmarkInstanceId = useMemo( + () => + runtimeLayout.find((w) => w.id === WidgetKeys.bookmarks)?.instanceId ?? null, + [runtimeLayout] + ) + + const derivedValue = useMemo( + () => ({ primaryBookmarkInstanceId }), + [primaryBookmarkInstanceId] + ) + + const actionsValue = useMemo( + () => ({ + setCanvasMode, + setSelectedInstanceId, + getGridBounds, + setMaxRows, + resizeWidget, + moveWidget, + startDragPreview, + updateDragPreview, + endDragPreview, + addWidget, + duplicateWidget, + removeWidget, + updateWidgetSettings, + updateWidgetVariant, + updateContainerWidth, + applyPresetLayout, + }), + [ + setCanvasMode, + setSelectedInstanceId, + getGridBounds, + setMaxRows, + resizeWidget, + moveWidget, + startDragPreview, + updateDragPreview, + endDragPreview, + addWidget, + duplicateWidget, + removeWidget, + updateWidgetSettings, + updateWidgetVariant, + updateContainerWidth, + applyPresetLayout, + ] + ) + + return ( + + + + {children} + + + + ) +} + +export function usePrimaryBookmarkInstanceId(): string | null | undefined { + const context = useContext(FreeWidgetDerivedContext) + if (!context) return undefined + return context.primaryBookmarkInstanceId +} + +export function useFreeWidgetActions(): FreeWidgetActions { + const context = useContext(FreeWidgetActionsContext) + if (!context) { + throw new Error('useFreeWidgetActions must be used within a FreeWidgetProvider') + } + return context +} + +export function useFreeWidgetLayout(): FreeWidgetLayoutState { + const context = useContext(FreeWidgetLayoutContext) + if (!context) { + throw new Error('useFreeWidgetLayout must be used within a FreeWidgetProvider') + } + return context +} + +export function useFreeWidgets(): FreeWidgetContextType { + const layout = useFreeWidgetLayout() + const actions = useFreeWidgetActions() + return useMemo(() => ({ ...layout, ...actions }), [layout, actions]) +} + +export function useOptionalFreeWidgets(): FreeWidgetContextType | null { + const layout = useContext(FreeWidgetLayoutContext) + const actions = useContext(FreeWidgetActionsContext) + return useMemo( + () => (layout && actions ? { ...layout, ...actions } : null), + [layout, actions] + ) +} diff --git a/src/context/free-widget/free-widget.types.ts b/src/context/free-widget/free-widget.types.ts new file mode 100644 index 000000000..e150eab74 --- /dev/null +++ b/src/context/free-widget/free-widget.types.ts @@ -0,0 +1,52 @@ +import type { + StoredWidget, + WidgetPosition, + WidgetSize, +} from '@/layouts/widgets/layout-engine/types' + +export interface FreeWidgetLayoutState { + savedLayout: StoredWidget[] + runtimeLayout: StoredWidget[] + cols: number + cellWidth: number + cellHeight: number + gap: number + isListFallback: boolean + isLoaded: boolean + canvasMode: 'normal' | 'edit' + selectedInstanceId: string | null +} + +export interface FreeWidgetActions { + setCanvasMode: (mode: 'normal' | 'edit') => void + setSelectedInstanceId: (id: string | null) => void + getGridBounds: () => { cols: number; maxRows: number } + resizeWidget: (instanceId: string, newSize: WidgetSize) => boolean + moveWidget: (instanceId: string, targetPosition: WidgetPosition) => boolean + startDragPreview: () => void + updateDragPreview: (instanceId: string, targetPosition: WidgetPosition) => void + endDragPreview: (instanceId: string, targetPosition: WidgetPosition | null) => void + addWidget: ( + id: string, + targetPosition?: WidgetPosition, + initialSize?: WidgetSize, + meta?: Record + ) => Promise + duplicateWidget: (instanceId: string) => Promise + removeWidget: (instanceId: string) => boolean + updateWidgetSettings: (instanceId: string, meta: any) => void + updateWidgetVariant: ( + instanceId: string, + newSize: WidgetSize, + meta?: Record + ) => boolean + updateContainerWidth: (containerWidth: number) => void + setMaxRows: (maxRows: number) => void + applyPresetLayout: (presetWidgets: StoredWidget[]) => Promise +} + +export type FreeWidgetContextType = FreeWidgetLayoutState & FreeWidgetActions + +export interface FreeWidgetDerivedState { + primaryBookmarkInstanceId: string | null +} diff --git a/src/context/free-widget/use-widget-drag.ts b/src/context/free-widget/use-widget-drag.ts new file mode 100644 index 000000000..eef9be259 --- /dev/null +++ b/src/context/free-widget/use-widget-drag.ts @@ -0,0 +1,110 @@ +import { startTransition, useCallback, useRef } from 'react' +import type React from 'react' +import { reconcileIdentity, resolveLayoutChange } from '@/layouts/widgets/layout-engine' +import type { StoredWidget, WidgetPosition } from '@/layouts/widgets/layout-engine/types' +import { WIDGET_DEFINITIONS } from '@/layouts/widgets/widget-registry' + +type ApplyRuntimeLayout = ( + next: StoredWidget[] | ((prev: StoredWidget[]) => StoredWidget[]) +) => void +type CommitMutation = ( + operation: string, + nextRuntime: StoredWidget[], + targetInstanceId?: string +) => boolean + +interface UseWidgetDragParams { + runtimeLayoutRef: React.MutableRefObject + colsRef: React.MutableRefObject + applyRuntimeLayout: ApplyRuntimeLayout + commitMutation: CommitMutation +} + +export function useWidgetDrag({ + runtimeLayoutRef, + colsRef, + applyRuntimeLayout, + commitMutation, +}: UseWidgetDragParams) { + const dragBaseLayoutRef = useRef(null) + const dragSequenceRef = useRef(0) + + const startDragPreview = useCallback(() => { + dragSequenceRef.current += 1 + dragBaseLayoutRef.current = runtimeLayoutRef.current + }, [runtimeLayoutRef]) + + const updateDragPreview = useCallback( + (instanceId: string, targetPosition: WidgetPosition) => { + const base = dragBaseLayoutRef.current + if (!base) return + + const sequence = dragSequenceRef.current + + const result = resolveLayoutChange({ + layout: base, + operation: 'move', + instanceId, + targetPosition, + cols: colsRef.current, + registry: WIDGET_DEFINITIONS, + }) + + if (!result) return + + startTransition(() => { + if ( + dragSequenceRef.current !== sequence || + dragBaseLayoutRef.current === null + ) { + return + } + applyRuntimeLayout((prev) => reconcileIdentity(prev, result)) + }) + }, + [colsRef, applyRuntimeLayout] + ) + + const endDragPreview = useCallback( + (instanceId: string, targetPosition: WidgetPosition | null) => { + const base = dragBaseLayoutRef.current + dragBaseLayoutRef.current = null + dragSequenceRef.current += 1 + if (!base) return + + const restore = () => { + if (runtimeLayoutRef.current !== base) { + applyRuntimeLayout(base) + } + } + + const origin = base.find((w) => w.instanceId === instanceId)?.position + const isUnmoved = + origin && + targetPosition && + origin.col === targetPosition.col && + origin.row === targetPosition.row + + if (!targetPosition || isUnmoved) { + restore() + return + } + + const result = resolveLayoutChange({ + layout: base, + operation: 'move', + instanceId, + targetPosition, + cols: colsRef.current, + registry: WIDGET_DEFINITIONS, + }) + + if (!result || !commitMutation('move', result, instanceId)) { + restore() + } + }, + [runtimeLayoutRef, colsRef, applyRuntimeLayout, commitMutation] + ) + + return { startDragPreview, updateDragPreview, endDragPreview } +} diff --git a/src/context/free-widget/use-widget-operations.ts b/src/context/free-widget/use-widget-operations.ts new file mode 100644 index 000000000..7ff676bc1 --- /dev/null +++ b/src/context/free-widget/use-widget-operations.ts @@ -0,0 +1,472 @@ +import { useCallback } from 'react' +import type React from 'react' +import Analytics from '@/analytics' +import { playNativeToastSound, showToast } from '@/common/toast' +import { translateError } from '@/common/utils/translate-error' +import { callEvent } from '@/common/utils/call-event' +import { + DEFAULT_COLS, + resolveLayoutChange, + validateLayout, +} from '@/layouts/widgets/layout-engine' +import { + type StoredWidget, + WidgetKeys, + type WidgetPosition, + type WidgetSize, +} from '@/layouts/widgets/layout-engine/types' +import { isServerInstanceId } from '@/layouts/widgets/instance-id' +import { WIDGET_DEFINITIONS } from '@/layouts/widgets/widget-registry' +import { + createUserWidgetApi, + deleteUserWidgetApi, + updateUserWidgetApi, +} from '@/services/hooks/widgets/widget-sync.hook' +import { reflowForColumns, sanitizeLayout } from './widget-layout-helpers' +import { createNoteForDuplicatedWidget } from './widget-note-helpers' + +type ApplyRuntimeLayout = ( + next: StoredWidget[] | ((prev: StoredWidget[]) => StoredWidget[]) +) => void + +interface UseWidgetOperationsParams { + runtimeLayoutRef: React.MutableRefObject + savedLayoutRef: React.MutableRefObject + colsRef: React.MutableRefObject + isVipRef: React.MutableRefObject + selectedInstanceIdRef: React.MutableRefObject + isAuthenticated: boolean + persistLayout: (layout: StoredWidget[]) => void + triggerServerSync: (layout: StoredWidget[]) => void + applyRuntimeLayout: ApplyRuntimeLayout + setSavedLayout: React.Dispatch> + setRuntimeLayout: React.Dispatch> + setSelectedInstanceId: (id: string | null) => void + markLocalEdit: () => void +} + +export function useWidgetOperations({ + runtimeLayoutRef, + savedLayoutRef, + colsRef, + isVipRef, + selectedInstanceIdRef, + isAuthenticated, + persistLayout, + triggerServerSync, + applyRuntimeLayout, + setSavedLayout, + setRuntimeLayout, + setSelectedInstanceId, + markLocalEdit, +}: UseWidgetOperationsParams) { + const commitMutation = useCallback( + (operation: string, nextRuntime: StoredWidget[], targetInstanceId?: string) => { + const currentCols = colsRef.current + if (!validateLayout(nextRuntime, currentCols, WIDGET_DEFINITIONS)) { + return false + } + + markLocalEdit() + applyRuntimeLayout(nextRuntime) + + if (currentCols >= DEFAULT_COLS) { + setSavedLayout(nextRuntime) + persistLayout(nextRuntime) + triggerServerSync(nextRuntime) + } else { + setSavedLayout((prevSaved) => { + const updated = prevSaved.map((savedW) => { + const matching = nextRuntime.find( + (r) => r.instanceId === savedW.instanceId + ) + if (matching) { + return { + ...savedW, + size: matching.size, + position: matching.position, + } + } + return savedW + }) + + const newItems = nextRuntime.filter( + (r) => !prevSaved.some((s) => s.instanceId === r.instanceId) + ) + + const finalSaved = [...updated, ...newItems] + persistLayout(finalSaved) + triggerServerSync(finalSaved) + return finalSaved + }) + } + + Analytics.event(`widget_layout_${operation}`, { + instanceId: targetInstanceId, + }) + return true + }, + [ + colsRef, + persistLayout, + triggerServerSync, + applyRuntimeLayout, + setSavedLayout, + markLocalEdit, + ] + ) + + const resizeWidget = useCallback( + (instanceId: string, newSize: WidgetSize): boolean => { + const result = resolveLayoutChange({ + layout: runtimeLayoutRef.current, + operation: 'resize', + instanceId, + targetSize: newSize, + cols: colsRef.current, + registry: WIDGET_DEFINITIONS, + }) + + if (!result) { + showToast( + 'برای این اندازه جا نیست! ویجت‌های کناری رو جابه‌جا کن تا جا باز بشه', + 'error' + ) + return false + } + + return commitMutation('resize', result, instanceId) + }, + [runtimeLayoutRef, colsRef, commitMutation] + ) + + const moveWidget = useCallback( + (instanceId: string, targetPosition: WidgetPosition): boolean => { + const result = resolveLayoutChange({ + layout: runtimeLayoutRef.current, + operation: 'move', + instanceId, + targetPosition, + cols: colsRef.current, + registry: WIDGET_DEFINITIONS, + }) + + if (!result) return false + return commitMutation('move', result, instanceId) + }, + [runtimeLayoutRef, colsRef, commitMutation] + ) + + const updateWidgetVariant = useCallback( + ( + instanceId: string, + newSize: WidgetSize, + meta?: Record + ): boolean => { + const current = runtimeLayoutRef.current.find( + (w) => w.instanceId === instanceId + ) + if (!current) return false + + const isSizeSame = + current.size.w === newSize.w && current.size.h === newSize.h + + if (isSizeSame) { + setSavedLayout((prev) => { + const updated = prev.map((w) => + w.instanceId === instanceId ? { ...w, meta } : w + ) + persistLayout(updated) + triggerServerSync(updated) + return updated + }) + applyRuntimeLayout((prev) => + prev.map((w) => (w.instanceId === instanceId ? { ...w, meta } : w)) + ) + playNativeToastSound('success') + return true + } + + const result = resolveLayoutChange({ + layout: runtimeLayoutRef.current, + operation: 'resize', + instanceId, + targetSize: newSize, + cols: colsRef.current, + registry: WIDGET_DEFINITIONS, + }) + + if (!result) { + showToast( + 'برای این مدل جا نیست! ویجت‌های کناری رو جابه‌جا کن یا مدل کوچک‌تری انتخاب کن', + 'error' + ) + return false + } + + const layoutWithMeta = result.map((w: StoredWidget) => + w.instanceId === instanceId ? { ...w, meta } : w + ) + + playNativeToastSound('success') + return commitMutation('resize', layoutWithMeta, instanceId) + }, + [ + runtimeLayoutRef, + colsRef, + commitMutation, + persistLayout, + triggerServerSync, + applyRuntimeLayout, + setSavedLayout, + ] + ) + + const addWidget = useCallback( + async ( + id: string, + targetPosition?: WidgetPosition, + initialSize?: WidgetSize, + meta?: Record + ): Promise => { + const def = WIDGET_DEFINITIONS[id as keyof typeof WIDGET_DEFINITIONS] + if (!def) return false + + const isAlreadyActive = runtimeLayoutRef.current.some((w) => w.id === id) + if (!def.canDuplicate && isAlreadyActive) { + showToast(translateError('WIDGET_ALREADY_EXISTS') as string, 'error') + return false + } + + const chosenSize = initialSize || def.defaultSize + const isCurrentlyActive = runtimeLayoutRef.current.some( + (w) => w.id === def.id + ) + if (isCurrentlyActive && !isVipRef.current) { + callEvent('openSettings', 'vip') + return false + } + + let finalInstanceId = `${id}-${Date.now().toString(36)}` + + if (isAuthenticated) { + const serverWidget = await createUserWidgetApi({ + widgetKey: def.id, + ui: 'CUSTOM', + workspace: 'HOME', + col: targetPosition?.col ?? 0, + row: targetPosition?.row ?? 0, + width: chosenSize.w, + height: chosenSize.h, + }) + if (serverWidget?.instanceId) { + finalInstanceId = serverWidget.instanceId + } + } + + const newWidget: StoredWidget = { + id: def.id, + instanceId: finalInstanceId, + position: targetPosition || { col: 0, row: 0 }, + size: chosenSize, + widgetId: finalInstanceId, + meta: meta || undefined, + } + + const result = resolveLayoutChange({ + layout: runtimeLayoutRef.current, + operation: 'add', + newWidget, + targetPosition, + cols: colsRef.current, + registry: WIDGET_DEFINITIONS, + }) + + if (!result) { + if (isAuthenticated && isServerInstanceId(finalInstanceId)) { + deleteUserWidgetApi(finalInstanceId).catch(() => {}) + } + showToast(translateError('NO_SPACE_FOR_WIDGET') as string, 'error') + return false + } + + playNativeToastSound('success') + return commitMutation('add', result, finalInstanceId) + }, + [runtimeLayoutRef, colsRef, isVipRef, isAuthenticated, commitMutation] + ) + + const duplicateWidget = useCallback( + async (instanceId: string): Promise => { + const original = runtimeLayoutRef.current.find( + (w) => w.instanceId === instanceId + ) + if (!original) return false + + const def = WIDGET_DEFINITIONS[original.id] + if (!def?.canDuplicate) { + showToast('امکان تکرار این ویجت وجود نداره', 'error') + return false + } + + if (!isVipRef.current) { + callEvent('openSettings', 'vip') + return false + } + + let duplicatedMeta = original.meta ? { ...original.meta } : {} + + if (original.id === WidgetKeys.notes) { + const { noteId } = await createNoteForDuplicatedWidget(isAuthenticated) + duplicatedMeta = { ...duplicatedMeta, activeNoteId: noteId } + } else if (original.id === WidgetKeys.photo) { + delete duplicatedMeta.imageSrc + } + + let newInstanceId = `${original.id}-${Date.now().toString(36)}` + + if (isAuthenticated) { + const serverWidget = await createUserWidgetApi({ + widgetKey: def.id, + ui: 'CUSTOM', + workspace: 'HOME', + col: original.position.col, + row: original.position.row, + width: original.size.w, + height: original.size.h, + meta: duplicatedMeta, + }) + if (serverWidget?.instanceId) { + newInstanceId = serverWidget.instanceId + } + } + + const newWidget: StoredWidget = { + id: original.id, + instanceId: newInstanceId, + position: original.position, + size: original.size, + widgetId: newInstanceId, + meta: duplicatedMeta, + } + + const result = resolveLayoutChange({ + layout: runtimeLayoutRef.current, + operation: 'duplicate', + instanceId, + newWidget, + cols: colsRef.current, + registry: WIDGET_DEFINITIONS, + }) + + if (!result) { + if (isAuthenticated && isServerInstanceId(newInstanceId)) { + deleteUserWidgetApi(newInstanceId).catch(() => {}) + } + showToast(translateError('NO_SPACE_FOR_DUPLICATE') as string, 'error') + return false + } + + playNativeToastSound('success') + return commitMutation('duplicate', result, newInstanceId) + }, + [runtimeLayoutRef, colsRef, isVipRef, isAuthenticated, commitMutation] + ) + + const removeWidget = useCallback( + (instanceId: string): boolean => { + const result = resolveLayoutChange({ + layout: runtimeLayoutRef.current, + operation: 'remove', + instanceId, + cols: colsRef.current, + registry: WIDGET_DEFINITIONS, + }) + + if (!result) return false + + if (selectedInstanceIdRef.current === instanceId) { + setSelectedInstanceId(null) + } + + if (isAuthenticated && isServerInstanceId(instanceId)) { + deleteUserWidgetApi(instanceId).catch(() => {}) + } + + playNativeToastSound('warning') + return commitMutation('remove', result, instanceId) + }, + [ + runtimeLayoutRef, + colsRef, + selectedInstanceIdRef, + isAuthenticated, + setSelectedInstanceId, + commitMutation, + ] + ) + + const updateWidgetSettings = useCallback( + (instanceId: string, meta: any) => { + setSavedLayout((prev) => { + const updated = prev.map((w) => + w.instanceId === instanceId ? { ...w, meta } : w + ) + persistLayout(updated) + return updated + }) + applyRuntimeLayout((prev) => + prev.map((w) => (w.instanceId === instanceId ? { ...w, meta } : w)) + ) + + if (isAuthenticated && isServerInstanceId(instanceId)) { + updateUserWidgetApi(instanceId, { meta }).catch(() => {}) + } + }, + [isAuthenticated, persistLayout, applyRuntimeLayout, setSavedLayout] + ) + + const applyPresetLayout = useCallback( + async (presetWidgets: StoredWidget[]): Promise => { + if (!Array.isArray(presetWidgets) || presetWidgets.length === 0) return false + + const sanitized = sanitizeLayout(presetWidgets, DEFAULT_COLS) + const reflowed = reflowForColumns(sanitized, colsRef.current) + + markLocalEdit() + setSavedLayout(sanitized) + savedLayoutRef.current = sanitized + setRuntimeLayout(reflowed) + persistLayout(sanitized) + + if (isAuthenticated) { + triggerServerSync(sanitized) + } + + playNativeToastSound('success') + return true + }, + [ + isAuthenticated, + persistLayout, + triggerServerSync, + colsRef, + savedLayoutRef, + setSavedLayout, + setRuntimeLayout, + markLocalEdit, + ] + ) + + return { + commitMutation, + resizeWidget, + moveWidget, + updateWidgetVariant, + addWidget, + duplicateWidget, + removeWidget, + updateWidgetSettings, + applyPresetLayout, + } +} diff --git a/src/context/free-widget/use-widget-sync.ts b/src/context/free-widget/use-widget-sync.ts new file mode 100644 index 000000000..07a3a6dd5 --- /dev/null +++ b/src/context/free-widget/use-widget-sync.ts @@ -0,0 +1,209 @@ +import { useCallback, useEffect, useRef } from 'react' +import type React from 'react' +import { setToStorage, watchStorage } from '@/common/storage' +import { DEFAULT_COLS, DEFAULT_WIDGET_LAYOUT } from '@/layouts/widgets/layout-engine' +import type { StoredWidget } from '@/layouts/widgets/layout-engine/types' +import { applyInstanceIdMap, buildInstanceIdMap } from '@/layouts/widgets/instance-id' +import { migrateWidgetLayoutIfNeeded } from '@/layouts/widgets/migration' +import { + getUserWidgetsApi, + syncUserWidgetsApi, +} from '@/services/hooks/widgets/widget-sync.hook' +import { + reflowForColumns, + sanitizeLayout, + storedWidgetToApiPayload, +} from './widget-layout-helpers' + +interface UseWidgetSyncParams { + isAuthenticated: boolean + token: string | null | undefined + setSavedLayout: React.Dispatch> + savedLayoutRef: React.MutableRefObject + colsRef: React.MutableRefObject + applyRuntimeLayout: ( + next: StoredWidget[] | ((prev: StoredWidget[]) => StoredWidget[]) + ) => void + setIsLoaded: (loaded: boolean) => void +} + +export function useWidgetSync({ + isAuthenticated, + token, + setSavedLayout, + savedLayoutRef, + colsRef, + applyRuntimeLayout, + setIsLoaded, +}: UseWidgetSyncParams) { + const syncTimerRef = useRef(null) + const hasFetchedServerRef = useRef(false) + const hasLocalEditRef = useRef(false) + const lastPersistedSignatureRef = useRef(null) + const prevTokenRef = useRef(undefined) + + const persistLayout = useCallback((layoutToPersist: StoredWidget[]) => { + lastPersistedSignatureRef.current = JSON.stringify(layoutToPersist) + setToStorage('storedWidgets', layoutToPersist) + }, []) + + const loadFromLocalStorage = useCallback(async () => { + try { + const localLayout = await migrateWidgetLayoutIfNeeded() + const finalLayout = Array.isArray(localLayout) + ? sanitizeLayout(localLayout, DEFAULT_COLS) + : DEFAULT_WIDGET_LAYOUT + savedLayoutRef.current = finalLayout + setSavedLayout(finalLayout) + applyRuntimeLayout(reflowForColumns(finalLayout, colsRef.current)) + } catch (err) { + console.error('Failed to load local widget layout', err) + savedLayoutRef.current = DEFAULT_WIDGET_LAYOUT + setSavedLayout(DEFAULT_WIDGET_LAYOUT) + applyRuntimeLayout(DEFAULT_WIDGET_LAYOUT) + } finally { + setIsLoaded(true) + } + }, [applyRuntimeLayout, setSavedLayout, savedLayoutRef, colsRef, setIsLoaded]) + + const triggerServerSync = useCallback( + (currentLayout: StoredWidget[]) => { + if (!isAuthenticated) return + + if (syncTimerRef.current) { + clearTimeout(syncTimerRef.current) + } + + syncTimerRef.current = setTimeout(() => { + syncUserWidgetsApi({ + workspace: 'HOME', + widgets: currentLayout.map(storedWidgetToApiPayload), + }) + .then((synced) => { + if (!synced || synced.length === 0) return + const idMap = buildInstanceIdMap(currentLayout, synced) + if (idMap.size === 0) return + setSavedLayout((prev) => { + const updated = applyInstanceIdMap(prev, idMap) + savedLayoutRef.current = updated + persistLayout(updated) + return updated + }) + applyRuntimeLayout((prev) => applyInstanceIdMap(prev, idMap)) + }) + .catch(() => {}) + }, 1000) + }, + [ + isAuthenticated, + persistLayout, + applyRuntimeLayout, + setSavedLayout, + savedLayoutRef, + ] + ) + + const markLocalEdit = useCallback(() => { + hasLocalEditRef.current = true + }, []) + + useEffect(() => { + loadFromLocalStorage() + }, [loadFromLocalStorage]) + + useEffect(() => { + const unwatch = watchStorage('storedWidgets', (newValue) => { + if (!Array.isArray(newValue)) return + if (JSON.stringify(newValue) === lastPersistedSignatureRef.current) return + savedLayoutRef.current = newValue + setSavedLayout(newValue) + applyRuntimeLayout(reflowForColumns(newValue, colsRef.current)) + }) + return () => unwatch() + }, [applyRuntimeLayout, setSavedLayout, savedLayoutRef, colsRef]) + + useEffect(() => { + if (prevTokenRef.current === undefined) { + prevTokenRef.current = token + return + } + if (prevTokenRef.current === token) return + prevTokenRef.current = token + + if (syncTimerRef.current) { + clearTimeout(syncTimerRef.current) + syncTimerRef.current = null + } + hasFetchedServerRef.current = false + hasLocalEditRef.current = false + loadFromLocalStorage() + }, [token, loadFromLocalStorage]) + + useEffect(() => { + if (!isAuthenticated || hasFetchedServerRef.current) return + hasFetchedServerRef.current = true + + async function fetchAndReconcileWithServer() { + try { + const serverWidgets = await getUserWidgetsApi('HOME') + if (serverWidgets === null) return + + if (serverWidgets.length > 0) { + if (hasLocalEditRef.current) return + + const rawWidgets: StoredWidget[] = serverWidgets.map((sw) => ({ + id: sw.widgetKey as any, + instanceId: sw.instanceId, + widgetId: sw.instanceId, + position: { col: sw.col, row: sw.row }, + size: { w: sw.width, h: sw.height }, + meta: sw.meta, + disabled: sw.disabled, + })) + + const fromSrv: StoredWidget[] = sanitizeLayout( + rawWidgets, + DEFAULT_COLS + ) + + savedLayoutRef.current = fromSrv + setSavedLayout(fromSrv) + applyRuntimeLayout(reflowForColumns(fromSrv, colsRef.current)) + persistLayout(fromSrv) + } else { + const localLayout = await migrateWidgetLayoutIfNeeded() + if (localLayout && localLayout.length > 0) { + const synced = await syncUserWidgetsApi({ + workspace: 'HOME', + widgets: localLayout.map(storedWidgetToApiPayload), + }) + + if (synced && synced.length > 0) { + setSavedLayout((prev) => { + const idMap = buildInstanceIdMap(prev, synced) + if (idMap.size === 0) return prev + const updated = applyInstanceIdMap(prev, idMap) + savedLayoutRef.current = updated + persistLayout(updated) + return updated + }) + } + } + } + } catch (err) { + console.error('Background widget fetch error', err) + } + } + + fetchAndReconcileWithServer() + }, [ + isAuthenticated, + persistLayout, + applyRuntimeLayout, + setSavedLayout, + savedLayoutRef, + colsRef, + ]) + + return { persistLayout, triggerServerSync, markLocalEdit } +} diff --git a/src/context/free-widget/widget-layout-helpers.ts b/src/context/free-widget/widget-layout-helpers.ts new file mode 100644 index 000000000..5dce1ee3e --- /dev/null +++ b/src/context/free-widget/widget-layout-helpers.ts @@ -0,0 +1,85 @@ +import { + getBestAllowedSizeForColumns, + DEFAULT_COLS, + resolveLayoutChange, + validateLayout, +} from '@/layouts/widgets/layout-engine' +import type { StoredWidget } from '@/layouts/widgets/layout-engine/types' +import { dedupeInstanceIds, isServerInstanceId } from '@/layouts/widgets/instance-id' +import { WIDGET_DEFINITIONS } from '@/layouts/widgets/widget-registry' + +export function normalizeWidgetSizes( + layout: StoredWidget[], + cols: number +): StoredWidget[] { + let changed = false + + const normalized = layout.map((widget) => { + const definition = WIDGET_DEFINITIONS[widget.id] + if (!definition) return widget + + const isAllowed = definition.allowedSizes.some( + (s) => s.w === widget.size.w && s.h === widget.size.h + ) + if (isAllowed) return widget + + const size = getBestAllowedSizeForColumns( + definition.allowedSizes, + widget.size, + cols + ) + changed = true + return { ...widget, size } + }) + + return changed ? normalized : layout +} + +export function sanitizeLayout(layout: StoredWidget[], cols: number): StoredWidget[] { + const sized = normalizeWidgetSizes(dedupeInstanceIds(layout), cols) + + if (validateLayout(sized, cols)) { + return sized + } + + return ( + resolveLayoutChange({ + layout: sized, + operation: 'responsive-reflow', + cols, + }) ?? sized + ) +} + +export function storedWidgetToApiPayload(w: StoredWidget) { + return { + instanceId: isServerInstanceId(w.instanceId) ? w.instanceId : undefined, + widgetKey: w.id, + col: w.position.col, + row: w.position.row, + width: w.size.w, + height: w.size.h, + meta: w.meta, + disabled: w.disabled ?? false, + } +} + +export function reflowForColumns( + baseLayout: StoredWidget[], + targetCols: number +): StoredWidget[] { + const safeLayout = sanitizeLayout(baseLayout, targetCols) + + if (targetCols >= DEFAULT_COLS) { + return safeLayout + } + + const reflowed = resolveLayoutChange({ + layout: safeLayout, + operation: 'responsive-reflow', + cols: targetCols, + registry: WIDGET_DEFINITIONS, + }) + + return reflowed || safeLayout +} diff --git a/src/context/free-widget/widget-note-helpers.ts b/src/context/free-widget/widget-note-helpers.ts new file mode 100644 index 000000000..72d1a65af --- /dev/null +++ b/src/context/free-widget/widget-note-helpers.ts @@ -0,0 +1,30 @@ +import { getFromStorage, setToStorage } from '@/common/storage' +import { upsertNote } from '@/services/hooks/note/upsert-note.hook' +import type { FetchedNote } from '@/services/hooks/note/note.interface' + +export async function createNoteForDuplicatedWidget( + isAuthenticated: boolean +): Promise<{ noteId: string; note: FetchedNote }> { + const now = Date.now() + const localNote: FetchedNote = { + id: `note-${now}`, + title: '', + body: '', + createdAt: now, + updatedAt: now, + } + + if (isAuthenticated) { + try { + const serverNote = await upsertNote({ title: '', body: '' }) + if (serverNote?.id) { + localNote.id = serverNote.id + } + } catch {} + } + + const storedNotes = (await getFromStorage('notes_data')) || [] + await setToStorage('notes_data', [localNote, ...storedNotes]) + + return { noteId: localNote.id, note: localNote } +} diff --git a/src/context/general-setting.context.tsx b/src/context/general-setting.context.tsx index 1ddca2c0b..bca80d460 100644 --- a/src/context/general-setting.context.tsx +++ b/src/context/general-setting.context.tsx @@ -76,6 +76,12 @@ export function GeneralSettingProvider({ children }: { children: React.ReactNode loadGeneralSettings() }, []) + useEffect(() => { + const root = document.documentElement + root.classList.toggle('optimal-mode', Boolean(settings.isOptimalMode)) + return () => root.classList.remove('optimal-mode') + }, [settings.isOptimalMode]) + useEffect(() => { async function getTimeZone() { if (user?.timeZone && user.timeZone !== settings?.selected_timezone?.value) { diff --git a/src/context/notes.context.tsx b/src/context/notes.context.tsx index e21f8cbc9..3cef2d7b8 100644 --- a/src/context/notes.context.tsx +++ b/src/context/notes.context.tsx @@ -8,7 +8,7 @@ import { useState, } from 'react' import Analytics from '@/analytics' -import { getFromStorage, setToStorage } from '@/common/storage' +import { getFromStorage, setToStorage, watchStorage } from '@/common/storage' import { safeAwait } from '@/services/api' import { translateError } from '@/common/utils/translate-error' import { showToast } from '@/common/toast' @@ -22,9 +22,9 @@ interface NotesContextType { notes: FetchedNote[] activeNoteId: string | null setActiveNoteId: (id: string | null) => void - addNote: () => void + addNote: (initial?: Partial) => Promise updateNote: (id: string, updates: Partial) => void - deleteNote: (id: string) => void + deleteNote: (id: string) => Promise isSaving: boolean isCreatingNote: boolean isRefetching: boolean @@ -59,21 +59,29 @@ export function NotesProvider({ children }: { children: ReactNode }) { } loadNotes() + return watchStorage('notes_data', (newVal) => { + if (newVal && Array.isArray(newVal)) { + setNotes(newVal) + } + }) }, []) useEffect(() => { sync(fetchedNotes || [], true) }, [dataUpdatedAt]) - const addNote = async () => { - if (isCreatingNote) return + const addNote = async ( + initial?: Partial + ): Promise => { + if (isCreatingNote) return null setIsCreatingNote(true) const newNote: FetchedNote = { id: '', - title: '', - body: '', + title: initial?.title || '', + body: initial?.body || '', + priority: initial?.priority, createdAt: Date.now(), updatedAt: Date.now(), } @@ -86,27 +94,33 @@ export function NotesProvider({ children }: { children: ReactNode }) { if (er) { showToast(translateError(er) as any, 'error') - return + return null } sync([createdNote, ...notes], true) setActiveNoteId(createdNote.id) Analytics.event('add_notes') + return createdNote } const updateNote = (id: string, updates: Partial) => { - setIsSaving(true) + setNotes((prev) => { + const updated = prev.map((n) => (n.id === id ? { ...n, ...updates } : n)) + setToStorage('notes_data', updated) + return updated + }) if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current) } saveTimeoutRef.current = setTimeout(async () => { + setIsSaving(true) Analytics.event('update_notes') const [error, updatedNote] = await safeAwait( upsertNoteAsync({ - title: updates.title || null, - body: updates.body || null, + title: updates.title ?? null, + body: updates.body ?? null, id, priority: updates.priority, }) @@ -121,14 +135,15 @@ export function NotesProvider({ children }: { children: ReactNode }) { return showToast(`${key}: ${translatedError[key]}`, 'error') } - let noteIndex = notes.findIndex((n) => n.id === id) - if (noteIndex === -1) return showToast('یادداشت پیدا نشد', 'error') - notes[noteIndex] = updatedNote - sync(notes, true) - }, 400) + setNotes((prev) => { + const updated = prev.map((n) => (n.id === id ? updatedNote : n)) + setToStorage('notes_data', updated) + return updated + }) + }, 500) } - const onDeleteNote = async (id: string) => { + const onDeleteNote = async (id: string): Promise => { setIsSaving(true) const [err, _] = await safeAwait(removeNoteAsync(id)) if (err) { @@ -143,9 +158,9 @@ export function NotesProvider({ children }: { children: ReactNode }) { } const sync = (data: FetchedNote[], syncLocal: boolean) => { - setNotes(data) + setNotes([...data]) if (syncLocal) { - setToStorage('notes_data', notes) + setToStorage('notes_data', data) } } diff --git a/src/context/theme.context.tsx b/src/context/theme.context.tsx index 4082b7ec0..b94e789a4 100644 --- a/src/context/theme.context.tsx +++ b/src/context/theme.context.tsx @@ -53,15 +53,24 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) { useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { - if ( - event.altKey && - event.key.toLowerCase() === 't' && - (event.ctrlKey || event.metaKey) - ) { + const isT = event.key?.toLowerCase() === 't' || event.code === 'KeyT' + + if (event.altKey && isT && (event.ctrlKey || event.metaKey)) { + const target = event.target as HTMLElement | null + if ( + target && + (target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.isContentEditable) + ) { + return + } + event.preventDefault() const currentIndex = freeThemes.indexOf(theme as Theme) - const nextIndex = (currentIndex + 1) % freeThemes.length + const nextIndex = + currentIndex === -1 ? 0 : (currentIndex + 1) % freeThemes.length const nextTheme = freeThemes[nextIndex] setThemeCallback(nextTheme) } diff --git a/src/context/wallpaper.context.tsx b/src/context/wallpaper.context.tsx index d9003b7c9..27ce47ac5 100644 --- a/src/context/wallpaper.context.tsx +++ b/src/context/wallpaper.context.tsx @@ -1,6 +1,6 @@ import type { AxiosError } from 'axios' import { createContext, useContext, useEffect, useState } from 'react' -import { getFromStorage, setToStorage } from '@/common/storage' +import { getFromStorage, removeFromStorage, setToStorage } from '@/common/storage' import { callEvent, listenEvent } from '@/common/utils/call-event' import type { StoredWallpaper, Wallpaper } from '@/common/wallpaper.interface' import { safeAwait } from '@/services/api' @@ -9,9 +9,10 @@ import { translateError } from '@/common/utils/translate-error' import Analytics from '@/analytics' import { showToast } from '@/common/toast' import { useQueryClient } from '@tanstack/react-query' -import { playAlarm } from '@/common/play-alarm' import { useAuth } from '@/context/auth.context' +import { getRandomWallpaper } from '@/services/hooks/wallpapers/get-wallpaper-categories.hook' + interface WallpaperContextValue { selectedBackground: Wallpaper | null customWallpaper: Wallpaper | null @@ -19,6 +20,7 @@ interface WallpaperContextValue { allWallpapers: (fetchedWallpapers?: Wallpaper[]) => Wallpaper[] handleSelectBackground: (wallpaper: Wallpaper) => Promise handleCustomWallpaperChange: (wallpaper: Wallpaper) => void + handleRemoveCustomWallpaper: () => Promise syncWithFetchedWallpapers: (wallpapers: Wallpaper[]) => void } @@ -36,17 +38,23 @@ export function WallpaperProvider({ children }: { children: React.ReactNode }) { useEffect(() => { async function loadInitialWallpaper() { + const customWp: Wallpaper | null = await getFromStorage('customWallpaper') + if (customWp) { + setCustomWallpaper(customWp) + } + const wallpaper: StoredWallpaper | null = await getFromStorage('wallpaper') if (!wallpaper) return setCurrentStoredWallpaper(wallpaper) - if (wallpaper.id === 'custom-wallpaper') { - const customWp = await getFromStorage('customWallpaper') - if (customWp) { - setCustomWallpaper(customWp) - setSelectedBackground(customWp) - } + if ( + wallpaper.id === 'custom-wallpaper' || + (wallpaper as Wallpaper).isCustom + ) { + const wp = customWp || (wallpaper as Wallpaper) + setCustomWallpaper(wp) + setSelectedBackground(wp) } else if (wallpaper.type === 'GRADIENT' && wallpaper.gradient) { setSelectedBackground({ id: wallpaper.id, @@ -66,8 +74,18 @@ export function WallpaperProvider({ children }: { children: React.ReactNode }) { if (wallpaper) callEvent('wallpaper_change', wallpaper) }) + const customSyncEvent = listenEvent('custom_wallpaper_sync', (wp) => { + if (wp) { + setCustomWallpaper(wp) + setSelectedBackground(wp) + } else { + setCustomWallpaper(null) + } + }) + return () => { event() + customSyncEvent() } }, []) @@ -96,6 +114,8 @@ export function WallpaperProvider({ children }: { children: React.ReactNode }) { setToStorage('wallpaper', wallpaperData) if (selectedBackground.id === 'custom-wallpaper') { setToStorage('customWallpaper', selectedBackground) + } else { + removeFromStorage('customWallpaper') } callEvent('wallpaper_change', wallpaperData) @@ -106,8 +126,12 @@ export function WallpaperProvider({ children }: { children: React.ReactNode }) { setSelectedBackground(wallpaper) return } + + setCustomWallpaper(null) + removeFromStorage('customWallpaper') + if (wallpaper.coin && !isAuthenticated) { - showToast('برای انتخاب این تصویر زمینه باید وارد حساب کاربری شوید.', 'error') + showToast('برای انتخاب این تصویر زمینه باید وارد حساب کاربری شوید', 'error') return } @@ -159,7 +183,6 @@ export function WallpaperProvider({ children }: { children: React.ReactNode }) { if (wallpaper.coin && !wallpaper.isOwned) { showToast('هووورا! تصویر زمینه فعال شد 🎉', 'success') queryClient.invalidateQueries({ queryKey: ['userProfile'] }) - playAlarm('market') } if (!isSet) setSelectedBackground(responseWallpaper) @@ -173,6 +196,32 @@ export function WallpaperProvider({ children }: { children: React.ReactNode }) { handleSelectBackground(newWallpaper) } + const handleRemoveCustomWallpaper = async () => { + setCustomWallpaper(null) + await setToStorage('customWallpaper', null as any) + if (selectedBackground?.id === 'custom-wallpaper') { + const [error, randomWallpaper] = await safeAwait( + getRandomWallpaper() + ) + if (randomWallpaper) { + handleSelectBackground(randomWallpaper) + } else { + handleSelectBackground({ + id: 'gradient-a1c4fd-c2e9fb', + name: 'گرادیان', + type: 'GRADIENT', + src: '', + previewSrc: '', + gradient: { + from: '#a1c4fd', + to: '#c2e9fb', + direction: 'to-r', + }, + }) + } + } + } + const allWallpapers = (fetchedWallpapers: Wallpaper[] = []) => { if (customWallpaper) return [...fetchedWallpapers, customWallpaper] return fetchedWallpapers @@ -187,6 +236,7 @@ export function WallpaperProvider({ children }: { children: React.ReactNode }) { allWallpapers, handleSelectBackground, handleCustomWallpaperChange, + handleRemoveCustomWallpaper, syncWithFetchedWallpapers, }} > diff --git a/src/context/widget-visibility.context.tsx b/src/context/widget-visibility.context.tsx index 611308d76..6ab2f7107 100644 --- a/src/context/widget-visibility.context.tsx +++ b/src/context/widget-visibility.context.tsx @@ -8,150 +8,27 @@ import { } from 'react' import Analytics from '@/analytics' import { getFromStorage, setToStorage } from '@/common/storage' -import CalendarLayout from '@/layouts/widgets/calendar/calendar' -import { ComboWidget } from '@/layouts/widgets/combo-widget/combo-widget.layout' -import { NetworkLayout } from '@/layouts/widgets/network/network.layout' -import { NewsLayout } from '@/layouts/widgets/news/news.layout' -import { ToolsLayout } from '@/layouts/widgets/tools/tools.layout' -import { WeatherLayout } from '@/layouts/widgets/weather/weather.layout' -import { WigiArzLayout } from '@/layouts/widgets/wigi-arz/wigi_arz.layout' -import { useAuth } from './auth.context' -import { CurrencyProvider } from './currency.context' import { showToast } from '@/common/toast' -import { YadkarWidget } from '@/layouts/widgets/yadkar/yadkar' -import { HabitsLayout } from '@/layouts/widgets/habit/habits.layout' - -export enum WidgetKeys { - comboWidget = 'comboWidget', - arzLive = 'arzLive', - news = 'news', - calendar = 'calendar', - weather = 'weather', - todos = 'todos', - tools = 'tools', - notes = 'notes', - youtube = 'youtube', - wigiPad = 'wigiPad', - network = 'network', - yadKar = 'yadKar', - HabitTracker = 'HabitTracker', -} -export interface WidgetItem { - id: WidgetKeys - emoji: string - label: string - node: any - order: number - canToggle?: boolean - isNew?: boolean - disabled?: boolean - soon?: boolean - popular?: boolean - isBeta?: boolean -} - -export const widgetItems: WidgetItem[] = [ - { - id: WidgetKeys.calendar, - emoji: '📅', - label: 'تقویم', - order: 0, - node: , - canToggle: true, - popular: true, - }, - { - id: WidgetKeys.yadKar, - emoji: '📒', - label: 'یادکار (وظایف/یادداشت/عادت‌ها)', - order: 0, - node: , - canToggle: true, - isNew: false, - }, - { - id: WidgetKeys.tools, - emoji: '🧰', - label: 'ابزارها', - order: 1, - node: , - canToggle: true, - }, - - { - id: WidgetKeys.weather, - emoji: '🌤️', - label: 'آب و هوا', - order: 3, - node: , - canToggle: true, - }, - { - id: WidgetKeys.comboWidget, - emoji: '🔗', - label: 'ویجت ترکیبی (ارز و اخبار)', - order: 4, - node: ( - - - - ), - canToggle: true, - popular: true, - }, - { - id: WidgetKeys.arzLive, - emoji: '💰', - label: 'ویجی ارز', - order: 5, - node: ( - - - - ), - canToggle: true, - }, - { - id: WidgetKeys.news, - emoji: '📰', - label: 'ویجی نیوز', - order: 6, - node: , - canToggle: true, - }, +import { + getUserWidgetsApi, + syncUserWidgetsApi, +} from '@/services/hooks/widgets/widget-sync.hook' +import { useAuth } from './auth.context' +import { type WidgetItem, WidgetKeys } from '@/layouts/widgets/layout-engine/types' +import { widgetItems } from '@/layouts/widgets/widget-registry' - { - id: WidgetKeys.network, - emoji: '🌐', - label: 'شبکه', - order: 9, - node: , - canToggle: true, - isNew: false, - }, - { - id: WidgetKeys.HabitTracker, - emoji: '🎯', - label: 'عادات', - order: 10, - node: , - canToggle: true, - isNew: true, - isBeta: true, - }, -] +export { WidgetKeys, type WidgetItem, widgetItems } interface WidgetVisibilityContextType { visibility: WidgetKeys[] toggleWidget: (widgetId: WidgetKeys) => void - reorderWidgets: (sourceIndex: number, destinationIndex: number) => void getSortedWidgets: () => WidgetItem[] } const defaultVisibility: WidgetKeys[] = [ WidgetKeys.calendar, - WidgetKeys.tools, WidgetKeys.yadKar, + WidgetKeys.tools, WidgetKeys.comboWidget, ] export const MAX_VISIBLE_WIDGETS = 5 @@ -169,69 +46,135 @@ const getDefaultWidgetOrders = (): Record => { } export function WidgetVisibilityProvider({ children }: { children: ReactNode }) { - const [visibility, setVisibility] = useState([]) + const [visibility, setVisibility] = useState(defaultVisibility) const [widgetOrders, setWidgetOrders] = useState>(getDefaultWidgetOrders) - const firstRender = useRef(true) + const syncTimerRef = useRef(null) + const hasFetchedServerRef = useRef(false) const { isAuthenticated } = useAuth() - const saveActiveWidgets = () => { + const saveActiveWidgets = ( + currentVisibility = visibility, + currentOrders = widgetOrders + ) => { const activeWidgets = widgetItems - .filter((item) => visibility.includes(item.id)) + .filter((item) => currentVisibility.includes(item.id)) .map((item) => ({ ...item, - order: widgetOrders[item.id] ?? item.order, + order: currentOrders[item.id] ?? item.order, })) setToStorage('activeWidgets', activeWidgets) + + if (isAuthenticated) { + if (syncTimerRef.current) { + clearTimeout(syncTimerRef.current) + } + syncTimerRef.current = setTimeout(() => { + syncUserWidgetsApi({ + workspace: 'HOME', + widgets: activeWidgets.map((w, index) => ({ + widgetKey: w.id, + order: w.order ?? index, + col: 0, + row: 0, + width: 2, + height: 3, + })), + }).catch(() => {}) + }, 1000) + } } useEffect(() => { - async function loadSettings() { - const storedVisibility = await getFromStorage('activeWidgets') - if (storedVisibility) { - const visibilityIds = storedVisibility - .filter((item) => widgetItems.some((w) => w.id === item.id)) - .map((item: any) => item.id as WidgetKeys) - + async function initActiveWidgets() { + try { + const storedVisibility = await getFromStorage('activeWidgets') if ( - visibilityIds.includes(WidgetKeys.todos) || - visibilityIds.includes(WidgetKeys.notes) + storedVisibility && + Array.isArray(storedVisibility) && + storedVisibility.length > 0 ) { - Analytics.event('yadkar_merged') - - visibilityIds.splice(visibilityIds.indexOf(WidgetKeys.todos), 1) - visibilityIds.splice(visibilityIds.indexOf(WidgetKeys.notes), 1) - - visibilityIds.push(WidgetKeys.yadKar) - saveActiveWidgets() + let visibilityIds = storedVisibility + .filter((item) => widgetItems.some((w) => w.id === item.id)) + .map((item: any) => item.id as WidgetKeys) + + if ( + visibilityIds.includes(WidgetKeys.todos) || + visibilityIds.includes(WidgetKeys.notes) + ) { + Analytics.event('yadkar_merged') + visibilityIds = visibilityIds.filter( + (id) => id !== WidgetKeys.todos && id !== WidgetKeys.notes + ) + if (!visibilityIds.includes(WidgetKeys.yadKar)) { + visibilityIds.push(WidgetKeys.yadKar) + } + } + + const orders: Record = getDefaultWidgetOrders() + for (const item of storedVisibility) { + orders[item.id as WidgetKeys] = + item.order ?? getDefaultWidgetOrders()[item.id as WidgetKeys] + } + + if (visibilityIds.length > 0) { + setVisibility(visibilityIds) + setWidgetOrders(orders) + } } + } catch (err) { + console.error('Failed to load local active widgets', err) + } + } - setVisibility(visibilityIds) + initActiveWidgets() + }, []) - const orders: Record = {} as Record< - WidgetKeys, - number - > - for (const item of storedVisibility) { - orders[item.id as WidgetKeys] = - item.order ?? getDefaultWidgetOrders()[item.id as WidgetKeys] + useEffect(() => { + if (!isAuthenticated || hasFetchedServerRef.current) return + hasFetchedServerRef.current = true + + let isCancelled = false + + async function fetchAndReconcileVisibility() { + try { + const serverWidgets = await getUserWidgetsApi('HOME') + + if (isCancelled) return + + if (serverWidgets && serverWidgets.length > 0) { + const visibilityIds = serverWidgets + .map((sw) => sw.widgetKey as WidgetKeys) + .filter((k) => widgetItems.some((w) => w.id === k)) + + const orders: Record = getDefaultWidgetOrders() + for (const sw of serverWidgets) { + orders[sw.widgetKey as WidgetKeys] = sw.order ?? 0 + } + + if (visibilityIds.length > 0) { + setVisibility(visibilityIds) + setWidgetOrders(orders) + const activeWidgets = widgetItems + .filter((item) => visibilityIds.includes(item.id)) + .map((item) => ({ + ...item, + order: orders[item.id] ?? item.order, + })) + setToStorage('activeWidgets', activeWidgets) + } } - setWidgetOrders(orders) - } else { - setVisibility(defaultVisibility) - setWidgetOrders(getDefaultWidgetOrders()) + } catch (err) { + console.error('Background visibility fetch error', err) } - firstRender.current = false } - loadSettings() - }, []) + fetchAndReconcileVisibility() - useEffect(() => { - if (!firstRender.current) { - saveActiveWidgets() + return () => { + isCancelled = true } - }, [visibility, widgetOrders]) + }, [isAuthenticated]) const toggleWidget = (widgetId: WidgetKeys) => { setVisibility((prev) => { @@ -256,27 +199,9 @@ export function WidgetVisibilityProvider({ children }: { children: ReactNode }) } else { Analytics.event(`widget_add_${widgetId}`) } - return newVisibility - }) - } - - const reorderWidgets = (sourceIndex: number, destinationIndex: number) => { - const visibleWidgets = getSortedWidgets() - - if (sourceIndex === destinationIndex) return - - setWidgetOrders((prev) => { - const newOrders = { ...prev } - const reorderedWidgets = [...visibleWidgets] - const [draggedWidget] = reorderedWidgets.splice(sourceIndex, 1) - reorderedWidgets.splice(destinationIndex, 0, draggedWidget) - - reorderedWidgets.forEach((widget, index) => { - newOrders[widget.id] = index - }) - - return newOrders + saveActiveWidgets(newVisibility, widgetOrders) + return newVisibility }) } @@ -294,8 +219,6 @@ export function WidgetVisibilityProvider({ children }: { children: ReactNode }) value={{ visibility, toggleWidget, - - reorderWidgets, getSortedWidgets, }} > diff --git a/src/hooks/__tests__/use-delayed-unmount.test.ts b/src/hooks/__tests__/use-delayed-unmount.test.ts new file mode 100644 index 000000000..760cc94dc --- /dev/null +++ b/src/hooks/__tests__/use-delayed-unmount.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'bun:test' +import { + EXIT_ANIMATION_MS, + isRetainableValue, +} from '@/common/utils/animation-timing' + +describe('EXIT_ANIMATION_MS', () => { + it('outlasts the daisyUI modal transition of 300ms', () => { + expect(EXIT_ANIMATION_MS).toBeGreaterThan(300) + }) +}) + +describe('isRetainableValue', () => { + it('holds real payloads through the exit animation', () => { + expect(isRetainableValue({ id: 1 })).toBe(true) + expect(isRetainableValue('pomodoro')).toBe(true) + }) + + it('holds falsy values that are still real content', () => { + expect(isRetainableValue(0)).toBe(true) + expect(isRetainableValue('')).toBe(true) + }) + + it('releases the cleared states', () => { + expect(isRetainableValue(null)).toBe(false) + expect(isRetainableValue(undefined)).toBe(false) + expect(isRetainableValue(false)).toBe(false) + }) +}) diff --git a/src/hooks/use-container-size.ts b/src/hooks/use-container-size.ts new file mode 100644 index 000000000..ea0bc5b75 --- /dev/null +++ b/src/hooks/use-container-size.ts @@ -0,0 +1,64 @@ +import { type RefObject, useLayoutEffect, useState } from 'react' + +export interface ContainerSize { + width: number + height: number +} + +export function useContainerSize( + ref: RefObject +): ContainerSize { + const [size, setSize] = useState({ + width: 0, + height: 0, + }) + + useLayoutEffect(() => { + const element = ref.current + if (!element) return + + let animationFrameId: number | null = null + + const updateSize = (entries: ResizeObserverEntry[]) => { + const entry = entries[0] + if (!entry) return + + const width = + entry.contentBoxSize?.[0]?.inlineSize ?? entry.contentRect.width + + const height = + entry.contentBoxSize?.[0]?.blockSize ?? entry.contentRect.height + + if (animationFrameId !== null) { + cancelAnimationFrame(animationFrameId) + } + + animationFrameId = requestAnimationFrame(() => { + setSize({ + width, + height, + }) + }) + } + + const observer = new ResizeObserver(updateSize) + observer.observe(element) + + const initialRect = element.getBoundingClientRect() + if (initialRect.width > 0 || initialRect.height > 0) { + setSize({ + width: initialRect.width, + height: initialRect.height, + }) + } + + return () => { + if (animationFrameId !== null) { + cancelAnimationFrame(animationFrameId) + } + observer.disconnect() + } + }, [ref]) + + return size +} diff --git a/src/hooks/use-contrast-text-color.tsx b/src/hooks/use-contrast-text-color.tsx deleted file mode 100644 index 8dd56f0eb..000000000 --- a/src/hooks/use-contrast-text-color.tsx +++ /dev/null @@ -1,15 +0,0 @@ -const getLuminance = (hex: string) => { - const rgb = Number.parseInt(hex.replace('#', ''), 16) - const r = (rgb >> 16) & 0xff - const g = (rgb >> 8) & 0xff - const b = rgb & 0xff - return 0.2126 * (r / 255) + 0.7152 * (g / 255) + 0.0722 * (b / 255) -} - -export const useContrastTextColor = (bgColor: string | undefined) => { - if (!bgColor) return '#000000' - const luminance = getLuminance(bgColor) - if (luminance < 0.4) return '#FFFFFF' - if (luminance > 0.8) return '#000000' - return luminance < 0.5 ? '#FFFFFF' : '#000000' -} diff --git a/src/hooks/use-debounced-value.tsx b/src/hooks/use-debounced-value.tsx deleted file mode 100644 index 77375abb2..000000000 --- a/src/hooks/use-debounced-value.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { useEffect, useState } from 'react' - -export function useDebouncedValue(value: T, delay = 500): T { - const [debouncedValue, setDebouncedValue] = useState(value) - - useEffect(() => { - const timer = setTimeout(() => { - setDebouncedValue(value) - }, delay) - - return () => clearTimeout(timer) - }, [value, delay]) - - return debouncedValue -} diff --git a/src/hooks/use-delayed-unmount.ts b/src/hooks/use-delayed-unmount.ts new file mode 100644 index 000000000..6e4e8bbd0 --- /dev/null +++ b/src/hooks/use-delayed-unmount.ts @@ -0,0 +1,28 @@ +import { useEffect, useState } from 'react' +import { useGeneralSetting } from '@/context/general-setting.context' +import { EXIT_ANIMATION_MS } from '@/common/utils/animation-timing' + +export { EXIT_ANIMATION_MS } + +export function useDelayedUnmount(isOpen: boolean, delayMs: number): boolean { + const { isOptimalMode } = useGeneralSetting() + const [shouldRender, setShouldRender] = useState(isOpen) + const effectiveDelay = isOptimalMode ? 0 : delayMs + + useEffect(() => { + if (isOpen) { + setShouldRender(true) + return + } + + if (effectiveDelay <= 0) { + setShouldRender(false) + return + } + + const timer = setTimeout(() => setShouldRender(false), effectiveDelay) + return () => clearTimeout(timer) + }, [isOpen, effectiveDelay]) + + return isOpen || shouldRender +} diff --git a/src/hooks/use-horizontal-wheel-scroll.ts b/src/hooks/use-horizontal-wheel-scroll.ts new file mode 100644 index 000000000..2733bc42d --- /dev/null +++ b/src/hooks/use-horizontal-wheel-scroll.ts @@ -0,0 +1,30 @@ +import { type RefObject, useEffect } from 'react' + +export function useHorizontalWheelScroll(ref: RefObject) { + useEffect(() => { + const element = ref.current + if (!element) return + + const onWheel = (event: WheelEvent) => { + if (event.ctrlKey) return + if (element.scrollWidth <= element.clientWidth) return + + const delta = + Math.abs(event.deltaY) > Math.abs(event.deltaX) + ? event.deltaY + : event.deltaX + if (delta === 0) return + + const isRtl = getComputedStyle(element).direction === 'rtl' + const before = element.scrollLeft + element.scrollLeft = before + (isRtl ? -delta : delta) + + if (element.scrollLeft !== before) { + event.preventDefault() + } + } + + element.addEventListener('wheel', onWheel, { passive: false }) + return () => element.removeEventListener('wheel', onWheel) + }, [ref]) +} diff --git a/src/layouts/setting/tabs/wallpapers/hooks/use-lazy-load.ts b/src/hooks/use-lazy-load.ts similarity index 100% rename from src/layouts/setting/tabs/wallpapers/hooks/use-lazy-load.ts rename to src/hooks/use-lazy-load.ts diff --git a/src/icons/packs/default.tsx b/src/icons/packs/default.tsx index 679e224a8..0948a7be8 100644 --- a/src/icons/packs/default.tsx +++ b/src/icons/packs/default.tsx @@ -36,13 +36,16 @@ import { LuChevronLeft, LuChevronRight, LuChevronUp, + LuCopy, LuDownload, LuHeart, LuHistory, LuLayers, LuLayoutTemplate, + LuMinus, LuPen, LuPlus, + LuTicket, LuX, } from 'react-icons/lu' import { @@ -65,6 +68,7 @@ import { TbUsersPlus, TbBookmarkPlus, TbCameraPlus, + TbPhotoPlus, TbWind, TbEdit, TbArrowsUpDown, @@ -73,6 +77,7 @@ import { TbNotebook, TbArrowsRightLeft, TbCloudUpload, + TbInfinity, } from 'react-icons/tb' import { MdDragIndicator, @@ -111,18 +116,37 @@ import { import { FaFolder, FaFolderOpen } from 'react-icons/fa' import { GoArchive, GoKebabHorizontal, GoTasklist } from 'react-icons/go' import { IoMdAddCircle, IoMdHelp } from 'react-icons/io' -import { FaArrowDownLong, FaArrowUpLong, FaPhotoFilm, FaTreeCity } from 'react-icons/fa6' -import { BiCalendar, BiLogoGoogle, BiRss } from 'react-icons/bi' +import { + FaArrowDownLong, + FaArrowUpLong, + FaExclamation, + FaPhotoFilm, + FaTreeCity, +} from 'react-icons/fa6' +import { BiCalendar, BiLogoGoogle, BiRss, BiWifi, BiWifiOff } from 'react-icons/bi' import { FcGoogle } from 'react-icons/fc' import { CiLocationOn, CiPause1 } from 'react-icons/ci' import { WiCloudy, WiHumidity } from 'react-icons/wi' import { RiApps2AiLine, RiQuillPenAiLine } from 'react-icons/ri' -import { AiOutlineDrag } from 'react-icons/ai' +import { AiOutlineCrown, AiOutlineDrag } from 'react-icons/ai' import { HiOutlineViewGridAdd } from 'react-icons/hi' import { BsFilterLeft, BsGenderAmbiguous } from 'react-icons/bs' import { IoPricetagOutline } from 'react-icons/io5' import { VscColorMode, VscMegaphone, VscPlay, VscRecordKeys } from 'react-icons/vsc' +function ProDiamondIcon(props: React.SVGProps) { + return ( + + + + + ) +} + export const defaultIcons = { home: HiHome, calendar: BiCalendar, @@ -156,6 +180,7 @@ export const defaultIcons = { menuOption: GoKebabHorizontal, uploadImage: TbCloudUpload, image: FaImage, + imagePlus: TbPhotoPlus, help: IoMdHelp, outlineBookmark: HiOutlineBookmark, outlineFolder: HiOutlineFolder, @@ -225,6 +250,8 @@ export const defaultIcons = { outlineFilterListOff: MdOutlineFilterListOff, outlineFilterList: MdOutlineFilterList, crown: FaCrown, + outlineCrown: AiOutlineCrown, + diamond: ProDiamondIcon, filterLeft: BsFilterLeft, upDown: TbArrowsUpDown, coffee: FiCoffee, @@ -254,4 +281,11 @@ export const defaultIcons = { bookmark: HiBookmark, userSecret: FaUserSecret, filter: FiFilter, + exclamation: FaExclamation, + copy: LuCopy, + wifi: BiWifi, + wifiOff: BiWifiOff, + infinity: TbInfinity, + ticket: LuTicket, + minus: LuMinus, } satisfies IconMap diff --git a/src/icons/types.ts b/src/icons/types.ts index f64e87474..8b902d82a 100644 --- a/src/icons/types.ts +++ b/src/icons/types.ts @@ -33,6 +33,7 @@ export type IconName = | 'menuOption' | 'uploadImage' | 'image' + | 'imagePlus' | 'help' | 'outlineBookmark' | 'outlineFolder' @@ -103,6 +104,8 @@ export type IconName = | 'outlineFilterList' | 'outlineFilterListOff' | 'crown' + | 'outlineCrown' + | 'diamond' | 'filterLeft' | 'upDown' | 'coffee' @@ -132,4 +135,11 @@ export type IconName = | 'bookmark' | 'userSecret' | 'filter' + | 'exclamation' + | 'copy' + | 'wifi' + | 'wifiOff' + | 'infinity' + | 'ticket' + | 'minus' export type IconMap = Record diff --git a/src/index.css b/src/index.css index ebd64704f..42cf2ec30 100644 --- a/src/index.css +++ b/src/index.css @@ -57,6 +57,28 @@ body { @apply border-base-300; } +@utility transition-ui { + transition-property: color, background-color, border-color, outline-color, box-shadow, opacity, transform; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +@utility focus-ring { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} + +@utility rounded-card { + border-radius: 0.75rem; +} + +.font-latin { + font-family: "Inter", "Segoe UI", system-ui, sans-serif; + -moz-font-feature-settings: normal; + -webkit-font-feature-settings: normal; + font-feature-settings: normal; +} + input[type="number"]::-webkit-inner-spin-button, input[type="number"]::-webkit-outer-spin-button { -webkit-appearance: none; @@ -160,12 +182,12 @@ input[type="color"]::-moz-color-swatch { } .scrollbar-thumb-base-300::-webkit-scrollbar-thumb { - background: hsl(var(--bc) / 0.2); + background: color-mix(in oklab, var(--color-base-content) 20%, transparent); border-radius: 2px; } .scrollbar-thumb-base-300::-webkit-scrollbar-thumb:hover { - background: hsl(var(--bc) / 0.3); + background: color-mix(in oklab, var(--color-base-content) 30%, transparent); } .scrollbar-none { @@ -177,6 +199,13 @@ input[type="color"]::-moz-color-swatch { display: none; } +html.optimal-mode *, +html.optimal-mode *::before, +html.optimal-mode *::after { + transition-duration: 0s !important; + transition-delay: 0s !important; +} + html.modal-isActive { scrollbar-width: none !important; } @@ -205,24 +234,6 @@ img { -o-user-select: none; } -/* Modal animations */ -@keyframes modal-in { - from { - opacity: 0; - transform: scale(0.95) translateY(10px); - } - - to { - opacity: 1; - transform: scale(1) translateY(0); - } -} - -.animate-modal-in { - animation: modal-in 0.2s ease-out; -} - - /* updater */ /* Vertical Writing Mode */ @@ -249,9 +260,89 @@ img { } .rounded-widget { - @apply rounded-2xl + @apply rounded-3xl } .z-popover { z-index: 9999 !important; +} + +@keyframes widget-wiggle { + 0% { + transform: rotate(-0.35deg); + } + + 100% { + transform: rotate(0.35deg); + } +} + +@keyframes widget-wiggle-alt { + 0% { + transform: rotate(0.3deg); + } + + 100% { + transform: rotate(-0.3deg); + } +} + +.animate-widget-wiggle-a { + animation: widget-wiggle 0.46s ease-in-out infinite alternate; + transform-origin: 50% 10%; +} + +.animate-widget-wiggle-b { + animation: widget-wiggle-alt 0.48s ease-in-out infinite alternate; + animation-delay: -0.15s; + transform-origin: 50% 10%; +} + +.animate-widget-wiggle-c { + animation: widget-wiggle 0.52s ease-in-out infinite alternate; + animation-delay: -0.3s; + transform-origin: 50% 10%; +} + +.widget-canvas-item-transition { + transition: transform 200ms ease-out, width 200ms ease-out, height 200ms ease-out; +} + +@keyframes bounce-slow { + + 0%, + 100% { + transform: translateY(-10%); + animation-timing-function: cubic-bezier(0.8, 0, 1, 1); + } + + 50% { + transform: translateY(0); + animation-timing-function: cubic-bezier(0, 0, 0.2, 1); + } +} + +.animate-bounce-slow { + animation: bounce-slow 2.8s ease-in-out infinite; +} + +/* Flip clock leaf animations */ +@keyframes flip-fold-down { + 0% { + transform: rotateX(0deg); + } + + 100% { + transform: rotateX(-90deg); + } +} + +@keyframes flip-drop-down { + 0% { + transform: rotateX(90deg); + } + + 100% { + transform: rotateX(0deg); + } } \ No newline at end of file diff --git a/src/layouts/bookmark/__tests__/bookmark.test.ts b/src/layouts/bookmark/__tests__/bookmark.test.ts new file mode 100644 index 000000000..f21a3d43d --- /dev/null +++ b/src/layouts/bookmark/__tests__/bookmark.test.ts @@ -0,0 +1,399 @@ +import { describe, expect, it } from 'bun:test' +import type { Bookmark } from '../types/bookmark.types' +import type { FetchedBookmark } from '@/services/hooks/bookmark/get-bookmarks.hook' +import type { WidgetSize } from '../../widgets/layout-engine/types' + +function mapBookmarks(fetchedBookmarks: FetchedBookmark[]): Bookmark[] { + return fetchedBookmarks.map((bookmark) => ({ + id: bookmark.offlineId || bookmark.id, + title: bookmark.title, + type: bookmark.type, + parentId: bookmark.parentId, + isLocal: true, + isManageable: bookmark.isManageable, + url: bookmark.url, + icon: bookmark.icon, + onlineId: bookmark.id, + sticker: bookmark.sticker ?? null, + customTextColor: bookmark.customTextColor ?? null, + customBackground: bookmark.customBackground ?? null, + order: bookmark.order || 0, + widgetId: bookmark.widgetId || null, + })) +} + +function getFolderItems( + bookmarks: Bookmark[] | null, + parentId: string | null +): Bookmark[] { + if (!bookmarks) return [] + const parentBookmark = bookmarks.find( + (b) => b.id === parentId || b.onlineId === parentId + ) + + let currentFolderBookmarks: Bookmark[] = [] + if (parentId) { + currentFolderBookmarks = bookmarks.filter( + (bookmark) => + (typeof bookmark.parentId === 'string' && + bookmark.parentId === parentId) || + (typeof bookmark.parentId === 'string' && + parentBookmark?.id && + bookmark.parentId === parentBookmark.id) || + (typeof bookmark.parentId === 'string' && + parentBookmark?.onlineId && + bookmark.parentId === parentBookmark.onlineId) + ) + } else { + currentFolderBookmarks = bookmarks.filter( + (bookmark) => bookmark.parentId === null + ) + } + + return [...currentFolderBookmarks].sort((a, b) => { + return (a.order || 0) - (b.order || 0) + }) +} + +function computeBookmarkGridDimensions(size?: WidgetSize): { + colsCount: number + rowsCount: number + totalBookmarks: number +} { + const colsCount = size ? (size.w === 4 ? 5 : size.w) : 5 + const rowsCount = size ? size.h : 2 + + return { + colsCount, + rowsCount, + totalBookmarks: colsCount * rowsCount, + } +} + +function mergeBookmarksWithEmptySlots( + bookmarks: Bookmark[], + totalSlots: number +): (Bookmark | null)[] { + const filled: (Bookmark | null)[] = [...bookmarks.slice(0, totalSlots)] + while (filled.length < totalSlots) { + filled.push(null) + } + return filled +} + +describe('Bookmark Legacy Compatibility & Layout Tests', () => { + it('maps legacy bookmarks with offlineId correctly', () => { + const legacyFetched: FetchedBookmark[] = [ + { + id: 'server-id-123', + offlineId: 'local-uuid-456', + title: 'گوگل', + url: 'https://google.com', + icon: 'https://google.com/favicon.ico', + isManageable: true, + type: 'BOOKMARK', + parentId: '', + iconIsS3Hosted: false, + children: [], + order: 0, + }, + { + id: 'server-id-789', + offlineId: null, + title: 'گیت‌هاب', + url: 'https://github.com', + icon: 'https://github.com/favicon.ico', + isManageable: true, + type: 'BOOKMARK', + parentId: '', + iconIsS3Hosted: false, + children: [], + order: 1, + }, + ] + + const mapped = mapBookmarks(legacyFetched) + + expect(mapped).toHaveLength(2) + expect(mapped[0].id).toBe('local-uuid-456') + expect(mapped[0].onlineId).toBe('server-id-123') + expect(mapped[1].id).toBe('server-id-789') + expect(mapped[1].onlineId).toBe('server-id-789') + }) + + it('safely handles legacy bookmarks with missing optional fields without crashing', () => { + const rawLegacyData: any[] = [ + { + id: 'legacy-1', + title: 'سایت قدیمی', + type: 'BOOKMARK', + url: 'https://example.com', + icon: null, + isManageable: true, + parentId: null, + iconIsS3Hosted: false, + children: [], + }, + ] + + const mapped = mapBookmarks(rawLegacyData) + expect(mapped[0].sticker).toBeNull() + expect(mapped[0].customTextColor).toBeNull() + expect(mapped[0].customBackground).toBeNull() + expect(mapped[0].order).toBe(0) + expect(mapped[0].widgetId).toBeNull() + }) + + it('propagates widgetId when present', () => { + const fetchedWithWidgetId: FetchedBookmark[] = [ + { + id: 'bm-1', + offlineId: null, + title: 'دیجی‌کالا', + url: 'https://digikala.com', + icon: '', + isManageable: true, + type: 'BOOKMARK', + parentId: '', + iconIsS3Hosted: false, + children: [], + widgetId: 'bookmarks-instance-1', + }, + ] + + const mapped = mapBookmarks(fetchedWithWidgetId) + expect(mapped[0].widgetId).toBe('bookmarks-instance-1') + }) + + it('filters root and nested folder items by id and onlineId', () => { + const items: Bookmark[] = [ + { + id: 'root-1', + title: 'روت ۱', + type: 'BOOKMARK', + parentId: null, + isLocal: true, + onlineId: 'root-1-online', + url: 'https://a.com', + icon: null, + customBackground: null, + customTextColor: null, + sticker: null, + order: 0, + }, + { + id: 'folder-1', + title: 'پوشه ۱', + type: 'FOLDER', + parentId: null, + isLocal: true, + onlineId: 'folder-1-online', + url: null, + icon: null, + customBackground: null, + customTextColor: null, + sticker: null, + order: 1, + }, + { + id: 'child-1', + title: 'فرزند با offlineId', + type: 'BOOKMARK', + parentId: 'folder-1', + isLocal: true, + onlineId: 'child-1-online', + url: 'https://b.com', + icon: null, + customBackground: null, + customTextColor: null, + sticker: null, + order: 0, + }, + { + id: 'child-2', + title: 'فرزند با onlineId', + type: 'BOOKMARK', + parentId: 'folder-1-online', + isLocal: true, + onlineId: 'child-2-online', + url: 'https://c.com', + icon: null, + customBackground: null, + customTextColor: null, + sticker: null, + order: 1, + }, + ] + + const rootItems = getFolderItems(items, null) + expect(rootItems).toHaveLength(2) + expect(rootItems.map((b) => b.id)).toEqual(['root-1', 'folder-1']) + + const folderItemsByUuid = getFolderItems(items, 'folder-1') + expect(folderItemsByUuid).toHaveLength(2) + expect(folderItemsByUuid.map((b) => b.id)).toEqual(['child-1', 'child-2']) + + const folderItemsByOnlineId = getFolderItems(items, 'folder-1-online') + expect(folderItemsByOnlineId).toHaveLength(2) + }) + + it('computes default size (4x2) as 10 bookmark slots in 2 rows of 5', () => { + const dim = computeBookmarkGridDimensions({ w: 4, h: 2 }) + expect(dim.colsCount).toBe(5) + expect(dim.rowsCount).toBe(2) + expect(dim.totalBookmarks).toBe(10) + }) + + it('computes 1-bookmark size (1x1) as 1 bookmark slot in 1 row of 1', () => { + const dim = computeBookmarkGridDimensions({ w: 1, h: 1 }) + expect(dim.colsCount).toBe(1) + expect(dim.rowsCount).toBe(1) + expect(dim.totalBookmarks).toBe(1) + }) + + it('computes 2-bookmark size (2x1) as 2 bookmark slots in 1 row of 2', () => { + const dim = computeBookmarkGridDimensions({ w: 2, h: 1 }) + expect(dim.colsCount).toBe(2) + expect(dim.rowsCount).toBe(1) + expect(dim.totalBookmarks).toBe(2) + }) + + it('computes 4-bookmark size (2x2) as 4 bookmark slots in 2 rows of 2', () => { + const dim = computeBookmarkGridDimensions({ w: 2, h: 2 }) + expect(dim.colsCount).toBe(2) + expect(dim.rowsCount).toBe(2) + expect(dim.totalBookmarks).toBe(4) + }) + + it('computes vertical size (2x4) as 8 bookmark slots in 4 rows of 2', () => { + const dim = computeBookmarkGridDimensions({ w: 2, h: 4 }) + expect(dim.colsCount).toBe(2) + expect(dim.rowsCount).toBe(4) + expect(dim.totalBookmarks).toBe(8) + }) + + it('pads bookmarks array with null empty slots up to total count', () => { + const testBookmarks: Bookmark[] = [ + { + id: '1', + title: 'گوگل', + type: 'BOOKMARK', + parentId: null, + isLocal: true, + onlineId: '1', + url: 'https://google.com', + icon: null, + customBackground: null, + customTextColor: null, + sticker: null, + order: 0, + }, + ] + + const slots10 = mergeBookmarksWithEmptySlots(testBookmarks, 10) + expect(slots10).toHaveLength(10) + expect(slots10[0]?.title).toBe('گوگل') + expect(slots10[1]).toBeNull() + expect(slots10[9]).toBeNull() + + const slots2 = mergeBookmarksWithEmptySlots(testBookmarks, 2) + expect(slots2).toHaveLength(2) + expect(slots2[0]?.title).toBe('گوگل') + expect(slots2[1]).toBeNull() + }) + + it('scopes bookmarks by widgetId while preserving legacy bookmarks in primary widget', () => { + const mixedBookmarks: Bookmark[] = [ + { + id: 'legacy-bm', + title: 'بوکمارک قدیمی بدون ویجت', + type: 'BOOKMARK', + parentId: null, + isLocal: true, + onlineId: 'legacy-bm', + url: 'https://legacy.com', + icon: null, + customBackground: null, + customTextColor: null, + sticker: null, + order: 0, + }, + { + id: 'default-widget-bm', + title: 'بوکمارک ویجت پیش‌فرض', + type: 'BOOKMARK', + parentId: null, + isLocal: true, + onlineId: 'default-bm', + url: 'https://default.com', + icon: null, + customBackground: null, + customTextColor: null, + sticker: null, + order: 1, + widgetId: 'bookmarks-default', + }, + { + id: 'duplicated-widget-bm', + title: 'بوکمارک ویجت تکراری', + type: 'BOOKMARK', + parentId: null, + isLocal: true, + onlineId: 'dup-bm', + url: 'https://dup.com', + icon: null, + customBackground: null, + customTextColor: null, + sticker: null, + order: 2, + widgetId: 'bookmarks-duplicated-123', + }, + ] + + const filterForWidget = (widgetId?: string | null, isPrimary?: boolean) => { + return mixedBookmarks.filter((b) => { + if (b.parentId !== null) return false + const shouldIncludeLegacy = + isPrimary !== undefined + ? isPrimary + : !widgetId || widgetId === 'bookmarks-default' + + if (shouldIncludeLegacy) { + return ( + !b.widgetId || + b.widgetId === 'bookmarks-default' || + (widgetId ? b.widgetId === widgetId : false) + ) + } + return b.widgetId === widgetId + }) + } + + // Primary / Default widget receives legacy bookmarks + its own + const primaryItems = filterForWidget('bookmarks-default', true) + expect(primaryItems).toHaveLength(2) + expect(primaryItems.map((b) => b.id)).toEqual(['legacy-bm', 'default-widget-bm']) + + // Primary widget with a dynamic MongoDB ObjectId (e.g. 6a8624e1...) ALSO receives legacy bookmarks + default + const mongoPrimaryItems = filterForWidget('6a8624e18ad0a538d22483dd', true) + expect(mongoPrimaryItems).toHaveLength(2) + expect(mongoPrimaryItems.map((b) => b.id)).toEqual([ + 'legacy-bm', + 'default-widget-bm', + ]) + + // Generic / Simplify / Advanced view without instanceId also receives legacy bookmarks + const genericItems = filterForWidget(null, true) + expect(genericItems).toHaveLength(2) + expect(genericItems.map((b) => b.id)).toEqual(['legacy-bm', 'default-widget-bm']) + + // Duplicated / New separate instance receives ONLY its own bookmarks and starts clean + const duplicatedItems = filterForWidget('bookmarks-duplicated-123', false) + expect(duplicatedItems).toHaveLength(1) + expect(duplicatedItems.map((b) => b.id)).toEqual(['duplicated-widget-bm']) + + // A brand new duplicated instance without any bookmarks yet has length 0 + const emptyNewInstance = filterForWidget('bookmarks-brand-new-999', false) + expect(emptyNewInstance).toHaveLength(0) + }) +}) diff --git a/src/layouts/bookmark/bookmark-grid.tsx b/src/layouts/bookmark/bookmark-grid.tsx index 0dd6029bb..abec3fd38 100644 --- a/src/layouts/bookmark/bookmark-grid.tsx +++ b/src/layouts/bookmark/bookmark-grid.tsx @@ -14,13 +14,17 @@ import { validate } from 'uuid' import { useAuth } from '@/context/auth.context' import { AuthRequiredModal } from '@/components/auth/auth-required-modal' import { showToast } from '@/common/toast' -import { Icon } from '@/src/icons' +import { translateError } from '@/common/utils/translate-error' interface BookmarkGridProps { displayedBookmarks: Bookmark[] openAddBookmarkModal: () => void folderPath: FolderPathItem[] setFolderPath: (path: FolderPathItem[]) => void + colsCount?: number + rowsCount?: number + onOpenFolder?: (folder: Bookmark) => void + isModal?: boolean } export function BookmarkGrid({ @@ -28,6 +32,10 @@ export function BookmarkGrid({ openAddBookmarkModal, setFolderPath, folderPath, + colsCount = 5, + rowsCount = 2, + onOpenFolder, + isModal = false, }: BookmarkGridProps) { const { getCurrentFolderItems, editBookmark, deleteBookmark, setCurrentFolderId } = useBookmarkStore() @@ -61,6 +69,8 @@ export function BookmarkGrid({ if (bookmark.type === 'FOLDER') { if (e?.ctrlKey || e?.metaKey) { openBookmarks(bookmark) + } else if (onOpenFolder) { + onOpenFolder(bookmark) } else { const isValidUUid = validate(bookmark.id) setCurrentFolderId(isValidUUid ? bookmark.id : bookmark.onlineId) @@ -83,12 +93,24 @@ export function BookmarkGrid({ const handleMenuClick = (e: React.MouseEvent, bookmark: Bookmark) => { e.preventDefault() + e.stopPropagation() setSelectedBookmark(bookmark) - const button = e.currentTarget - if (button) { - const rect = button.getBoundingClientRect() - setContextMenuPos({ x: rect.left - 110, y: rect.bottom + 5 }) + + const isContextMenu = e.type === 'contextmenu' + let x: number + let y: number + + if (isContextMenu && e.clientX && e.clientY) { + x = e.clientX + y = e.clientY + } else { + const target = e.currentTarget + const rect = target.getBoundingClientRect() + x = rect.left + rect.width / 2 - 74 + y = rect.bottom + 4 } + + setContextMenuPos({ x, y }) } const openBookmarks = (bookmark: Bookmark) => { @@ -118,7 +140,7 @@ export function BookmarkGrid({ const handleDeleteBookmark = (bookmark: Bookmark) => { if (!isAuthenticated) { - return showToast('برای حذف بوکمارک باید وارد حساب کاربری خود شوید.', 'error') + return showToast(translateError('UNAUTHORIZED') as string, 'error') } setBookmarkToDelete(bookmark) @@ -171,21 +193,36 @@ export function BookmarkGrid({ } }, []) + const isAutoRows = isModal || !rowsCount + + const sortableIds = useMemo( + () => displayedBookmarks.filter(Boolean).map((bookmark) => bookmark?.id || ''), + [displayedBookmarks] + ) + return (
bookmark?.id || '')} + items={sortableIds} strategy={rectSortingStrategy} > {displayedBookmarks.map((bookmark, i) => bookmark ? (
) : ( - +
+ +
) )}
@@ -207,22 +245,27 @@ export function BookmarkGrid({ {showEditBookmarkModal && bookmarkToEdit && !isAuthenticated ? ( setShowEditBookmarkModal(false)} - message="برای ویرایش بوکمارک باید وارد حساب کاربری خود شوید." - loginButtonText="ورود به حساب کاربری" + onClose={() => { + setShowEditBookmarkModal(false) + setBookmarkToEdit(null) + }} + message="برای ویرایش بوکمارک اول وارد حسابت شو" /> ) : ( - showEditBookmarkModal && - bookmarkToEdit && ( - setShowEditBookmarkModal(false)} - onSave={(bookmark) => - editBookmark(bookmark, () => setShowEditBookmarkModal(false)) - } - bookmark={bookmarkToEdit} - /> - ) + { + setShowEditBookmarkModal(false) + setBookmarkToEdit(null) + }} + onSave={(bookmark) => + editBookmark(bookmark, () => { + setShowEditBookmarkModal(false) + setBookmarkToEdit(null) + }) + } + bookmark={bookmarkToEdit} + /> )} handleDeleteBookmark(selectedBookmark)} onEdit={() => handleEditBookmark(selectedBookmark)} - onOpenInNewTab={() => onOpenInNewTab(selectedBookmark)} + onOpenInNewTab={ + selectedBookmark.type === 'BOOKMARK' + ? () => onOpenInNewTab(selectedBookmark) + : undefined + } + onClose={() => setSelectedBookmark(null)} /> )}
diff --git a/src/layouts/bookmark/bookmarks.tsx b/src/layouts/bookmark/bookmarks.tsx index d6cc97f4c..a9aafbb0e 100644 --- a/src/layouts/bookmark/bookmarks.tsx +++ b/src/layouts/bookmark/bookmarks.tsx @@ -8,7 +8,7 @@ import { } from '@dnd-kit/core' import { useState } from 'react' import Analytics from '@/analytics' -import { FolderHeader } from './components/folder-header' +import { BookmarkFolderModal } from './components/modal/bookmark-folder.modal' import { AddBookmarkModal } from './components/modal/add-bookmark.modal' import { ImportBrowserBookmarksModal } from './components/modal/import-browser-bookmarks.modal' import type { Bookmark, FolderPathItem } from './types/bookmark.types' @@ -17,14 +17,28 @@ import { useBookmarkStore } from './context/bookmark.context' import { useAuth } from '@/context/auth.context' import { AuthRequiredModal } from '@/components/auth/auth-required-modal' import { showToast } from '@/common/toast' +import { translateError } from '@/common/utils/translate-error' import { useUpdateBookmarkOrder } from '@/services/hooks/bookmark/update-bookmark-order.hook' +import { usePrimaryBookmarkInstanceId } from '@/context/free-widget/free-widget.context' +import type { WidgetSize } from '../widgets/layout-engine/types' +import { validate } from 'uuid' + +const POINTER_SENSOR_OPTIONS = { + activationConstraint: { + distance: 5, + }, +} + +interface BookmarksListProps { + size?: WidgetSize + instanceId?: string +} -export function BookmarksList() { +export function BookmarksList({ size, instanceId }: BookmarksListProps = {}) { const { bookmarks, getCurrentFolderItems, currentFolderId, - setCurrentFolderId, addBookmark, setBookmarks, } = useBookmarkStore() @@ -32,30 +46,37 @@ export function BookmarksList() { const [showAddBookmarkModal, setShowAddBookmarkModal] = useState(false) const [showImportBookmarksModal, setShowImportBookmarksModal] = useState(false) + const [folderModalPath, setFolderModalPath] = useState([]) const { mutateAsync: updateOrder } = useUpdateBookmarkOrder() const [folderPath, setFolderPath] = useState([]) - const sensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { - distance: 5, - }, - }) - ) - const BOOKMARKS_PER_ROW = 5 - const TOTAL_BOOKMARKS = BOOKMARKS_PER_ROW * 2 + const sensors = useSensors(useSensor(PointerSensor, POINTER_SENSOR_OPTIONS)) + + const colsCount = size ? (size.w === 4 ? 5 : size.w) : 5 + const rowsCount = size ? size.h : 2 + + const TOTAL_BOOKMARKS = colsCount * rowsCount + + const primaryBookmarkInstanceId = usePrimaryBookmarkInstanceId() + const isPrimary = (() => { + if (!instanceId || instanceId === 'bookmarks-default') return true + if (primaryBookmarkInstanceId === undefined) return true + if (primaryBookmarkInstanceId === null) return true + return primaryBookmarkInstanceId === instanceId + })() const handleDragEnd = async (event: DragEndEvent) => { if (!isAuthenticated) - return showToast( - 'برای مرتب‌سازی بوکمارک‌ها باید وارد حساب کاربری خود شوید.', - 'error' - ) + return showToast(translateError('UNAUTHORIZED') as string, 'error') const { active, over } = event if (!over || active.id === over.id) return - const currentItems = getCurrentFolderItems(currentFolderId) + const currentItems = getCurrentFolderItems( + currentFolderId, + instanceId, + isPrimary + ) const sourceIndex = currentItems.findIndex( (item) => item.id === active.id || item.onlineId === active.id @@ -122,18 +143,17 @@ export function BookmarksList() { Analytics.event('bookmark_reorder') } - const handleNavigate = (folderId: string | null, depth: number) => { - if (depth === -1) { - setFolderPath([]) - setCurrentFolderId(null) - return - } - const newPath = folderPath.slice(0, depth + 1) - setFolderPath(newPath) - setCurrentFolderId(folderId) + const handleOpenFolderInModal = (folder: Bookmark) => { + const isValidUuid = validate(folder.id) + const targetId = isValidUuid ? folder.id : folder.onlineId || folder.id + setFolderModalPath([{ id: targetId, title: folder.title }]) } - const currentFolderItems = getCurrentFolderItems(currentFolderId) + const currentFolderItems = getCurrentFolderItems( + currentFolderId, + instanceId, + isPrimary + ) const getDisplayedBookmarks = (): Bookmark[] => { if (!currentFolderId) { @@ -166,38 +186,37 @@ export function BookmarksList() { onDragEnd={handleDragEnd} >
- {currentFolderId && ( - - )} - -
+
setFolderPath(path)} openAddBookmarkModal={() => setShowAddBookmarkModal(true)} + onOpenFolder={handleOpenFolderInModal} + colsCount={colsCount} + rowsCount={rowsCount} + isModal={Boolean(currentFolderId)} />
+ + 0} + onClose={() => setFolderModalPath([])} + folderPath={folderModalPath} + setFolderPath={setFolderModalPath} + instanceId={instanceId} + isPrimary={isPrimary} + /> + {showAddBookmarkModal && !isAuthenticated ? ( setShowAddBookmarkModal(false)} - message="برای افزودن بوکمارک جدید باید وارد حساب کاربری خود شوید." - loginButtonText="ورود به حساب کاربری" + message="برای افزودن بوکمارک جدید اول وارد حسابت شو" /> ) : ( showAddBookmarkModal && ( @@ -208,6 +227,7 @@ export function BookmarksList() { addBookmark(bookmark, () => setShowAddBookmarkModal(false)) } parentId={currentFolderId} + widgetId={instanceId} onOpenImport={() => { setShowAddBookmarkModal(false) setShowImportBookmarksModal(true) @@ -215,13 +235,12 @@ export function BookmarksList() { /> ) )} - {showImportBookmarksModal && ( - setShowImportBookmarksModal(false)} - parentId={currentFolderId} - /> - )} + + setShowImportBookmarksModal(false)} + parentId={currentFolderId} + /> ) } diff --git a/src/layouts/bookmark/components/bookmark-empty-slot.tsx b/src/layouts/bookmark/components/bookmark-empty-slot.tsx index 503272310..3e2d466cf 100644 --- a/src/layouts/bookmark/components/bookmark-empty-slot.tsx +++ b/src/layouts/bookmark/components/bookmark-empty-slot.tsx @@ -1,4 +1,5 @@ -import { Icon } from '@/src/icons' +import { cn } from '@/common/utils/cn' +import { Icon } from '@/icons' export function EmptyBookmarkSlot({ onClick, @@ -8,39 +9,25 @@ export function EmptyBookmarkSlot({ theme?: string canAdd: boolean }) { - const getBookmarkStyle = () => { - return 'bg-content hover:!bg-base-300 text-content backdrop-blur-sm border-content bg-glass' - } - - const getEmptySlotStyle = () => { - if (!canAdd) { - return `opacity-30 bg-content ${getBookmarkStyle()} cursor-default` - } - - return getBookmarkStyle() - } - return ( ) diff --git a/src/layouts/bookmark/components/bookmark-folder.tsx b/src/layouts/bookmark/components/bookmark-folder.tsx index d55dd3d05..aa3695309 100644 --- a/src/layouts/bookmark/components/bookmark-folder.tsx +++ b/src/layouts/bookmark/components/bookmark-folder.tsx @@ -1,13 +1,14 @@ -import { useState } from 'react' +import { memo, useMemo, useState } from 'react' import { addOpacityToColor } from '@/common/color' import type { Bookmark } from '../types/bookmark.types' import { RenderStickerPattern } from './bookmark/bookmark-sticker' import { BookmarkTitle } from './bookmark/bookmark-title' import { useBookmarkStore } from '../context/bookmark.context' import { BookmarkIcon } from './bookmark/bookmark-icon' -import { Icon } from '@/src/icons' +import { Icon } from '@/icons' +import { cn } from '@/common/utils/cn' -export function FolderBookmarkItem({ +export const FolderBookmarkItem = memo(function FolderBookmarkItem({ bookmark, onClick, isDragging = false, @@ -18,13 +19,17 @@ export function FolderBookmarkItem({ isDragging?: boolean onMenuClick?: (e: React.MouseEvent) => void }) { - const { getCurrentFolderItems } = useBookmarkStore() + const { bookmarks, getCurrentFolderItems } = useBookmarkStore() const [isHovered, setIsHovered] = useState(false) - const folderItems = getCurrentFolderItems(bookmark.id) - .filter((item) => item.type === 'BOOKMARK') - .slice(0, 6) + const folderItems = useMemo( + () => + getCurrentFolderItems(bookmark.id) + .filter((item) => item.type === 'BOOKMARK') + .slice(0, 6), + [bookmarks, bookmark.id] + ) const renderFolderIcons = () => { if (bookmark.icon) { @@ -33,11 +38,11 @@ export function FolderBookmarkItem({ if (folderItems.length > 0) { return ( -
+
{folderItems.map((child, index) => (
@@ -70,27 +75,33 @@ export function FolderBookmarkItem({ return (
) -} +}) diff --git a/src/layouts/bookmark/components/bookmark-icon.picker.tsx b/src/layouts/bookmark/components/bookmark-icon.picker.tsx index 0a3468a61..d4fc45c30 100644 --- a/src/layouts/bookmark/components/bookmark-icon.picker.tsx +++ b/src/layouts/bookmark/components/bookmark-icon.picker.tsx @@ -1,32 +1,46 @@ import { showToast } from '@/common/toast' import { getFaviconFromUrl } from '@/common/utils/icon' -import { Icon } from '@/src/icons' +import { Icon } from '@/icons' import type React from 'react' import { useRef, useState } from 'react' +import { PopoverMenu, PopoverMenuItem, PopoverMenuDivider } from '@/components/ui' +import { GalleryPickerModal } from '@/components/gallery' +import type { GalleryAsset } from '@/services/hooks/gallery/get-gallery-assets.hook' type Props = { value: File | string | null url?: string | null - onChange: (file: File | null) => void + onChange: (value: File | string | null) => void + size?: 'md' | 'lg' } -export function BookmarkIconPicker({ value, url, onChange }: Props) { +export function BookmarkIconPicker({ value, url, onChange, size = 'md' }: Props) { + const triggerRef = useRef(null) const fileInputRef = useRef(null) + const [isMenuOpen, setIsMenuOpen] = useState(false) + const [isGalleryOpen, setIsGalleryOpen] = useState(false) const [isDragging, setIsDragging] = useState(false) const [error, setError] = useState(false) - const openPicker = () => { - fileInputRef.current?.click() + const openPicker = (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + setIsMenuOpen(true) } const handleFile = (file?: File) => { if (!file || !file.type.startsWith('image/')) - return showToast('فرمت نامعتبر', 'error') + return showToast('فرمت فایل نامعتبر است', 'error') + if (file.size > 250 * 1024) { + return showToast('حجم فایل آیکون نباید بیشتر از ۲۵۰ کیلوبایت باشد', 'error') + } + setError(false) onChange(file) } const handleDrop = (e: React.DragEvent) => { e.preventDefault() + e.stopPropagation() setIsDragging(false) handleFile(e.dataTransfer.files?.[0]) } @@ -36,70 +50,161 @@ export function BookmarkIconPicker({ value, url, onChange }: Props) { } const handleRemove = (e: React.MouseEvent) => { + e.preventDefault() e.stopPropagation() onChange(null) setError(false) + if (fileInputRef.current) { + fileInputRef.current.value = '' + } + } + + const handleGallerySelect = (asset: GalleryAsset) => { + setIsGalleryOpen(false) + setError(false) + onChange(asset.url) } const isFile = value instanceof File let iconSrc: string | null = null if (value && isFile) { iconSrc = URL.createObjectURL(value) - } else { - if (value) { - iconSrc = value - } else if (url && url !== 'null') { - iconSrc = getFaviconFromUrl(url || '') - } + } else if (typeof value === 'string' && value) { + iconSrc = value + } else if (url && url !== 'null') { + iconSrc = getFaviconFromUrl(url || '') } + const isLarge = size === 'lg' + const containerSizeClasses = isLarge + ? 'w-16 h-16 rounded-2xl' + : 'w-12 h-12 rounded-xl' + return ( <> -
{ - e.preventDefault() - setIsDragging(true) - }} - onDragLeave={() => setIsDragging(false)} - onDrop={handleDrop} - className={`relative w-12 h-10 flex items-center justify-center cursor-pointer border-2 rounded-xl transition - ${isDragging ? 'border-primary' : 'border-base-300'} - `} - > - {iconSrc && !error ? ( - setError(true)} - /> - ) : ( - - )} +
+ - {isFile && ( + {Boolean(value) && ( )}
+ + setIsMenuOpen(false)} + triggerRef={triggerRef} + width={190} + placement="bottom-start" + > + } + label="بارگذاری از دستگاه" + onClick={() => { + setIsMenuOpen(false) + if (fileInputRef.current) { + fileInputRef.current.value = '' + fileInputRef.current.click() + } + }} + /> + } + label="انتخاب از گالری" + onClick={() => { + setIsMenuOpen(false) + setIsGalleryOpen(true) + }} + /> + {Boolean(value) && ( + <> + + } + label="حذف آیکون" + variant="danger" + onClick={() => { + setIsMenuOpen(false) + handleRemove({ + preventDefault: () => {}, + stopPropagation: () => {}, + } as any) + }} + /> + + )} + + + {isGalleryOpen && ( + setIsGalleryOpen(false)} + type="BOOKMARK_ICON" + title="گالری آیکون بوکمارک" + onSelect={handleGallerySelect} + selectedAssetUrl={typeof value === 'string' ? value : undefined} + /> + )} ) } diff --git a/src/layouts/bookmark/components/bookmark-item.tsx b/src/layouts/bookmark/components/bookmark-item.tsx index 4ab82590d..ecf80d393 100644 --- a/src/layouts/bookmark/components/bookmark-item.tsx +++ b/src/layouts/bookmark/components/bookmark-item.tsx @@ -1,9 +1,11 @@ +import { memo } from 'react' import { addOpacityToColor, getContrastingTextColor } from '@/common/color' import type { Bookmark } from '../types/bookmark.types' import { BookmarkIcon } from './bookmark/bookmark-icon' import { RenderStickerPattern } from './bookmark/bookmark-sticker' import { BookmarkTitle } from './bookmark/bookmark-title' -import { Icon } from '@/src/icons' +import { Icon } from '@/icons' +import { cn } from '@/common/utils/cn' interface BookmarkItemProps { bookmark: Bookmark @@ -13,7 +15,7 @@ interface BookmarkItemProps { onMenuClick?: (e: React.MouseEvent) => void } -export function BookmarkItem({ +export const BookmarkItem = memo(function BookmarkItem({ bookmark, onClick, isDragging = false, @@ -33,13 +35,23 @@ export function BookmarkItem({ } return ( -
+
) -} +}) diff --git a/src/layouts/bookmark/components/bookmark-suggestions.tsx b/src/layouts/bookmark/components/bookmark-suggestions.tsx index 4206b1b85..58f3e417e 100644 --- a/src/layouts/bookmark/components/bookmark-suggestions.tsx +++ b/src/layouts/bookmark/components/bookmark-suggestions.tsx @@ -3,6 +3,7 @@ import { type BookmarkSuggestion, useGetSuggestedBookmarks, } from '@/services/hooks/bookmark/get-bookmarks.hook' +import { Icon } from '@/icons' interface BookmarkSuggestionsProps { onSelect: (suggestion: BookmarkSuggestion) => void @@ -18,31 +19,37 @@ export function BookmarkSuggestions({ onSelect }: BookmarkSuggestionsProps) { return (
-
+
{suggestions.map((suggestion, index) => ( -
onSelect(suggestion)} - className="p-1 flex flex-col items-center justify-center text-center transition-colors duration-200 bg-content hover:!bg-base-300/75 h-12 border border-base-300/40 rounded-xl cursor-pointer" + type="button" + onClick={(e) => { + e.preventDefault() + e.stopPropagation() + onSelect(suggestion) + }} + className="p-1.5 flex flex-col items-center justify-center text-center transition-all duration-200 bg-base-200 hover:bg-primary/10 hover:border-primary/30 h-14 border border-base-content/10 rounded-xl cursor-pointer" > -
- {suggestion.title} { - const target = e.target as HTMLImageElement - target.style.display = 'none' - target.nextElementSibling?.classList.remove( - 'hidden' - ) - }} - /> +
+ {suggestion.icon ? ( + {suggestion.title} { + const target = e.target as HTMLImageElement + target.style.display = 'none' + }} + /> + ) : ( + + )}
-

+

{suggestion.title}

-
+ ))}
diff --git a/src/layouts/bookmark/components/bookmark/bookmark-icon.tsx b/src/layouts/bookmark/components/bookmark/bookmark-icon.tsx index 4c3f969c5..517d19121 100644 --- a/src/layouts/bookmark/components/bookmark/bookmark-icon.tsx +++ b/src/layouts/bookmark/components/bookmark/bookmark-icon.tsx @@ -1,7 +1,7 @@ import { getFaviconFromUrl } from '@/common/utils/icon' import type { Bookmark } from '../../types/bookmark.types' import { useState } from 'react' -import { Icon } from '@/src/icons' +import { Icon } from '@/icons' const colors = [ 'bg-blue-500/60', 'bg-purple-500/60', @@ -69,13 +69,12 @@ export function BookmarkIcon({ bookmark }: { bookmark: Bookmark }) { ? bookmark.customTextColor : 'rgba(255, 255, 255, 1)' const colorClass = hasCustomColors ? '' : getColorFromTitle(bookmark.title) - return ( -
+
{typeof displayIcon === 'string' && !imageError ? ( {bookmark.title} +
{title} diff --git a/src/layouts/bookmark/components/folder-path.tsx b/src/layouts/bookmark/components/folder-path.tsx index 635916c3f..41d0ea7db 100644 --- a/src/layouts/bookmark/components/folder-path.tsx +++ b/src/layouts/bookmark/components/folder-path.tsx @@ -1,4 +1,4 @@ -import { Icon } from '@/src/icons' +import { Icon } from '@/icons' import type { FolderPathItem } from '../types/bookmark.types' type FolderPathProps = { diff --git a/src/layouts/bookmark/components/modal/add-bookmark.modal.tsx b/src/layouts/bookmark/components/modal/add-bookmark.modal.tsx index 4030f68a3..9eb7c0dfb 100644 --- a/src/layouts/bookmark/components/modal/add-bookmark.modal.tsx +++ b/src/layouts/bookmark/components/modal/add-bookmark.modal.tsx @@ -1,13 +1,14 @@ import { useEffect, useState } from 'react' import { Button, Modal } from '@/components/ui' import { TextInput } from '@/components/ui' -import { Icon } from '@/src/icons' +import { Icon } from '@/icons' import type { BookmarkType } from '../../types/bookmark.types' import { BookmarkSuggestions } from '../bookmark-suggestions' import { ShowAdvancedButton, TypeSelector } from '../shared' import { AdvancedModal } from './advanced.modal' import { useIsMutating } from '@tanstack/react-query' import { BookmarkIconPicker } from '../bookmark-icon.picker' +import type { BookmarkSuggestion } from '@/services/hooks/bookmark/get-bookmarks.hook' interface AddBookmarkModalProps { isOpen: boolean @@ -15,6 +16,7 @@ interface AddBookmarkModalProps { onAdd: (bookmark: BookmarkCreateFormFields) => void parentId: string | null onOpenImport?: () => void + widgetId?: string | null } export interface BookmarkCreateFormFields { @@ -26,7 +28,8 @@ export interface BookmarkCreateFormFields { customBackground: string | null customTextColor: string | null sticker: string | null - icon: File | null + icon: File | string | null + widgetId?: string | null } const empty: BookmarkCreateFormFields = { @@ -39,6 +42,7 @@ const empty: BookmarkCreateFormFields = { customTextColor: '', sticker: '', icon: null, + widgetId: null, } export type AddBookmarkUpdateFormData = ( @@ -52,6 +56,7 @@ export function AddBookmarkModal({ onAdd, parentId = null, onOpenImport, + widgetId = null, }: AddBookmarkModalProps) { const [type, setType] = useState('BOOKMARK') const [showAdvanced, setShowAdvanced] = useState(false) @@ -71,25 +76,33 @@ export function AddBookmarkModal({ setFormData((prev) => ({ ...prev, [key]: value })) } + const handleTypeChange = (newType: BookmarkType) => { + setType(newType) + updateFormData('type', newType) + } + const handleUrlChange = (value: string) => { const newUrl = value.trim() - updateFormData('url', newUrl) - - updateFormData('icon', null) - + let hostName = '' if (formData.title.trim() === '' && newUrl !== '') { - let hostName = '' try { - hostName = new URL(newUrl).hostname + hostName = new URL( + newUrl.startsWith('http') ? newUrl : `https://${newUrl}` + ).hostname if (hostName && hostName.split('.').length > 2) { hostName = hostName.split('.')[1] } } catch { hostName = newUrl } - - updateFormData('title', hostName) } + + setFormData((prev) => ({ + ...prev, + url: newUrl, + icon: prev.icon, + title: prev.title.trim() === '' && hostName ? hostName : prev.title, + })) } const handleAdd = (e: React.FormEvent) => { @@ -111,6 +124,7 @@ export function AddBookmarkModal({ customTextColor: formData.customTextColor || null, sticker: formData.sticker || null, icon: formData.icon, + widgetId: formData.widgetId || widgetId || null, } onAdd(baseBookmark) @@ -127,13 +141,13 @@ export function AddBookmarkModal({ onClose() } - const handleSuggestionSelect = (suggestion: { - title: string - url: string - icon: string | null - }) => { - updateFormData('title', suggestion.title) - updateFormData('url', suggestion.url) + const handleSuggestionSelect = (suggestion: BookmarkSuggestion) => { + setFormData((prev) => ({ + ...prev, + title: suggestion.title, + url: suggestion.url, + icon: suggestion.icon || null, + })) } const handleAdvancedModalClose = ( @@ -167,108 +181,119 @@ export function AddBookmarkModal({ }, [isOpen]) return ( - onCloseHandler()} - size="md" - title={`${type === 'FOLDER' ? 'پوشه جدید' : 'بوکمارک جدید'}`} - direction="rtl" - className="overflow-y-hidden!" - closeOnBackdropClick={false} - > -
+ onCloseHandler()} + size="md" + title={`${type === 'FOLDER' ? 'پوشه جدید' : 'بوکمارک جدید'}`} + direction="rtl" + className="overflow-y-hidden!" + closeOnBackdropClick={false} > -
- - {onOpenImport && ( - - )} -
- updateFormData('title', v)} - className={ - 'w-full px-4 py-3 text-right rounded-lg transition-all duration-200 ' - } - /> + e.stopPropagation()} + className="flex flex-col gap-4" + > + - updateFormData('icon', value)} - value={formData.icon} - url={formData.url} - /> -
-
- {type === 'BOOKMARK' && ( - handleUrlChange(v)} - className={ - 'mt-2 w-full px-4 py-3 text-right absolute rounded-lg transition-all duration-300' - } +
+
+ { + updateFormData('icon', value) + }} + value={formData.icon} + url={formData.url} /> + +
+ updateFormData('title', v)} + className="w-full px-3.5 py-2.5 text-right transition-all duration-200 rounded-xl" + /> +
+
+ + {type === 'BOOKMARK' && ( +
+ handleUrlChange(v)} + className="w-full px-3.5 py-2.5 text-left font-mono text-xs transition-all duration-200 rounded-xl" + /> +
+ )} + + {type === 'BOOKMARK' && ( + )}
- {type === 'BOOKMARK' && ( - + + {onOpenImport && ( +
+ +
)} - -
- -
- - -
- - +
+ + +
+ + +
-
- - + + + + + ) } diff --git a/src/layouts/bookmark/components/modal/advanced.modal.tsx b/src/layouts/bookmark/components/modal/advanced.modal.tsx index 078e387f5..beb71d0d5 100644 --- a/src/layouts/bookmark/components/modal/advanced.modal.tsx +++ b/src/layouts/bookmark/components/modal/advanced.modal.tsx @@ -2,11 +2,11 @@ import { useCallback, useEffect, useRef, useState } from 'react' import Analytics from '@/analytics' import { Button, Modal } from '@/components/ui' import { ColorPicker } from '@/components/ui' -import { TextInput } from '@/components/text-input' -import { getEmojiList } from '@/services/emoji/emoji-api' +import { TextInput } from '@/components/ui' +import { getEmojiList } from '@/services/emoji/emoji.api' import { BookmarkItem } from '../bookmark-item' import type { BookmarkType } from '../../types/bookmark.types' -import { Icon } from '@/src/icons' +import { Icon } from '@/icons' interface AdvancedModalProps { title: string @@ -148,8 +148,6 @@ export function AdvancedModal({ title, onClose, isOpen, bookmark }: AdvancedModa }) } - if (!isOpen) return null - return (
-
+
@@ -183,14 +181,13 @@ export function AdvancedModal({ title, onClose, isOpen, bookmark }: AdvancedModa size="md" className="p-3!" rounded={'2xl'} - variant={'default'} >
-
+
@@ -215,14 +212,13 @@ export function AdvancedModal({ title, onClose, isOpen, bookmark }: AdvancedModa size="md" className="p-3!" rounded={'2xl'} - variant={'default'} >
-
+
@@ -234,7 +230,6 @@ export function AdvancedModal({ title, onClose, isOpen, bookmark }: AdvancedModa onClick={toggleEmojiPopover} className={'btn !w-fit px-8'} rounded={'2xl'} - variant={'default'} > {sticker ? ( <> @@ -283,7 +278,7 @@ export function AdvancedModal({ title, onClose, isOpen, bookmark }: AdvancedModa className={ 'absolute mt-1 p-2 w-64 max-h-32 overflow-y-auto small-scrollbar rounded-xl backdrop-blur-lg border border-content' } - style={{ zIndex: 1000 }} + style={{ zIndex: 99999 }} > {renderEmojiGrid()}
@@ -303,7 +298,7 @@ export function AdvancedModal({ title, onClose, isOpen, bookmark }: AdvancedModa backgroundPosition: 'center', }} > -
+
onClose(null)} rounded={'2xl'} - className="w-20 transition-colors duration-300 ease-in-out border-none shadow-none btn bg-base-300 hover:bg-error/10 text-base-content/80 hover:text-error rounded-2xl" + className="w-20 transition-colors duration-300 ease-in-out shadow-none rounded-2xl" > لغو @@ -338,7 +333,7 @@ export function AdvancedModal({ title, onClose, isOpen, bookmark }: AdvancedModa type="submit" onClick={() => handleClose()} size="md" - variant={'primary'} + color={'primary'} rounded={'2xl'} className={'w-fit px-8 border-none'} > diff --git a/src/layouts/bookmark/components/modal/bookmark-context-menu.tsx b/src/layouts/bookmark/components/modal/bookmark-context-menu.tsx index d53e172ca..c9f962a02 100644 --- a/src/layouts/bookmark/components/modal/bookmark-context-menu.tsx +++ b/src/layouts/bookmark/components/modal/bookmark-context-menu.tsx @@ -1,11 +1,16 @@ -import { ContextMenu } from '@/components/ui' -import { Icon } from '@/src/icons' +import { + PopoverMenu, + PopoverMenuItem, + PopoverMenuDivider, +} from '@/components/ui' +import { Icon } from '@/icons' interface BookmarkContextMenuProps { position: { x: number; y: number } onDelete: () => void onEdit: () => void onOpenInNewTab?: () => void + onClose: () => void isFolder?: boolean } @@ -14,42 +19,46 @@ export function BookmarkContextMenu({ onDelete, onEdit, onOpenInNewTab, + onClose, }: BookmarkContextMenuProps) { - const getMenuItemStyle = (isDelete = false) => { - if (isDelete) { - return 'text-error hover:text-error/90 hover:!bg-error/10' - } - - return 'text-content hover:text-content/90 hover:!bg-base-300/70' - } - return ( - + {onOpenInNewTab && ( - + } + label="در تب جدید" + onClick={() => { + onOpenInNewTab() + onClose() + }} + /> )} - + } + label="ویرایش" + onClick={() => { + onEdit() + onClose() + }} + /> + + - - + } + label="حذف" + variant="danger" + onClick={() => { + onDelete() + onClose() + }} + /> + ) } diff --git a/src/layouts/bookmark/components/modal/bookmark-folder.modal.tsx b/src/layouts/bookmark/components/modal/bookmark-folder.modal.tsx new file mode 100644 index 000000000..01bcb3716 --- /dev/null +++ b/src/layouts/bookmark/components/modal/bookmark-folder.modal.tsx @@ -0,0 +1,246 @@ +import { + closestCenter, + DndContext, + type DragEndEvent, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core' +import { useState } from 'react' +import Analytics from '@/analytics' +import { Modal } from '@/components/ui' +import { showToast } from '@/common/toast' +import { translateError } from '@/common/utils/translate-error' +import { useAuth } from '@/context/auth.context' +import { AuthRequiredModal } from '@/components/auth/auth-required-modal' +import { useBookmarkStore } from '../../context/bookmark.context' +import { useUpdateBookmarkOrder } from '@/services/hooks/bookmark/update-bookmark-order.hook' +import type { Bookmark, FolderPathItem } from '../../types/bookmark.types' +import { BookmarkGrid } from '../../bookmark-grid' +import { FolderPath } from '../folder-path' +import { AddBookmarkModal } from './add-bookmark.modal' +import { ImportBrowserBookmarksModal } from './import-browser-bookmarks.modal' +import { validate } from 'uuid' + +const POINTER_SENSOR_OPTIONS = { + activationConstraint: { + distance: 5, + }, +} + +interface BookmarkFolderModalProps { + isOpen: boolean + onClose: () => void + folderPath: FolderPathItem[] + setFolderPath: (path: FolderPathItem[]) => void + instanceId?: string + isPrimary?: boolean +} + +export function BookmarkFolderModal({ + isOpen, + onClose, + folderPath, + setFolderPath, + instanceId, + isPrimary = true, +}: BookmarkFolderModalProps) { + const { bookmarks, getCurrentFolderItems, addBookmark, setBookmarks } = + useBookmarkStore() + const { isAuthenticated } = useAuth() + const { mutateAsync: updateOrder } = useUpdateBookmarkOrder() + + const [showAddModal, setShowAddModal] = useState(false) + const [showImportModal, setShowImportModal] = useState(false) + + const sensors = useSensors(useSensor(PointerSensor, POINTER_SENSOR_OPTIONS)) + + const currentFolder = folderPath[folderPath.length - 1] + const currentFolderId = currentFolder + ? validate(currentFolder.id) + ? currentFolder.id + : currentFolder.id + : null + + const currentItems = currentFolderId + ? getCurrentFolderItems(currentFolderId, instanceId, isPrimary) + : [] + + const handleNavigate = (folderId: string | null, depth: number) => { + if (depth === -1 || !folderId) { + onClose() + return + } + const newPath = folderPath.slice(0, depth + 1) + setFolderPath(newPath) + } + + const handleOpenSubFolder = (folder: Bookmark) => { + const isValidUuid = validate(folder.id) + const targetId = isValidUuid ? folder.id : folder.onlineId || folder.id + setFolderPath([...folderPath, { id: targetId, title: folder.title }]) + } + + const handleDragEnd = async (event: DragEndEvent) => { + if (!isAuthenticated) { + return showToast(translateError('UNAUTHORIZED') as string, 'error') + } + + const { active, over } = event + if (!over || active.id === over.id || !currentFolderId) return + + const sourceIndex = currentItems.findIndex( + (item) => item.id === active.id || item.onlineId === active.id + ) + const targetIndex = currentItems.findIndex( + (item) => item.id === over.id || item.onlineId === over.id + ) + + if (sourceIndex === -1 || targetIndex === -1 || sourceIndex === targetIndex) + return + + const newCurrentItems = [...currentItems] + const [movedItem] = newCurrentItems.splice(sourceIndex, 1) + newCurrentItems.splice(targetIndex, 0, movedItem) + + const parentBookmark = bookmarks.find( + (b) => b.id === currentFolderId || b.onlineId === currentFolderId + ) + + const updatedBookmarks = bookmarks.map((bookmark) => { + const isInCurrentFolder = + bookmark.parentId === currentFolderId || + (parentBookmark?.id && bookmark.parentId === parentBookmark.id) || + (parentBookmark?.onlineId && + bookmark.parentId === parentBookmark.onlineId) + + if (isInCurrentFolder) { + const newIndex = newCurrentItems.findIndex( + (item) => item.id === bookmark.id || item.onlineId === bookmark.id + ) + if (newIndex !== -1) { + return { ...bookmark, order: newIndex } + } + } + return bookmark + }) + + setBookmarks(updatedBookmarks) + + const itemsToReorder = updatedBookmarks.filter( + (b) => + b.parentId === currentFolderId || + (parentBookmark?.id && b.parentId === parentBookmark.id) || + (parentBookmark?.onlineId && b.parentId === parentBookmark.onlineId) + ) + + let folderIdForApi = currentFolderId + if (parentBookmark?.onlineId) { + folderIdForApi = parentBookmark.onlineId + } + + try { + await updateOrder({ + folderId: folderIdForApi, + bookmarks: itemsToReorder.map((b) => ({ + id: b.onlineId || b.id, + order: b.order, + })), + }) + } catch { + showToast('خطا در مرتب‌سازی بوکمارک‌ها', 'error') + } + + Analytics.event('bookmark_reorder') + } + + const getDisplayedBookmarks = (): Bookmark[] => { + const bookmarkCount = currentItems.length + const minSlots = 10 + const needsFillers = bookmarkCount < minSlots + const fillersCount = needsFillers ? minSlots - bookmarkCount : 0 + const folderItems = [...currentItems, ...new Array(fillersCount).fill(null)] + + if (bookmarkCount >= minSlots) { + folderItems.push(null) + } + return folderItems + } + + const displayedBookmarks = getDisplayedBookmarks() + + return ( + + 📁 + + {currentFolder?.title || 'پوشه بوکمارک'} + +
+ } + size="md" + className="max-w-xl min-h-95 md:min-h-100 h-95 md:h-100 flex flex-col [&>div:last-child]:flex-1 [&>div:last-child]:min-h-0 [&>div:last-child]:flex [&>div:last-child]:flex-col" + direction="rtl" + closeOnBackdropClick={true} + > +
+ {folderPath.length > 1 && ( +
+ +
+ )} + + +
+ setShowAddModal(true)} + onOpenFolder={handleOpenSubFolder} + colsCount={5} + isModal={true} + /> +
+
+
+ + {showAddModal && !isAuthenticated ? ( + setShowAddModal(false)} + message="برای افزودن بوکمارک جدید اول وارد حسابت شو" + /> + ) : ( + showAddModal && ( + setShowAddModal(false)} + onAdd={(bookmark) => + addBookmark(bookmark, () => setShowAddModal(false)) + } + parentId={currentFolderId} + widgetId={instanceId} + onOpenImport={() => { + setShowAddModal(false) + setShowImportModal(true) + }} + /> + ) + )} + + setShowImportModal(false)} + parentId={currentFolderId} + /> + + ) +} diff --git a/src/layouts/bookmark/components/modal/edit-bookmark.modal.tsx b/src/layouts/bookmark/components/modal/edit-bookmark.modal.tsx index 15ceab113..8a6e18398 100644 --- a/src/layouts/bookmark/components/modal/edit-bookmark.modal.tsx +++ b/src/layouts/bookmark/components/modal/edit-bookmark.modal.tsx @@ -3,7 +3,7 @@ import { Button, Modal } from '@/components/ui' import { TextInput } from '@/components/ui' import { useEffect, useState } from 'react' import type { Bookmark } from '../../types/bookmark.types' -import { type IconSourceType, ShowAdvancedButton } from '../shared' +import { ShowAdvancedButton } from '../shared' import { AdvancedModal } from './advanced.modal' import { useIsMutating } from '@tanstack/react-query' import { BookmarkIconPicker } from '../bookmark-icon.picker' @@ -12,7 +12,7 @@ interface EditBookmarkModalProps { isOpen: boolean onClose: () => void onSave: (out: BookmarkUpdateFormFields) => void - bookmark: Bookmark + bookmark: Bookmark | null } export interface BookmarkUpdateFormFields { @@ -24,7 +24,7 @@ export interface BookmarkUpdateFormFields { customBackground: string | null customTextColor: string | null sticker: string | null - icon: File | null + icon: File | string | null isDeletedIcon: boolean } @@ -57,7 +57,6 @@ export function EditBookmarkModal({ const isUpdating = useIsMutating({ mutationKey: ['updateBookmark'] }) > 0 - const [iconSource, setIconSource] = useState('auto') const [showAdvanced, setShowAdvanced] = useState(false) const [icon, setIcon] = useState(null) @@ -86,14 +85,6 @@ export function EditBookmarkModal({ onlineId: bookmark.onlineId || undefined, isDeletedIcon: formData.isDeletedIcon, }) - // onClose() - } - - const onRemoveIcon = () => { - setIcon(null) - updateFormData('icon', null) - updateFormData('isDeletedIcon', true) - setIconSource('auto') } const handleAdvancedModalClose = ( @@ -129,130 +120,135 @@ export function EditBookmarkModal({ customBackground: bookmark.customBackground, customImage: null, customTextColor: bookmark.customTextColor, - icon: null, + icon: bookmark.icon || null, sticker: bookmark.sticker, url: bookmark.url, isDeletedIcon: false, }) - setIconSource(bookmark.icon ? 'upload' : 'auto') - console.log(bookmark) if (bookmark.icon) { setIcon(bookmark.icon) } else if (bookmark.type === 'BOOKMARK' && bookmark.url) { setIcon(getFaviconFromUrl(bookmark.url)) + } else { + setIcon(null) } + } else { + setFormData(structuredClone(empty)) + setIcon(null) } - }, [bookmark]) + }, [bookmark, isOpen]) if (!bookmark) return null return ( - -
-
-
-
+ <> + +
{ + e.preventDefault() + handleSave() + }} + onContextMenu={(e) => e.stopPropagation()} + className="flex flex-col gap-4" + > +
+
updateFormData('icon', value)} + onChange={(value) => { + updateFormData('icon', value) + if (value) { + updateFormData('isDeletedIcon', false) + setIcon(value) + } else { + updateFormData('isDeletedIcon', true) + setIcon(null) + } + }} value={icon} url={formData.url} /> - {iconSource === 'upload' && Boolean(icon) && ( - - )} -
-
- updateFormData('title', value)} - className={ - 'w-full px-4 py-3 text-right rounded-lg transition-all duration-200' - } - /> +
+ updateFormData('title', value)} + className="w-full px-3.5 py-2.5 text-right transition-all duration-200 rounded-xl" + /> +
+
-
{type === 'BOOKMARK' && ( - updateFormData('url', value)} - className={ - 'w-full px-4 py-3 text-right absolute rounded-lg transition-all duration-300' - } - /> +
+ updateFormData('url', value)} + className="w-full px-3.5 py-2.5 text-left font-mono text-xs transition-all duration-200 rounded-xl" + /> +
)}
-
-
- - -
- - +
+ + +
+ + +
-
-
+ + - + ) } diff --git a/src/layouts/bookmark/components/modal/import-browser-bookmarks.modal.tsx b/src/layouts/bookmark/components/modal/import-browser-bookmarks.modal.tsx index 01ac65a9e..0376cb809 100644 --- a/src/layouts/bookmark/components/modal/import-browser-bookmarks.modal.tsx +++ b/src/layouts/bookmark/components/modal/import-browser-bookmarks.modal.tsx @@ -3,15 +3,15 @@ import Analytics from '@/analytics' import { showToast } from '@/common/toast' import { getFaviconFromUrl } from '@/common/utils/icon' import { useGeneralSetting } from '@/context/general-setting.context' -import { Icon } from '@/src/icons' +import { Icon } from '@/icons' import { Button, Modal } from '@/components/ui' import { useBookmarkStore } from '../../context/bookmark.context' -import { MAX_BROWSER_IMPORT_ITEMS } from '../../constants/browser-import.constant' +import { MAX_BROWSER_IMPORT_ITEMS } from '../../constants' import type { BrowserImportNode } from '../../types/bookmark.types' import { type FetchedBrowserBookmark, getBrowserBookmarks, -} from '../../utils/browser-bookmarks.util' +} from '../../utils/browser-bookmarks' interface ImportBrowserBookmarksModalProps { isOpen: boolean @@ -299,7 +299,7 @@ export function ImportBrowserBookmarksModal({ size="sm" onClick={handlePermission} rounded={'2xl'} - variant={'primary'} + color={'primary'} > فعال‌سازی دسترسی @@ -347,14 +347,14 @@ export function ImportBrowserBookmarksModal({ onClick={onClose} size="md" disabled={isImporting} - className="w-20 transition-colors duration-300 ease-in-out bg-base-300 hover:bg-error/10 text-base-content/80 hover:text-error rounded-2xl" + className="w-20 transition-colors duration-300 ease-in-out rounded-2xl" > انصراف
) @@ -57,10 +57,9 @@ export function ShowAdvancedButton({ - ) - })} + {cat.category} + + ) + })} +
)} -
-
-
- {isLoading ? ( - - ) : ( -
- {contents.map( - (category: CategoryItem, index: number) => ( - - ) - )} -
- )} -
+
+
+ {isLoading ? ( + + ) : ( +
+ {contents.map((category: CategoryItem) => ( + + ))} +
+ )}
diff --git a/src/layouts/friends/components/activity-card/empty-activity-card.tsx b/src/layouts/friends/components/activity-card/empty-activity-card.tsx index 00c77cccf..4b1fff989 100644 --- a/src/layouts/friends/components/activity-card/empty-activity-card.tsx +++ b/src/layouts/friends/components/activity-card/empty-activity-card.tsx @@ -1,5 +1,5 @@ import { AvatarComponent } from '@/components/ui' -import { Icon } from '@/src/icons' +import { Icon } from '@/icons' interface EmptyActivityCardProps { avatar: string diff --git a/src/layouts/friends/components/activity-card/manage-activity.bottom-sheet.tsx b/src/layouts/friends/components/activity-card/manage-activity.bottom-sheet.tsx index 6cfbcbf9b..dbf771157 100644 --- a/src/layouts/friends/components/activity-card/manage-activity.bottom-sheet.tsx +++ b/src/layouts/friends/components/activity-card/manage-activity.bottom-sheet.tsx @@ -18,7 +18,7 @@ import { callEvent } from '@/common/utils/call-event' import { Chip } from '@/components/ui' import { SelectBox } from '@/components/ui' import { Tooltip } from '@/components/ui' -import { Icon } from '@/src/icons' +import { Icon } from '@/icons' interface ManageActivityBottomSheetProps { isOpen: boolean @@ -104,7 +104,9 @@ export function ManageActivityBottomSheet({ onClick={handleDelete} disabled={isRemoving} size="xs" - className="border shadow rounded-xl left-1 group btn btn-error shadow-error/20" + color="danger" + rounded="xl" + className="shadow left-1 group shadow-error/20" loading={isRemoving} >
@@ -251,7 +253,7 @@ export function ManageActivityBottomSheet({ disabled={isSubmitting || !activity.trim()} size="sm" className="w-full mt-1" - variant={'primary'} + color={'primary'} rounded={'2xl'} loading={isSubmitting} > @@ -287,7 +289,9 @@ export function ManageActivityBottomSheet({ diff --git a/src/layouts/friends/components/buttons/friend-requests.button.tsx b/src/layouts/friends/components/buttons/friend-requests.button.tsx index ea795d33a..244b76f2f 100644 --- a/src/layouts/friends/components/buttons/friend-requests.button.tsx +++ b/src/layouts/friends/components/buttons/friend-requests.button.tsx @@ -1,5 +1,5 @@ import { FriendRequestsBottomSheet } from '../friend-requests.bottom-sheet' -import { Icon } from '@/src/icons' +import { Icon } from '@/icons' interface Prop { size: 'small' | 'large' diff --git a/src/layouts/friends/components/empty-list.friend.tsx b/src/layouts/friends/components/empty-list.friend.tsx index 497b41ea3..a5015ce59 100644 --- a/src/layouts/friends/components/empty-list.friend.tsx +++ b/src/layouts/friends/components/empty-list.friend.tsx @@ -1,4 +1,4 @@ -import { Icon } from '@/src/icons' +import { Icon } from '@/icons' interface Prop { emptyMessage?: string diff --git a/src/layouts/friends/components/friend-item/friend.item.tsx b/src/layouts/friends/components/friend-item/friend.item.tsx deleted file mode 100644 index 08eb73874..000000000 --- a/src/layouts/friends/components/friend-item/friend.item.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { AvatarComponent } from '@/components/ui' - -interface Prop { - avatar: string - name: string - username: string | null - actions: React.ReactNode -} -export function FriendItem({ avatar, name, username, actions }: Prop) { - return ( -
-
-
-
- -
-
- -
-
- {name} -
-
- {username}@ -
-
-
- -
{actions}
-
- ) -} diff --git a/src/layouts/friends/components/friend-requests.bottom-sheet.tsx b/src/layouts/friends/components/friend-requests.bottom-sheet.tsx index 3ed9bd870..37b896a72 100644 --- a/src/layouts/friends/components/friend-requests.bottom-sheet.tsx +++ b/src/layouts/friends/components/friend-requests.bottom-sheet.tsx @@ -7,7 +7,7 @@ import { RemoveFriendButton } from './remove-button' import { FriendsList } from './friends-list' import { showToast } from '@/common/toast' import { Button, Modal } from '@/components/ui' -import { Icon } from '@/src/icons' +import { Icon } from '@/icons' interface Prop { isOpen: boolean @@ -45,7 +45,9 @@ export const FriendRequestsBottomSheet = ({ isOpen, onClose }: Prop) => { size="sm" onClick={() => acceptFriend(friend.id)} disabled={isProcessing} - className="flex items-center justify-center gap-1 h-9 px-3 text-success bg-success/10 border border-success/20 rounded-lg transition-all active:scale-[0.97]" + className="gap-1 h-9 px-3 rounded-lg transition-all active:scale-[0.97]" + variant="outline" + color="success" > دوست شیم diff --git a/src/layouts/friends/components/remove-button.tsx b/src/layouts/friends/components/remove-button.tsx index 0670f41de..d4ef871e8 100644 --- a/src/layouts/friends/components/remove-button.tsx +++ b/src/layouts/friends/components/remove-button.tsx @@ -1,6 +1,6 @@ import { Button } from '@/components/ui' import type { Friend } from '@/services/hooks/friends/friend-service.hook' -import { Icon } from '@/src/icons' +import { Icon } from '@/icons' type Props = { friend: Friend @@ -16,14 +16,15 @@ export function RemoveFriendButton({ friend, onClick, disabled, label }: Props) onClick={() => onClick(friend.id)} disabled={disabled} size="sm" + variant="outline" + color="danger" + rounded="lg" className=" - flex items-center justify-center gap-1 + gap-1 h-9 px-3 - text-error bg-error/10 border border-error/20 - rounded-lg transition-all - hover:bg-error/20 hover:scale-[1.03] + hover:scale-[1.03] active:scale-[0.97] - disabled:opacity-50 disabled:cursor-not-allowed + disabled:cursor-not-allowed " > diff --git a/src/layouts/friends/friends.tsx b/src/layouts/friends/friends.tsx index bcc94a652..29870a591 100644 --- a/src/layouts/friends/friends.tsx +++ b/src/layouts/friends/friends.tsx @@ -1,5 +1,8 @@ import { useState } from 'react' -import { type Friend, useRemoveFriend } from '@/services/hooks/friends/friend-service.hook' +import { + type Friend, + useRemoveFriend, +} from '@/services/hooks/friends/friend-service.hook' import { translateError } from '@/common/utils/translate-error' import { showToast } from '@/common/toast' import { RemoveFriendButton } from './components/remove-button' @@ -8,7 +11,7 @@ import { AddFriendBottomSheet } from './components/add-friend.bottom-sheet' import { ConfirmationModal } from '@/components/ui' import { FriendRequestsButton } from './components/buttons/friend-requests.button' import { useAuth } from '@/context/auth.context' -import { Icon } from '@/src/icons' +import { Icon } from '@/icons' export const FriendsLayout = () => { const { user } = useAuth() @@ -56,7 +59,7 @@ export const FriendsLayout = () => { - )} +
+ )} + +
+
diff --git a/src/layouts/market/other-items/components/render-preview.tsx b/src/layouts/market/other-items/components/render-preview.tsx index e93333e8b..093f072d6 100644 --- a/src/layouts/market/other-items/components/render-preview.tsx +++ b/src/layouts/market/other-items/components/render-preview.tsx @@ -1,5 +1,5 @@ -import { getItemTypeEmoji } from '@/components/market/get-item-type-emoji' -import { renderBrowserTitlePreview } from '@/components/market/title/title-render-preview' +import { getItemTypeEmoji } from './get-item-type-emoji' +import { renderBrowserTitlePreview } from '@/components/browser-title-preview' import type { MarketItem } from '@/services/hooks/market/market.interface' interface RenderPreviewProps { diff --git a/src/layouts/market/other-items/index.tsx b/src/layouts/market/other-items/index.tsx index e0366d92d..03e339f34 100644 --- a/src/layouts/market/other-items/index.tsx +++ b/src/layouts/market/other-items/index.tsx @@ -12,7 +12,7 @@ import { showToast } from '@/common/toast' import { Chip } from '@/components/ui' import { useAppearanceSetting } from '@/context/appearance.context' import { usePreviewHandler } from '@/hooks/use-preview-handler' -import { Icon } from '@/src/icons' +import { Icon } from '@/icons' const FILTER_OPTIONS = [ { id: 'all', label: 'همه' }, diff --git a/src/layouts/mini-apps/components/card/mini-app-card.tsx b/src/layouts/mini-apps/components/card/mini-app-card.tsx index a9cbd4df5..1e6538859 100644 --- a/src/layouts/mini-apps/components/card/mini-app-card.tsx +++ b/src/layouts/mini-apps/components/card/mini-app-card.tsx @@ -1,6 +1,6 @@ import { getContrastingTextColor } from '@/common/color' import { NewBadge } from '@/components/ui' -import type { MiniApp } from '@/services/hooks/mini-apps/mini-apps-interface' +import type { MiniApp } from '@/services/hooks/mini-apps/mini-apps.interface' interface MiniAppCardProps { app: MiniApp diff --git a/src/layouts/mini-apps/components/header/runner.header.tsx b/src/layouts/mini-apps/components/header/runner.header.tsx index f48314134..cf2a8142d 100644 --- a/src/layouts/mini-apps/components/header/runner.header.tsx +++ b/src/layouts/mini-apps/components/header/runner.header.tsx @@ -1,6 +1,6 @@ import { callEvent } from '@/common/utils/call-event' -import type { MiniApp } from '@/services/hooks/mini-apps/mini-apps-interface' -import { Icon } from '@/src/icons' +import type { MiniApp } from '@/services/hooks/mini-apps/mini-apps.interface' +import { Icon } from '@/icons' interface Prop { onClickToBack: any diff --git a/src/layouts/mini-apps/mini-apps.layout.tsx b/src/layouts/mini-apps/mini-apps.layout.tsx index 874f31a31..b245870d5 100644 --- a/src/layouts/mini-apps/mini-apps.layout.tsx +++ b/src/layouts/mini-apps/mini-apps.layout.tsx @@ -6,7 +6,7 @@ import { MiniAppRunner } from './mini-app-runner' import { listenEvent } from '@/common/utils/call-event' import Analytics from '@/analytics' import { Button, Modal } from '@/components/ui' -import { Icon } from '@/src/icons' +import { Icon } from '@/icons' const EmptyMiniAppImage = 'https://cdn.widgetify.ir/extension/empty-mini-app.png' export function MiniAppsLayout() { const { data, isLoading, isFetchingNextPage, fetchNextPage, hasNextPage, isError } = @@ -146,10 +146,9 @@ export function MiniAppsLayout() {
- {showInfo && ( - setShowInfo(false)} direction="rtl" > @@ -175,15 +174,15 @@ export function MiniAppsLayout() { - )}
) } diff --git a/src/layouts/mini-apps/states/mini-app-error.tsx b/src/layouts/mini-apps/states/mini-app-error.tsx index 8ff1bf2fb..c02761c4f 100644 --- a/src/layouts/mini-apps/states/mini-app-error.tsx +++ b/src/layouts/mini-apps/states/mini-app-error.tsx @@ -35,7 +35,7 @@ export function MiniAppError({ handleReload, onClickToBack }: Prop) { type="button" className="w-full" rounded={'2xl'} - variant={'primary'} + color={'primary'} onClick={handleReload} > تلاش مجدد @@ -44,7 +44,6 @@ export function MiniAppError({ handleReload, onClickToBack }: Prop) { type="button" size="sm" // className="" - variant={'default'} rounded={'2xl'} onClick={() => onClickToBack()} > diff --git a/src/layouts/mini-apps/states/mini-app.auth.tsx b/src/layouts/mini-apps/states/mini-app.auth.tsx index 978a263c2..84be89fd7 100644 --- a/src/layouts/mini-apps/states/mini-app.auth.tsx +++ b/src/layouts/mini-apps/states/mini-app.auth.tsx @@ -2,8 +2,8 @@ import Analytics from '@/analytics' import { callEvent } from '@/common/utils/call-event' import { Button } from '@/components/ui' import { useAuth } from '@/context/auth.context' -import type { MiniAppScopeEnum } from '@/services/hooks/mini-apps/mini-apps-interface' -import { Icon } from '@/src/icons' +import type { MiniAppScopeEnum } from '@/services/hooks/mini-apps/mini-apps.interface' +import { Icon } from '@/icons' interface Prop { scopes: MiniAppScopeEnum[] @@ -57,7 +57,7 @@ export function WebAppAuthGate({ scopes, onConfirm }: Prop) { onClick={onClick} size="sm" rounded="xl" - variant={'primary'} + color={'primary'} className="w-full text-sm font-medium border-none" > تایید و ادامه @@ -79,7 +79,7 @@ export function WebAppAuthGate({ scopes, onConfirm }: Prop) { onClick={() => callEvent('openProfile')} size="sm" rounded="xl" - variant={'primary'} + color={'primary'} className="w-full text-sm font-medium border-none" > باشه، ورود به حساب diff --git a/src/components/blur-mode/blur-mode.button.tsx b/src/layouts/navbar/components/blur-mode.button.tsx similarity index 72% rename from src/components/blur-mode/blur-mode.button.tsx rename to src/layouts/navbar/components/blur-mode.button.tsx index e8bd33daf..8a1d9de1e 100644 --- a/src/components/blur-mode/blur-mode.button.tsx +++ b/src/layouts/navbar/components/blur-mode.button.tsx @@ -1,7 +1,6 @@ import { useState } from 'react' import { useGeneralSetting } from '@/context/general-setting.context' -import { Tooltip } from '@/components/ui' -import { Icon } from '@/src/icons' +import { Icon } from '@/icons' import { ConfirmationModal } from '@/components/ui' import Analytics from '@/analytics' @@ -29,18 +28,16 @@ export function BlurModeButton() { return ( <> - -
- {blurMode ? ( - - ) : ( - - )} -
-
+
+ {blurMode ? ( + + ) : ( + + )} +
void) => { + callEvent('closeAllDropdowns') + action() + } + + const handleToggleEditMode = () => { + handleAction(() => { + setCanvasMode(canvasMode === 'edit' ? 'normal' : 'edit') + }) + } + + return ( + + +
+ } + > +
+ } + label="مدیریت ویجت‌ها" + onClick={() => + handleAction(() => callEvent('openAddCustomWidgetModal')) + } + /> + + } + label={canvasMode === 'edit' ? 'پایان ویرایش' : 'حالت ویرایش'} + onClick={handleToggleEditMode} + /> + + } + label="تنظیمات ظاهری" + onClick={() => + handleAction(() => callEvent('openSettings', 'appearance')) + } + /> + + } + label="تصویر زمینه‌ها" + onClick={() => + handleAction(() => callEvent('openSettings', 'wallpapers')) + } + /> +
+ + ) +} diff --git a/src/layouts/navbar/components/nav-button.tsx b/src/layouts/navbar/components/nav-button.tsx deleted file mode 100644 index 694431ee9..000000000 --- a/src/layouts/navbar/components/nav-button.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import type { JSX } from 'react' - -interface NavButtonProps { - onClick: () => void - icon: JSX.Element - id: string - badge?: JSX.Element | null - isActive?: boolean -} -export const NavButton = ({ - onClick, - icon, - id, - badge = null, - isActive = false, -}: NavButtonProps) => ( -
- {icon} - {badge} -
-) diff --git a/src/layouts/navbar/components/settings-dropdown.tsx b/src/layouts/navbar/components/settings-dropdown.tsx deleted file mode 100644 index f487d3061..000000000 --- a/src/layouts/navbar/components/settings-dropdown.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { useCallback } from 'react' -import { callEvent } from '@/common/utils/call-event' -import { UI, useAppearanceSetting } from '@/context/appearance.context' -import { showToast } from '@/common/toast' -import { Icon } from '@/src/icons' -import { useAuth } from '@/context/auth.context' -import { Dropdown } from '@/components/ui' - -export const SettingsDropdown = () => { - const { isAuthenticated } = useAuth() - const { canReOrderWidget, toggleCanReOrderWidget, ui, setUI } = useAppearanceSetting() - const triggerRef = useRef(null) - - const handleWidgetSettingsClick = useCallback(() => { - callEvent('openWidgetsSettings', { tab: null }) - callEvent('closeAllDropdowns') - }, []) - - const handleSettingsClick = useCallback(() => { - callEvent('openSettings', 'general') - callEvent('closeAllDropdowns') - }, []) - - const onClick = () => { - if (ui === UI.SIMPLE) { - showToast('در حالت ظاهری ساده، امکان تغییر و جابجایی ویجت ها نیست!', 'error') - return - } - toggleCanReOrderWidget() - callEvent('closeAllDropdowns') - } - - const onClickToChangeUI = () => { - setUI(ui === 'SIMPLE' ? UI.ADVANCED : UI.SIMPLE) - } - - const onClickToOpenGallery = () => { - callEvent('openSettings', 'wallpapers') - callEvent('closeAllDropdowns') - } - - return ( - - -
- } - > -
- - - - -
onClickToOpenGallery()} - > -
- - تصویر زمینه‌ها -
-
- - {isAuthenticated ? ( -
onClickToChangeUI()} - > -
- - {ui === UI.SIMPLE ? ( - تغییر حالت ظاهری به پیشفرض - ) : ( - تغییر حالت ظاهری به ساده - )} -
-
- ) : null} - -
onClick()} - > -
- - {canReOrderWidget ? ( - غیرفعال‌سازی حالت جابجایی - ) : ( - حالت جابجایی ویجت ها - )} -
-
-
- - ) -} diff --git a/src/layouts/navbar/friends-list/friends.navbar.tsx b/src/layouts/navbar/friends-list/friends.navbar.tsx index e773bc014..0f042c352 100644 --- a/src/layouts/navbar/friends-list/friends.navbar.tsx +++ b/src/layouts/navbar/friends-list/friends.navbar.tsx @@ -6,7 +6,7 @@ import { ActiveFriendsHorizontal } from '@/layouts/friends/components/activities import { callEvent, listenEvent } from '@/common/utils/call-event' import { FriendRequestsButton } from '@/layouts/friends/components/buttons/friend-requests.button' import Analytics from '@/analytics' -import { Icon } from '@/src/icons' +import { Icon } from '@/icons' const renderPendingNotification = (pendingCount: number) => (
@@ -87,16 +87,14 @@ export function FriendsListNavbar() {
- {firstAuth && ( - - )} + ) } diff --git a/src/layouts/navbar/market/market-button.tsx b/src/layouts/navbar/market/market-button.tsx deleted file mode 100644 index cd1e62805..000000000 --- a/src/layouts/navbar/market/market-button.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { useEffect, useState } from 'react' -import Analytics from '@/analytics' -import { listenEvent } from '@/common/utils/call-event' -import { Modal } from '@/components/ui' -import { MarketContainer } from '@/layouts/market/market-container' -import { Icon } from '@/src/icons' - -export function MarketButton() { - const [showMarket, setShowMarket] = useState(false) - - const handleClick = () => { - setShowMarket(true) - Analytics.event('market_opened') - } - - useEffect(() => { - const event = listenEvent('openMarketModal', () => handleClick()) - return () => { - event() - } - }, []) - - return ( - <> - - setShowMarket(false)} - title="فروشگاه" - size="xl" - direction="rtl" - closeOnBackdropClick={true} - > - - - - ) -} diff --git a/src/layouts/navbar/market/market-modal-listener.tsx b/src/layouts/navbar/market/market-modal-listener.tsx new file mode 100644 index 000000000..2d33dab3f --- /dev/null +++ b/src/layouts/navbar/market/market-modal-listener.tsx @@ -0,0 +1,34 @@ +import { useEffect, useState } from 'react' +import Analytics from '@/analytics' +import { listenEvent } from '@/common/utils/call-event' +import { Modal } from '@/components/ui' +import { MarketContainer } from '@/layouts/market/market-container' + +export function MarketModalListener() { + const [showMarket, setShowMarket] = useState(false) + + const handleOpen = () => { + setShowMarket(true) + Analytics.event('market_opened') + } + + useEffect(() => { + const event = listenEvent('openMarketModal', () => handleOpen()) + return () => { + event() + } + }, []) + + return ( + setShowMarket(false)} + title="فروشگاه" + size="xl" + direction="rtl" + closeOnBackdropClick={true} + > + + + ) +} diff --git a/src/layouts/navbar/navbar.layout.tsx b/src/layouts/navbar/navbar.layout.tsx index 61d16eb2e..d33cb385a 100644 --- a/src/layouts/navbar/navbar.layout.tsx +++ b/src/layouts/navbar/navbar.layout.tsx @@ -1,23 +1,25 @@ -import { type JSX, useCallback, useEffect, useState } from 'react' +import { type JSX, useCallback, useEffect, useMemo, useState } from 'react' import { getFromStorage, setToStorage } from '@/common/storage' import { callEvent, listenEvent } from '@/common/utils/call-event' import { SettingModal } from '../setting/setting-modal' -import { SettingsDropdown } from './components/settings-dropdown' import { FriendsListNavbar } from './friends-list/friends.navbar' +import { LayoutDropdown } from './components/layout-dropdown' import { ProfileNav } from './profile/profile' -import { useAppearanceSetting } from '@/context/appearance.context' -import { MarketButton } from './market/market-button' +import { NotificationNavbar } from './notifications/notification.navbar' +import { MarketModalListener } from './market/market-modal-listener' import Analytics from '@/analytics' import { Page, usePage } from '@/context/page.context' import { useAuth } from '@/context/auth.context' -import { BlurModeButton } from '@/components/blur-mode/blur-mode.button' +import { useAppearance } from '@/context/appearance.context' +import { BlurModeButton } from './components/blur-mode.button' import type { UserProfile } from '@/services/hooks/user/user-service.hook' -import { Tooltip } from '@/components/ui' +import { NewBadge } from '@/components/ui' import { SyncAccount } from './sync' -import { getCurrentDate } from '../widgets/calendar/utils' +import { getCurrentDate } from '../widgets/calendar/utils/date-events' import { useBirthdayConfetti } from '@/hooks/use-birthday-confetti' -import { Icon } from '@/src/icons' +import { Icon } from '@/icons' import { GetUserFirstName } from '@/common/utils/get-firstname' +import { useGetNotifications } from '@/services/hooks/extension/get-notifications.hook' const WIDGETIFY_URLS = { website: 'https://widgetify.ir', @@ -88,16 +90,26 @@ export function NavbarTabs() { } export function NavbarLayout(): JSX.Element { - const { canReOrderWidget, toggleCanReOrderWidget } = useAppearanceSetting() const [showSettings, setShowSettings] = useState(false) const [isVisible, setIsVisible] = useState(false) const { user } = useAuth() + const { canvasMode } = useAppearance() + const isEditingCanvas = canvasMode === 'edit' + const showNavbar = isVisible && !isEditingCanvas + const showHandle = !isVisible && !isEditingCanvas const [tab, setTab] = useState(null) const handleOpenSettings = useCallback((tabName: string | null) => { setTab(tabName) setShowSettings(true) }, []) + const { data: notificationsData } = useGetNotifications() + + const hasCloseableNotifications = useMemo(() => { + const cardItems = notificationsData?.widgetifyCard || [] + return cardItems.some((item) => item.closeable) + }, [notificationsData]) + const onToggleNavbar = () => { if (isVisible) { callEvent('close_friends_bottomSheet') @@ -107,7 +119,10 @@ export function NavbarLayout(): JSX.Element { Analytics.event(`navbar_${isVisible ? 'closed' : 'opened'}`) } - const settingsModalCloseHandler = () => setShowSettings(false) + const settingsModalCloseHandler = () => { + setShowSettings(false) + setTab(null) + } useEffect(() => { const load = async () => { @@ -127,42 +142,26 @@ export function NavbarLayout(): JSX.Element { useBirthdayConfetti(user?.isBirthdayToday || false) return ( <> - {canReOrderWidget && ( -
-
-
- - حالت جابجایی فعال، ویجت هارو جابجا کنید - -
-
-
- )} - - {!isVisible && ( - - )} +
- - - + + - - +
+ + ) diff --git a/src/layouts/widgetify-card/notification-center/components/notification-item.tsx b/src/layouts/navbar/notifications/notification-center/components/notification-item.tsx similarity index 99% rename from src/layouts/widgetify-card/notification-center/components/notification-item.tsx rename to src/layouts/navbar/notifications/notification-center/components/notification-item.tsx index 73f87b81b..37fb2c8b2 100644 --- a/src/layouts/widgetify-card/notification-center/components/notification-item.tsx +++ b/src/layouts/navbar/notifications/notification-center/components/notification-item.tsx @@ -2,7 +2,7 @@ import { callEvent } from '@/common/utils/call-event' import { useState } from 'react' import Analytics from '@/analytics' import type { NotificationItem } from '@/services/hooks/extension/get-notifications.hook' -import { Icon } from '@/src/icons' +import { Icon } from '@/icons' interface NotificationItemProps { onClose(e: any, id: string): any diff --git a/src/layouts/widgetify-card/daily-mood.tsx b/src/layouts/navbar/notifications/notification-center/daily-mood.tsx similarity index 92% rename from src/layouts/widgetify-card/daily-mood.tsx rename to src/layouts/navbar/notifications/notification-center/daily-mood.tsx index e2b77cf91..79929d10b 100644 --- a/src/layouts/widgetify-card/daily-mood.tsx +++ b/src/layouts/navbar/notifications/notification-center/daily-mood.tsx @@ -1,16 +1,17 @@ import Analytics from '@/analytics' -import { moodOptions } from '@/common/constant/moods' +import { moodOptions } from '@/common/constants/moods' import { autoFormatErrorToast, showToast } from '@/common/toast' import { callEvent } from '@/common/utils/call-event' import { GetUserFirstName } from '@/common/utils/get-firstname' import { useAuth } from '@/context/auth.context' -import { useDate } from '@/context/date.context' +import { useGeneralSetting } from '@/context/general-setting.context' +import { getCurrentDate } from '@/layouts/widgets/calendar/utils/date-events' import { safeAwait } from '@/services/api' import { type MoodType, useUpsertMoodLog, } from '@/services/hooks/mood-log/upsert-mood-log.hook' -import { Icon } from '@/src/icons' +import { Icon } from '@/icons' import { useIsMutating, useQueryClient } from '@tanstack/react-query' import type { AxiosError } from 'axios' @@ -20,7 +21,8 @@ interface Prop { export function DailyMoodNotification({ className }: Prop) { const queryClient = useQueryClient() const { user } = useAuth() - const { today } = useDate() + const { selected_timezone: timezone } = useGeneralSetting() + const today = getCurrentDate(timezone.value) const { mutateAsync: upsertMoodLog } = useUpsertMoodLog() const [mood, setMood] = useState() const isAdding = useIsMutating({ mutationKey: ['upsertMoodLog'] }) > 0 diff --git a/src/layouts/widgetify-card/notification-center/notification-center.tsx b/src/layouts/navbar/notifications/notification-center/notification-center.tsx similarity index 75% rename from src/layouts/widgetify-card/notification-center/notification-center.tsx rename to src/layouts/navbar/notifications/notification-center/notification-center.tsx index 5d6b89726..020ea6550 100644 --- a/src/layouts/widgetify-card/notification-center/notification-center.tsx +++ b/src/layouts/navbar/notifications/notification-center/notification-center.tsx @@ -1,7 +1,8 @@ +import { useEffect, useMemo, useState } from 'react' +import type { ReactNode } from 'react' import { NotificationCardItem } from './components/notification-item' import { listenEvent } from '@/common/utils/call-event' import { getWithExpiry, setToStorage, setWithExpiry } from '@/common/storage' -import type { ReactNode } from 'react' import { type NotificationItem, useGetNotifications, @@ -9,22 +10,28 @@ import { } from '@/services/hooks/extension/get-notifications.hook' import Analytics from '@/analytics' import { useAuth } from '@/context/auth.context' -import { DailyMoodNotification } from '../daily-mood' -import { ProfileProgressNotification } from '../profile-progress' +import { DailyMoodNotification } from './daily-mood' +import { ProfileProgressNotification } from './profile-progress' import { safeAwait } from '@/services/api' const localIds = ['notificationMood', 'update_profile'] interface Prop { hasBorder?: boolean } + export function NotificationCenter({ hasBorder }: Prop = { hasBorder: true }) { const { user, isAuthenticated, isLoadingUser, profilePercentage } = useAuth() - const { data: fetchedNotifications, dataUpdatedAt } = useGetNotifications() + const { data: fetchedNotifications } = useGetNotifications() const { mutateAsync: notifyAsSeen } = useNotifyAsSeen() - const [notifications, setNotifications] = useState([]) + const [dismissedIds, setDismissedIds] = useState>(new Set()) const [pushed, setPushed] = useState<{ id: string; node: ReactNode }[]>([]) + const notifications = useMemo(() => { + const items = fetchedNotifications?.widgetifyCard || [] + return items.filter((item) => item.id && !dismissedIds.has(item.id)) + }, [fetchedNotifications?.widgetifyCard, dismissedIds]) + const addToNodes = async (notif: { id: string; node: React.ReactNode }) => { const notifFromStorage = await getWithExpiry(`removed_notification_${notif.id}`) if (!notifFromStorage) { @@ -116,45 +123,25 @@ export function NotificationCenter({ hasBorder }: Prop = { hasBorder: true }) { } }, []) - useEffect(() => { - async function handle() { - const validNotifications: NotificationItem[] = [] - - for (const item of fetchedNotifications?.widgetifyCard || []) { - if (item.id) { - if (notifications.findIndex((f) => f.id === item.id) === -1) - validNotifications.push(item) - } - } - setNotifications([...validNotifications]) - } - - handle() - }, [dataUpdatedAt]) - const onClose = async (e: any, id: string, ttl = 1200) => { e.preventDefault() - const notif = notifications.find((f) => f.id === id) - if (notif) { - const filtered = notifications.filter((f) => f.id !== id) - setNotifications([...filtered]) + setDismissedIds((prev) => new Set([...prev, id])) + Analytics.event('notifications_close') - Analytics.event('notifications_close') - if (!localIds.includes(id)) { - await safeAwait(notifyAsSeen(id)) - } else { - await setWithExpiry(`removed_notification_${id}`, 'true', ttl) - } + if (!localIds.includes(id)) { + await safeAwait(notifyAsSeen(id)) + } else { + await setWithExpiry(`removed_notification_${id}`, 'true', ttl) } } return (
- {notifications?.map((item, index) => ( + {notifications.map((item, index) => ( onClose(e, item.id || '', item.ttl)} /> ))} diff --git a/src/layouts/widgetify-card/profile-progress.tsx b/src/layouts/navbar/notifications/notification-center/profile-progress.tsx similarity index 98% rename from src/layouts/widgetify-card/profile-progress.tsx rename to src/layouts/navbar/notifications/notification-center/profile-progress.tsx index bdf24dc11..19aa1ace2 100644 --- a/src/layouts/widgetify-card/profile-progress.tsx +++ b/src/layouts/navbar/notifications/notification-center/profile-progress.tsx @@ -1,7 +1,7 @@ import Analytics from '@/analytics' import { callEvent } from '@/common/utils/call-event' import { useAuth } from '@/context/auth.context' -import { Icon } from '@/src/icons' +import { Icon } from '@/icons' interface Prop { className: string diff --git a/src/layouts/navbar/notifications/notification.navbar.tsx b/src/layouts/navbar/notifications/notification.navbar.tsx new file mode 100644 index 000000000..20e93e849 --- /dev/null +++ b/src/layouts/navbar/notifications/notification.navbar.tsx @@ -0,0 +1,68 @@ +import { useMemo } from 'react' +import { Dropdown, NewBadge } from '@/components/ui' +import { Icon } from '@/icons' +import { useGetNotifications } from '@/services/hooks/extension/get-notifications.hook' +import { NotificationCenter } from '@/layouts/navbar/notifications/notification-center/notification-center' +import Analytics from '@/analytics' + +export function NotificationNavbar() { + const { data: notificationsData } = useGetNotifications() + + const hasCloseableNotifications = useMemo(() => { + const cardItems = notificationsData?.widgetifyCard || [] + return cardItems.some((item) => item.closeable) + }, [notificationsData]) + + const hasNotifications = useMemo(() => { + const cardCount = notificationsData?.widgetifyCard?.length || 0 + return cardCount > 0 + }, [notificationsData]) + + const handleOpen = () => { + Analytics.event('notification_navbar_opened') + } + + return ( + + + {hasCloseableNotifications && } +
+ } + > +
+
+
+ + اعلان‌ها +
+
+ +
+ + + {!hasNotifications && ( +
+
+ +
+ + اعلان جدیدی نداری + + + همه چیز به‌روز و مرتبه + +
+ )} +
+
+ + ) +} diff --git a/src/layouts/navbar/profile/components/profile-dropdown-menu.tsx b/src/layouts/navbar/profile/components/profile-dropdown-menu.tsx new file mode 100644 index 000000000..d9957cc9c --- /dev/null +++ b/src/layouts/navbar/profile/components/profile-dropdown-menu.tsx @@ -0,0 +1,115 @@ +import { callEvent } from '@/common/utils/call-event' +import { AvatarComponent, DropdownDivider, DropdownItem, VipBadge } from '@/components/ui' +import { Icon } from '@/icons' +import type { UserProfile } from '@/services/hooks/user/user-service.hook' + +interface ProfileDropdownMenuProps { + user: UserProfile | null + isAuthenticated: boolean + isVip: boolean + onRequestAuth: () => void + onRequestLogout: () => void +} + +export function ProfileDropdownMenu({ + user, + isAuthenticated, + isVip, + onRequestAuth, + onRequestLogout, +}: ProfileDropdownMenuProps) { + const handleAction = (action: () => void) => { + callEvent('closeAllDropdowns') + action() + } + + const handleProfileClick = () => { + handleAction(() => { + if (!isAuthenticated) { + onRequestAuth() + } else { + callEvent('openSettings', 'profile') + } + }) + } + + return ( +
+ {isAuthenticated ? ( +
+
+ +
+
+
+ + {user?.name || user?.username || 'کاربر ویجتیفای'} + + {isVip && ( + + )} +
+ + مشاهده پروفایل + +
+
+ ) : ( +
+
+ +
+
+ + ورود یا ثبت‌نام + + + همگام‌سازی و دسترسی به امکانات + +
+
+ )} + +
+ } + label="تنظیمات" + onClick={() => + handleAction(() => callEvent('openSettings', 'general')) + } + /> + + } + label="فروشگاه" + onClick={() => handleAction(() => callEvent('openMarketModal'))} + /> + + } + label="ویجتیفای پرو" + badge={!isVip ? : undefined} + onClick={() => handleAction(() => callEvent('openSettings', 'vip'))} + /> +
+ + {isAuthenticated && ( + <> + + } + label="خروج از حساب" + onClick={() => handleAction(onRequestLogout)} + /> + + )} +
+ ) +} diff --git a/src/layouts/navbar/profile/components/profile-trigger.tsx b/src/layouts/navbar/profile/components/profile-trigger.tsx new file mode 100644 index 000000000..6efa60ea6 --- /dev/null +++ b/src/layouts/navbar/profile/components/profile-trigger.tsx @@ -0,0 +1,57 @@ +import { AvatarComponent } from '@/components/ui' +import { Icon } from '@/icons' +import type { UserProfile } from '@/services/hooks/user/user-service.hook' + +interface ProfileTriggerProps { + user: UserProfile | null + isAuthenticated: boolean + profilePercentage: number | null +} + +export function ProfileTrigger({ + user, + isAuthenticated, + profilePercentage, +}: ProfileTriggerProps) { + if (!isAuthenticated) { + return ( +
+
+ +
+
+ ) + } + + return ( +
+
+ {profilePercentage ? ( +
+ ) : null} +
+ +
+
+
+ ) +} diff --git a/src/layouts/navbar/profile/profile.tsx b/src/layouts/navbar/profile/profile.tsx index 215cb9d1d..6b4d0f245 100644 --- a/src/layouts/navbar/profile/profile.tsx +++ b/src/layouts/navbar/profile/profile.tsx @@ -1,55 +1,49 @@ import { useState, useEffect } from 'react' -import { listenEvent } from '@/common/utils/call-event' -import { AvatarComponent } from '@/components/ui' -import { Tooltip } from '@/components/ui' +import { callEvent, listenEvent } from '@/common/utils/call-event' +import { ConfirmationModal, Dropdown, Modal } from '@/components/ui' import { useAuth } from '@/context/auth.context' -import { UserAccountModal } from '../../setting/tabs/account/user-account.modal' -import { WelcomeWizard } from '@/components/welcome-wizard' -import { Icon } from '@/src/icons' - -const renderUserAvatar = (user: any) => { - if (user?.avatar) { - return - } - - const initial = user?.username?.charAt(0) || user?.email?.charAt(0) || 'U' - return {initial} -} - -const getTooltipContent = (user: any) => { - return user?.inCache ? ( - خطا در بارگیری پروفایل - ) : ( - 'پروفایل کاربری' - ) -} +import AuthForm from '../../setting/tabs/account/auth-form/auth-form' +import { ProfileDropdownMenu } from './components/profile-dropdown-menu' +import { ProfileTrigger } from './components/profile-trigger' +import { WelcomeWizard } from './welcome-wizard' export function ProfileNav() { - const { user, isAuthenticated, profilePercentage } = useAuth() - const [showSettingsModal, setShowSettingsModal] = useState(false) + const { user, isAuthenticated, isVip, profilePercentage, logout } = useAuth() + const [showAuthModal, setShowAuthModal] = useState(false) + const [showLogoutModal, setShowLogoutModal] = useState(false) const [openedWizard, setOpenedWizard] = useState(false) - const [activeTab, setActiveTab] = useState() - const handleProfileClick = (active?: string) => { - setShowSettingsModal(true) - if (active) setActiveTab(active) + const handleConfirmLogout = () => { + logout() + setShowLogoutModal(false) + setTimeout(() => { + location.reload() + }, 1000) } - const modalCloseHandler = () => setShowSettingsModal(false) + const authModalCloseHandler = () => setShowAuthModal(false) - const isAuth = user || isAuthenticated + useEffect(() => { + if (isAuthenticated && showAuthModal) { + setShowAuthModal(false) + } + }, [isAuthenticated, showAuthModal]) useEffect(() => { const event = listenEvent('openProfile', (active) => { - handleProfileClick(active) + if (!isAuthenticated) { + setShowAuthModal(true) + } else { + callEvent('openSettings', (active as any) || 'profile') + } }) const eventClose = listenEvent('close_all_modals', () => { - modalCloseHandler() + authModalCloseHandler() }) const openWizardEvent = listenEvent('openWizardModal', () => { - modalCloseHandler() + authModalCloseHandler() setOpenedWizard(true) }) @@ -58,55 +52,47 @@ export function ProfileNav() { eventClose() openWizardEvent() } - }, []) + }, [isAuthenticated]) return ( <> - {!isAuth ? ( - -
handleProfileClick()} - > - -
-
- ) : ( - - {profilePercentage ? ( -
handleProfileClick('profile')} - >
- ) : null} -
handleProfileClick('profile')} - > - {renderUserAvatar(user)} -
-
- )} + + } + > + setShowAuthModal(true)} + onRequestLogout={() => setShowLogoutModal(true)} + /> + + + + + - setShowLogoutModal(false)} + onConfirm={handleConfirmLogout} + title="خروج از حساب" + message="مطمئنی می‌خوای از حسابت خارج بشی؟" + confirmText="خروج" + cancelText="بی‌خیال" + variant="danger" /> {openedWizard && ( diff --git a/src/components/welcome-wizard.tsx b/src/layouts/navbar/profile/welcome-wizard.tsx similarity index 94% rename from src/components/welcome-wizard.tsx rename to src/layouts/navbar/profile/welcome-wizard.tsx index 9ee921286..c5d753812 100644 --- a/src/components/welcome-wizard.tsx +++ b/src/layouts/navbar/profile/welcome-wizard.tsx @@ -4,7 +4,7 @@ import { useGetOccupations, useGetInterests, } from '@/services/hooks/profile/get-profile-meta.hook' -import { TextInput } from '@/components/text-input' +import { TextInput } from '@/components/ui' import { sleep } from '@/common/utils/timeout' import { Chip } from '@/components/ui' import { ItemSelector } from '@/components/ui' @@ -12,7 +12,7 @@ import { useSetupWizard } from '@/services/hooks/auth/auth-service.hook' import { showToast } from '@/common/toast' import { safeAwait } from '@/services/api' import Analytics from '@/analytics' -import { Icon } from '../icons' +import { Icon } from '@/icons' export enum ReferralSource { Social = 'social', @@ -37,11 +37,6 @@ const StepImage = ({ src }: { src: string; alt: string }) => { className="absolute inset-0 transition-opacity duration-700 bg-center bg-cover" style={{ backgroundImage: `url(${src})`, - - maskImage: - 'linear-gradient(135deg, rgba(0, 0, 0, 0.4) 0%, rgba(0, 0, 0, 0.6) 40%, rgba(0, 0, 0, 0.3) 90%, rgba(0, 0, 0, 0) 100%)', - WebkitMaskImage: - 'linear-gradient(135deg, rgba(0, 0, 0, 0.4) 0%, rgba(0, 0, 0, 0.6) 40%, rgba(0, 0, 0, 0.3) 90%, rgba(0, 0, 0, 0) 100%)', }} />
@@ -131,7 +126,7 @@ export const WelcomeWizard = ({ isOpen, onClose }: WelcomeWizardProps) => { size="sm" onClick={nextStep} className="w-full h-12 text-base font-bold shadow-lg" - variant={'primary'} + color={'primary'} rounded={'2xl'} > بزن بریم @@ -187,7 +182,7 @@ export const WelcomeWizard = ({ isOpen, onClose }: WelcomeWizardProps) => { onClick={nextStep} disabled={!selectedOccupation} className="flex-1 h-12 font-bold" - variant={'primary'} + color={'primary'} rounded={'2xl'} > تایید و ادامه @@ -255,7 +250,7 @@ export const WelcomeWizard = ({ isOpen, onClose }: WelcomeWizardProps) => { onClick={nextStep} disabled={selectedInterests.length === 0} className="flex-1 h-12 font-bold" - variant={'primary'} + color={'primary'} rounded={'2xl'} > ادامه @@ -331,8 +326,8 @@ export const WelcomeWizard = ({ isOpen, onClose }: WelcomeWizardProps) => { onClick={() => save()} disabled={!selectedReferralSource || isPending} loading={isPending} - className="flex-1 h-12 font-bold mt-4" - variant={'primary'} + className="h-12 font-bold mt-4 w-full" + color={'primary'} rounded={'2xl'} > ادامه @@ -361,8 +356,8 @@ export const WelcomeWizard = ({ isOpen, onClose }: WelcomeWizardProps) => {
)} -
, - document.body + + )} + + ) } diff --git a/src/layouts/search/browser-bookmark/browser-bookmark.tsx b/src/layouts/search/browser-bookmark/browser-bookmark.tsx index 094691953..d1e66a38a 100644 --- a/src/layouts/search/browser-bookmark/browser-bookmark.tsx +++ b/src/layouts/search/browser-bookmark/browser-bookmark.tsx @@ -1,12 +1,12 @@ import { useState, useRef, useEffect, useCallback } from 'react' -import { useGetSearchboxData } from '@/services/hooks/trends/get-trends' +import { useGetSearchboxData } from '@/services/hooks/trends/get-trends.hook' import { getFaviconFromUrl } from '@/common/utils/icon' import { Tooltip } from '@/components/ui' import { BookmarkPopover } from './bookmark-popover' import { Page, usePage } from '@/context/page.context' import Analytics from '@/analytics' import { NewBadge } from '@/components/ui' -import { Icon } from '@/src/icons' +import { Icon } from '@/icons' export function BrowserBookmark() { const { data } = useGetSearchboxData({ enabled: true }) @@ -62,11 +62,12 @@ export function BrowserBookmark() { } return ( -
-
+
+
-
onClickToExplorer()} >
@@ -80,25 +81,26 @@ export function BrowserBookmark() { )}
-
+
-

بوکمارک مرورگر

-
+
@@ -110,9 +112,11 @@ export function BrowserBookmark() { className="flex items-center justify-center shrink-0" > -
window.open(item.url || '', '_blank')} > {item.name} -
+
))} diff --git a/src/layouts/search/history.portal.tsx b/src/layouts/search/history.portal.tsx index 00ff69108..e87640b7b 100644 --- a/src/layouts/search/history.portal.tsx +++ b/src/layouts/search/history.portal.tsx @@ -1,27 +1,34 @@ -import { useState, useMemo } from 'react' +import { useState, useMemo, useEffect, useRef } from 'react' import { useSearchSuggestions } from '@/services/hooks/search/get-suggest-search.hook' import { SuggestionSkeleton } from './suggestion/suggestion.skeleton' import { useAuth } from '@/context/auth.context' import { AutocompleteConsentModal } from './suggestion/autocomplete-consent.modal' import { Portal } from '@/components/ui' +import { Motion, Presence } from '@/common/motion' import { useSearchHistory } from './hooks/use-search-history' import { Suggestions } from './suggestion/suggestions' -import { Icon } from '@/src/icons' +import { Icon } from '@/icons' interface SearchHistoryPortalProps { + isOpen: boolean onClose: () => void onSearch: (query: string) => void onEngineChange: any searchQuery: string portalStyles?: React.CSSProperties portalRef: React.RefObject + selectedIndex?: number + onSuggestionsChange?: (items: { text: string; isRecent: boolean }[]) => void } export function SearchHistoryPortal({ + isOpen, onSearch, searchQuery, portalStyles, portalRef, + selectedIndex = -1, + onSuggestionsChange, }: SearchHistoryPortalProps) { const { isAuthenticated, user } = useAuth() const [showConsentModal, setShowConsentModal] = useState(false) @@ -62,6 +69,37 @@ export function SearchHistoryPortal({ return combined }, [suggestions, recentSearches, searchQuery, user?.searchAutocompleteEnabled]) + const showLocalSearches = + user?.searchAutocompleteEnabled && recentSearches.length > 0 && !hasQuery + + const currentList = useMemo(() => { + return hasQuery + ? combinedSuggestions + : showLocalSearches + ? recentSearches.map((f) => ({ isRecent: true, text: f.query })) + : [] + }, [hasQuery, combinedSuggestions, showLocalSearches, recentSearches]) + + const onSuggestionsChangeRef = useRef(onSuggestionsChange) + onSuggestionsChangeRef.current = onSuggestionsChange + + const prevListRef = useRef(currentList) + useEffect(() => { + const prev = prevListRef.current + const hasChanged = + prev.length !== currentList.length || + prev.some( + (item, i) => + item.text !== currentList[i]?.text || + item.isRecent !== currentList[i]?.isRecent + ) + + if (hasChanged) { + prevListRef.current = currentList + onSuggestionsChangeRef.current?.(currentList) + } + }, [currentList]) + const handleSearch = (query: string) => { if (user?.searchAutocompleteEnabled) addSearch(query) onSearch(query) @@ -71,88 +109,95 @@ export function SearchHistoryPortal({ removeHistoryItem(query) } - const showLocalSearches = - user?.searchAutocompleteEnabled && recentSearches.length > 0 && !hasQuery - return ( <> -
- {showSuggestions && - hasQuery && - (isFetching ? ( - - ) : combinedSuggestions.length > 0 ? ( - - ) : null)} - - {!hasQuery && showLocalSearches && ( - ({ - isRecent: true, - text: f.query, - }))} - handleSearch={handleSearch} - onRemove={onRemoveHistoryItem} - /> - )} - - {showEnableButton && ( -
-
- + {isOpen && ( + + {showSuggestions && + hasQuery && + (isFetching ? ( + + ) : combinedSuggestions.length > 0 ? ( + + ) : null)} + + {!hasQuery && showLocalSearches && ( + ({ + isRecent: true, + text: f.query, + }))} + handleSearch={handleSearch} + onRemove={onRemoveHistoryItem} + selectedIndex={selectedIndex} /> -
-
-

- پیشنهادهای جستجو -

-

- با فعال‌سازی، هنگام تایپ پیشنهادهای هوشمندی داده میشه! -

-
- -
+ )} + + {showEnableButton && ( +
+
+ +
+
+

+ پیشنهادهای جستجو +

+

+ با فعال‌سازی، هنگام تایپ پیشنهادهای هوشمندی + داده میشه! +

+
+ +
+ )} + + {!showSuggestions && + !showEnableButton && + !showLocalSearches && + hasQuery && ( +
+

+ نتیجه‌ای برای نمایش وجود ندارد +

+
+ )} + )} - - {!showSuggestions && - !showEnableButton && - !showLocalSearches && - hasQuery && ( -
-

- نتیجه‌ای برای نمایش وجود ندارد -

-
- )} -
+
- {showConsentModal && ( - setShowConsentModal(false)} - /> - )} + setShowConsentModal(false)} + /> ) } diff --git a/src/layouts/search/image/image-search.portal.tsx b/src/layouts/search/image/image-search.portal.tsx index 5376eb45e..6ed326ecd 100644 --- a/src/layouts/search/image/image-search.portal.tsx +++ b/src/layouts/search/image/image-search.portal.tsx @@ -1,12 +1,12 @@ -import { useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { showToast } from '@/common/toast' -import { TextInput } from '@/components/text-input' +import { TextInput } from '@/components/ui' import Analytics from '@/analytics' import { RequireAuth } from '@/components/auth/require-auth' import { getMainClient } from '@/services/api' import { translateError } from '@/common/utils/translate-error' import { Button, Portal } from '@/components/ui' -import { Icon } from '@/src/icons' +import { Icon } from '@/icons' interface ImageSearchPortalProps { onClose: () => void @@ -21,11 +21,20 @@ export function ImageSearchPortal({ }: ImageSearchPortalProps) { const [isUploading, setIsUploading] = useState(false) const [imageUrl, setImageUrl] = useState('') + const [previewUrl, setPreviewUrl] = useState(null) const fileInputRef = useRef(null) const [uploadProgress, setUploadProgress] = useState(0) + useEffect(() => { + return () => { + if (previewUrl) { + URL.revokeObjectURL(previewUrl) + } + } + }, [previewUrl]) + const handleUpload = async (file: File) => { - if (!file.type.startsWith('image/')) { + if (!file || !file.type?.startsWith('image/')) { showToast('لطفا فقط فایل تصویری انتخاب کنید', 'error') return } @@ -35,6 +44,8 @@ export function ImageSearchPortal({ return } + const objectUrl = URL.createObjectURL(file) + setPreviewUrl(objectUrl) setIsUploading(true) setUploadProgress(0) Analytics.event('searchbox_image_file') @@ -66,6 +77,10 @@ export function ImageSearchPortal({ showToast(translateError(er) as string, 'error') } finally { setIsUploading(false) + if (objectUrl) { + URL.revokeObjectURL(objectUrl) + } + setPreviewUrl(null) } } @@ -167,12 +182,13 @@ export function ImageSearchPortal({

{isUploading && (
- + {previewUrl && ( + پیش‌نمایش تصویر + )}
@@ -215,7 +231,7 @@ export function ImageSearchPortal({ -
- setActivePortal('image')} /> - setActivePortal('voice')} /> -
-
+ setActivePortal('image')} + /> + setActivePortal('voice')} + /> +
+ )} +
+
)} - {showHistoryPortal && !activePortal && ( - setShowHistoryPortal(false)} - onSearch={handleHistorySearch} - onEngineChange={onEngineChange} - searchQuery={searchQuery} - portalStyles={portalStyles} - /> - )} + setShowHistoryPortal(false)} + onSearch={handleHistorySearch} + onEngineChange={onEngineChange} + searchQuery={searchQuery} + portalStyles={portalStyles} + selectedIndex={selectedIndex} + onSuggestionsChange={setCurrentSuggestions} + />
@@ -242,6 +295,13 @@ export function SearchLayout() { ) } +export function SearchLayout({ size }: SearchLayoutProps = {}) { + if (size && size.w <= 2 && size.h <= 1) { + return + } + return +} + function SearchHandler({ content, engine }: { content: string; engine: EngineMeta }) { if (engine.id === 'google') { browser.search.query({ diff --git a/src/layouts/search/select-engine/engine-selector.tsx b/src/layouts/search/select-engine/engine-selector.tsx index 093c1f3a6..22a8ced06 100644 --- a/src/layouts/search/select-engine/engine-selector.tsx +++ b/src/layouts/search/select-engine/engine-selector.tsx @@ -3,8 +3,8 @@ import { callEvent } from '@/common/utils/call-event' import { Dropdown } from '@/components/ui' import { useAuth } from '@/context/auth.context' import { useChangeSearchEngine } from '@/services/hooks/extension/update-setting.hook' -import { type EngineMeta, useGetSearchboxData } from '@/services/hooks/trends/get-trends' -import { Icon } from '@/src/icons' +import { type EngineMeta, useGetSearchboxData } from '@/services/hooks/trends/get-trends.hook' +import { Icon } from '@/icons' import type { ReactNode } from 'react' import { useState, useEffect, useMemo } from 'react' diff --git a/src/layouts/search/suggestion/autocomplete-consent.modal.tsx b/src/layouts/search/suggestion/autocomplete-consent.modal.tsx index 713215ccf..55b1d858d 100644 --- a/src/layouts/search/suggestion/autocomplete-consent.modal.tsx +++ b/src/layouts/search/suggestion/autocomplete-consent.modal.tsx @@ -49,7 +49,7 @@ export function AutocompleteConsentModal({ onClick={() => onUpdateStatus()} disabled={isPending} size="md" - variant={'primary'} + color={'primary'} rounded={'2xl'} loading={isPending} className="px-8" diff --git a/src/layouts/search/suggestion/suggestions.tsx b/src/layouts/search/suggestion/suggestions.tsx index 818de891b..c2edce1ce 100644 --- a/src/layouts/search/suggestion/suggestions.tsx +++ b/src/layouts/search/suggestion/suggestions.tsx @@ -1,58 +1,75 @@ import { useGeneralSetting } from '@/context/general-setting.context' -import { Icon } from '@/src/icons' +import { Icon } from '@/icons' interface Prop { combinedSuggestions: { text: string; isRecent: boolean }[] handleSearch: (query: string) => void onRemove: (query: string) => void + selectedIndex?: number } -export function Suggestions({ combinedSuggestions, handleSearch, onRemove }: Prop) { +export function Suggestions({ + combinedSuggestions, + handleSearch, + onRemove, + selectedIndex = -1, +}: Prop) { const { blurMode } = useGeneralSetting() return (
- {combinedSuggestions.map((item, index) => ( -
- ) : ( - - )} - - {item.text} - - - ))} + )} + + {item.text} + + + ) + })}
) } diff --git a/src/layouts/search/variants/search-2x1.tsx b/src/layouts/search/variants/search-2x1.tsx new file mode 100644 index 000000000..f606705f5 --- /dev/null +++ b/src/layouts/search/variants/search-2x1.tsx @@ -0,0 +1,248 @@ +import { useEffect, useRef, useState, useCallback } from 'react' +import Analytics from '@/analytics' +import { EngineSelector } from '../select-engine/engine-selector' +import { SearchHistoryPortal } from '../history.portal' +import type { EngineMeta } from '@/services/hooks/trends/get-trends.hook' +import { useSearchHistory } from '../hooks/use-search-history' +import { useAuth } from '@/context/auth.context' +import { Icon } from '@/icons' + +const DEFAULT_ENGINE: EngineMeta = { + id: 'google', + prefix: '', + label: 'گوگل', + icon: '', +} + +export function SearchCompactRow() { + const [searchQuery, setSearchQuery] = useState('') + const [selectedEngine, setSelectedEngine] = useState(DEFAULT_ENGINE) + const [showHistoryPortal, setShowHistoryPortal] = useState(false) + const [selectedIndex, setSelectedIndex] = useState(-1) + const [currentSuggestions, setCurrentSuggestions] = useState< + { text: string; isRecent: boolean }[] + >([]) + const originalQueryRef = useRef('') + const searchRef = useRef(null) + const portalRef = useRef(null) + const inputRef = useRef(null) + const [portalStyles, setPortalStyles] = useState({}) + const { user } = useAuth() + const { addSearch } = useSearchHistory() + + const updatePortalPosition = useCallback(() => { + if (searchRef.current) { + const rect = searchRef.current.getBoundingClientRect() + const width = Math.min(Math.max(rect.width, 180), window.innerWidth - 32) + const left = Math.min( + Math.max(16, rect.right - width), + window.innerWidth - width - 16 + ) + + setPortalStyles({ + position: 'fixed', + top: `${rect.bottom + 2}px`, + left: `${left}px`, + width: `${width}px`, + zIndex: 100, + marginTop: '0px', + borderRadius: '1.5rem', + }) + } + }, []) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + const targetQuery = + selectedIndex >= 0 && currentSuggestions[selectedIndex] + ? currentSuggestions[selectedIndex].text + : searchQuery + const query = targetQuery.trim() + if (query) { + if (user?.searchAutocompleteEnabled) addSearch(query) + + SearchHandler({ + content: query, + engine: selectedEngine, + }) + + Analytics.event('search_query_submitted_2x1') + setShowHistoryPortal(false) + } + } + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (!showHistoryPortal || currentSuggestions.length === 0) return + + if (e.key === 'ArrowDown') { + e.preventDefault() + setSelectedIndex((prev) => + prev < currentSuggestions.length - 1 ? prev + 1 : -1 + ) + } else if (e.key === 'ArrowUp') { + e.preventDefault() + setSelectedIndex((prev) => + prev > -1 ? prev - 1 : currentSuggestions.length - 1 + ) + } else if (e.key === 'Escape') { + setShowHistoryPortal(false) + setSelectedIndex(-1) + } + } + + const handleClearSearch = () => { + setSearchQuery('') + setSelectedIndex(-1) + if (inputRef.current) { + inputRef.current.value = '' + inputRef.current.focus() + } + } + + const handleHistorySearch = (query: string) => { + if (query.trim()) { + if (user?.searchAutocompleteEnabled) addSearch(query.trim()) + SearchHandler({ content: query.trim(), engine: selectedEngine }) + Analytics.event('history_search_submitted') + setShowHistoryPortal(false) + } + } + + const onEngineChange = (engine: EngineMeta) => { + setSelectedEngine(engine) + } + + useEffect(() => { + if (showHistoryPortal) { + updatePortalPosition() + window.addEventListener('resize', updatePortalPosition) + window.addEventListener('scroll', updatePortalPosition, true) + } + return () => { + window.removeEventListener('resize', updatePortalPosition) + window.removeEventListener('scroll', updatePortalPosition, true) + } + }, [showHistoryPortal, updatePortalPosition]) + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as HTMLElement + if (!target) return + + if ( + document.documentElement.classList.contains('modal-isActive') || + document.querySelector('dialog[open]') || + target.closest('dialog') || + target.closest('.modal') || + target.closest('[role="dialog"]') || + target.closest('.modal-backdrop') || + target.closest('.searchbox-item') || + target.classList.contains('searchbox-item') + ) { + return + } + + if (portalRef?.current?.contains(target)) { + return + } + + if ( + showHistoryPortal && + searchRef?.current && + !searchRef?.current?.contains(target) + ) { + setShowHistoryPortal(false) + } + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setShowHistoryPortal(false) + } + } + + document.addEventListener('mousedown', handleClickOutside) + document.addEventListener('keydown', handleKeyDown) + + return () => { + document.removeEventListener('mousedown', handleClickOutside) + document.removeEventListener('keydown', handleKeyDown) + } + }, [showHistoryPortal]) + + return ( +
+
+ +
+ + + { + setSearchQuery(e.target.value) + originalQueryRef.current = e.target.value + setSelectedIndex(-1) + }} + onKeyDown={handleKeyDown} + onFocus={() => { + setShowHistoryPortal(true) + updatePortalPosition() + Analytics.event('search_input_focused_2x1') + }} + className="w-full py-1 px-1.5 text-xs font-light text-right focus:outline-none text-content placeholder:text-base-content/60 placeholder:font-medium bg-transparent" + placeholder="جستجو..." + autoComplete="off" + /> + + {searchQuery ? ( + + ) : ( + + )} +
+ + + setShowHistoryPortal(false)} + onSearch={handleHistorySearch} + onEngineChange={onEngineChange} + searchQuery={searchQuery} + portalStyles={portalStyles} + selectedIndex={selectedIndex} + onSuggestionsChange={setCurrentSuggestions} + /> +
+
+ ) +} + +function SearchHandler({ content, engine }: { content: string; engine: EngineMeta }) { + if (engine.id === 'google') { + browser.search.query({ + text: content, + disposition: browser.search.Disposition.CURRENT_TAB, + }) + } else { + window.open(engine.prefix + encodeURIComponent(content), '_self') + } +} diff --git a/src/layouts/search/voice/voice-search.button.tsx b/src/layouts/search/voice/voice-search.button.tsx index 86d0531f0..f05a32519 100644 --- a/src/layouts/search/voice/voice-search.button.tsx +++ b/src/layouts/search/voice/voice-search.button.tsx @@ -8,9 +8,10 @@ export function VoiceSearchButton({ onClick }: { onClick: () => void }) { } return ( -
onClickHandle()} - className="flex items-center justify-center transition-all duration-300 rounded-full cursor-pointer h-9 w-9 shrink-0 hover:bg-base-300 group" + className="flex items-center justify-center transition-all duration-300 rounded-full cursor-pointer h-9 w-9 shrink-0 hover:bg-base-300 group border-none bg-transparent p-0" > void }) { d="M480-400q-50 0-85-35t-35-85v-240q0-50 35-85t85-35q50 0 85 35t35 85v240q0 50-35 85t-85 35Zm-40 280v-123q-104-14-172-93t-68-184h80q0 83 58.5 141.5T480-320q83 0 141.5-58.5T680-520h80q0 105-68 184t-172 93v123h-80Z" /> -
+
) } diff --git a/src/layouts/search/voice/voice-search.portal.tsx b/src/layouts/search/voice/voice-search.portal.tsx index a46a3edf7..887341e40 100644 --- a/src/layouts/search/voice/voice-search.portal.tsx +++ b/src/layouts/search/voice/voice-search.portal.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react' import { useVoiceSearch } from './use-voice-search' -import { Portal } from '@/components/ui' -import { Icon } from '@/src/icons' +import { Dropdown, Portal } from '@/components/ui' +import { Icon } from '@/icons' interface VoiceSearchPortalProps { onClose: () => void @@ -23,7 +23,6 @@ export function VoiceSearchPortal({ portalRef, }: VoiceSearchPortalProps) { const [selectedLanguage, setSelectedLanguage] = useState('fa-IR') - const [isLanguageMenuOpen, setIsLanguageMenuOpen] = useState(false) const { isListening, currentTranscript, startVoiceSearch, stopVoiceSearch } = useVoiceSearch((result) => { @@ -86,38 +85,33 @@ export function VoiceSearchPortal({
-
- - - {isLanguageMenuOpen && ( -
- {languages.map((lang) => ( - - ))} + + + { + languages.find((l) => l.code === selectedLanguage) + ?.name + }
- )} -
+ } + options={languages.map((lang) => ({ + id: lang.code, + label: lang.name, + }))} + onOptionSelect={(option) => + setSelectedLanguage(option.id as Language) + } + /> - ) - )} -
- ))} + + {icon} + {isNew && ( + + )} + + + {label} + + + ) + )} +
+ ) + })}
{children}
diff --git a/src/layouts/setting/tabs/about-us/about-us.tsx b/src/layouts/setting/tabs/about-us/about-us.tsx index eaedc2753..1e36eddba 100644 --- a/src/layouts/setting/tabs/about-us/about-us.tsx +++ b/src/layouts/setting/tabs/about-us/about-us.tsx @@ -1,6 +1,6 @@ import { SectionPanel } from '@/components/ui' -import { ConfigKey } from '../../../../common/constant/config.key' -import { Icon } from '@/src/icons' +import { ConfigKey } from '../../../../common/constants/config.key' +import { Icon } from '@/icons' export function AboutUsTab() { return ( diff --git a/src/layouts/setting/tabs/account/account.tsx b/src/layouts/setting/tabs/account/account.tsx index 1b4c9a841..b0bf6d2a2 100644 --- a/src/layouts/setting/tabs/account/account.tsx +++ b/src/layouts/setting/tabs/account/account.tsx @@ -8,7 +8,7 @@ export const AccountTab = () => { return (
- + {isAuthenticated ? ( { - const [showPasswordForm, setShowPasswordForm] = useState(false) - const [AuthOtpStep, setAuthOtpStep] = useState<'enter-email' | 'enter-otp'>( - 'enter-email' - ) + const { login } = useAuth() + const [step, setStep] = useState('identifier') + const [identifier, setIdentifier] = useState('') + const [password, setPassword] = useState('') + const [otp, setOtp] = useState('') + const [resendCooldown, setResendCooldown] = useState(0) + + const timerRef = useRef(null) + + const [error, setError] = useState<{ + identifier: string | null + password: string | null + otp: string | null + api: string | null + }>({ identifier: null, password: null, otp: null, api: null }) + + const resetErrors = () => { + setError({ identifier: null, password: null, otp: null, api: null }) + } + + useEffect(() => { + if (resendCooldown > 0) { + timerRef.current = setTimeout(() => { + setResendCooldown((prev) => prev - 1) + }, 1000) + } + return () => { + if (timerRef.current) clearTimeout(timerRef.current) + } + }, [resendCooldown]) + + const startCooldown = (sec: number = RESEND_COOLDOWN_SEC) => { + setResendCooldown(sec) + } + + const { data: authStatus } = useGetAuthStatus() + const { mutateAsync: requestOtp, isPending: isOtpSending } = useRequestOtp() + const { mutateAsync: verifyOtp, isPending: isOtpVerifying } = useVerifyOtp() + const { mutateAsync: signInMutation, isPending: isSigningIn } = useSignIn() + + const isEmailInput = isEmail(identifier.trim()) + + const handleIdentifierSubmit = (e: React.FormEvent) => { + e.preventDefault() + resetErrors() + + const trimmed = identifier.trim() + if (isEmpty(trimmed)) { + return setError((prev) => ({ + ...prev, + identifier: 'ایمیل یا شماره موبایلت رو وارد کن', + })) + } + + if (isEmail(trimmed)) { + setStep('password') + } else { + handleSendOtp(trimmed) + } + } + + const handleSendOtp = async (target?: string) => { + if (resendCooldown > 0) return + + resetErrors() + const inputTarget = (target || identifier).trim() + + if (isEmpty(inputTarget)) { + return setError((prev) => ({ + ...prev, + identifier: 'ایمیل یا شماره موبایلت رو وارد کن', + })) + } + + try { + let res: any + if (isEmail(inputTarget)) { + res = await requestOtp({ email: inputTarget }) + } else { + res = await requestOtp({ phone: inputTarget }) + } + + const serverTtl = res?.data?.ttl || res?.ttl || RESEND_COOLDOWN_SEC + startCooldown(serverTtl) + setStep('otp') + Analytics.event('auth_otp_requested') + } catch (err: any) { + const errMsg = err?.response?.data?.message + if (typeof errMsg === 'string' && errMsg.startsWith('OTP_RATE_LIMIT:')) { + const rem = parseInt(errMsg.split(':')[1], 10) + if (!isNaN(rem)) { + startCooldown(rem) + setStep('otp') + return + } + } + + const content = translateError(err) + setError((prev) => ({ + ...prev, + api: typeof content === 'string' ? content : 'خطایی در ارسال کد رخ داد', + })) + } + } + + const handlePasswordSubmit = async (e: React.FormEvent) => { + e.preventDefault() + resetErrors() + + const trimmed = identifier.trim() + if (isEmpty(password)) { + return setError((prev) => ({ + ...prev, + password: 'رمز عبورت رو وارد کن', + })) + } + + if (isLessThan(password, 6)) { + return setError((prev) => ({ + ...prev, + password: 'رمز عبور باید حداقل ۶ کاراکتر باشه', + })) + } + + try { + const response = await signInMutation({ + email: trimmed, + password, + }) + login(response.data) + Analytics.event('sign_in') + } catch (err: any) { + const content = translateError(err) + if (typeof content === 'string') { + setError((prev) => ({ ...prev, api: content })) + } else { + if (content && Object.keys(content).length > 0) { + setError((prev) => ({ + ...prev, + password: content.password || null, + identifier: content.email || null, + api: null, + })) + } else { + setError((prev) => ({ + ...prev, + api: 'ایمیل یا رمز عبور اشتباهه', + })) + } + } + } + } + + const handleOtpSubmit = async (e: React.FormEvent) => { + e.preventDefault() + resetErrors() + + if (isEmpty(otp) || isLessThan(otp, 6)) { + return setError((prev) => ({ + ...prev, + otp: 'کد ارسال شده رو وارد کن', + })) + } + + try { + const trimmed = identifier.trim() + let response: any + if (isEmail(trimmed)) { + response = await verifyOtp({ email: trimmed, code: otp }) + } else { + response = await verifyOtp({ phone: trimmed, code: otp }) + } + + if (response.isNewUser) { + callEvent('openWizardModal') + await sleep(500) + } + + login(response.data) + Analytics.event('auth_otp_verified') + } catch (err: any) { + const content = translateError(err) + if (typeof content === 'string') { + if (err.response?.data?.message === 'INVALID_OTP_CODE') { + setError((prev) => ({ + ...prev, + otp: 'کد تایید اشتباهه، دوباره امتحان کن', + })) + } else { + setError((prev) => ({ ...prev, api: content })) + } + } else { + setError((prev) => ({ + ...prev, + otp: 'کد تایید اشتباهه، دوباره امتحان کن', + })) + } + } + } + + const goBackToIdentifier = () => { + setStep('identifier') + setPassword('') + setOtp('') + resetErrors() + } return ( -
-
- {showPasswordForm ? ( - - ) : ( - - )} -
- - {AuthOtpStep === 'enter-email' && ( - +
+ {authStatus?.content && step === 'identifier' && ( +
+ + {authStatus.content} +
+ )} + + {step === 'identifier' && ( +
+
+

+ ورود یا ساخت حساب +

+

+ ایمیل یا شماره موبایلت رو وارد کن +

+
+ +
+
+ { + setIdentifier(val) + resetErrors() + }} + placeholder="شماره موبایل یا ایمیل..." + disabled={isOtpSending} + className="w-full h-11 !rounded-xl text-sm" + autoComplete="on" + direction={!identifier ? 'rtl' : 'ltr'} + /> + +
+ + +
+ +
+
+ + +
+ )} + + {step === 'password' && ( +
+
+

+ رمز عبور +

+
+ + {identifier} + + • + +
+
+ + {error.api && ( +
+ {error.api} +
+ )} + +
+
+ { + setPassword(val) + resetErrors() + }} + placeholder="رمز عبورت رو وارد کن..." + disabled={isSigningIn} + className="w-full h-11 !rounded-xl text-sm" + direction={password ? 'ltr' : 'rtl'} + /> + +
+ + + + +
+
+ )} + + {step === 'otp' && ( +
+
+

+ کد تایید +

+

+ کد ارسال شده به + + {identifier} + + رو وارد کن +

+
+ +
+
+ { + setOtp(val) + setError((prev) => ({ ...prev, otp: null })) + }} + isError={!!error.otp || !!error.api} + /> + +
+ +
+ {resendCooldown > 0 ? ( +
+ + + {Math.floor(resendCooldown / 60)}: + {String(resendCooldown % 60).padStart(2, '0')} + + + تا امکان ارسال دوباره + +
+ ) : ( + + )} + + {isEmailInput ? ( + + ) : ( + + )} +
+ + +
+
)}
) diff --git a/src/layouts/setting/tabs/account/auth-form/auth-otp.tsx b/src/layouts/setting/tabs/account/auth-form/auth-otp.tsx deleted file mode 100644 index 88bd5d3d3..000000000 --- a/src/layouts/setting/tabs/account/auth-form/auth-otp.tsx +++ /dev/null @@ -1,289 +0,0 @@ -import { useState } from 'react' -import { TextInput } from '@/components/text-input' -import { - useGetAuthStatus, - useRequestOtp, - useVerifyOtp, -} from '@/services/hooks/auth/auth-service.hook' -import { translateError } from '@/common/utils/translate-error' -import { useAuth } from '@/context/auth.context' -import { isEmpty, isEmail, isLessThan } from '@/common/utils/validators' -import InputTextError from './components/input-text-error' -import OtpInput from './components/otp-input' -import { callEvent } from '@/common/utils/call-event' -import { sleep } from '@/common/utils/timeout' -import { Icon } from '@/src/icons' -import { Button } from '@/components/ui' - -type AuthOtpProps = { - step: 'enter-email' | 'enter-otp' - setStep: (step: 'enter-email' | 'enter-otp') => void -} - -const AuthOtp: React.FC = ({ step, setStep }) => { - const { login } = useAuth() - const [email, setEmail] = useState('') - const [otp, setOtp] = useState('') - - const [error, setError] = useState<{ - email: string | null - otp: string | null - api: string | null - }>({ email: null, otp: null, api: null }) - - const resetErrors = () => { - setError({ email: null, otp: null, api: null }) - } - - const { data: authStatus } = useGetAuthStatus() - const { mutateAsync: requestOtp, isPending } = useRequestOtp() - const { mutateAsync: verifyOtp } = useVerifyOtp() - - const validateInputs = async (e: React.FormEvent) => { - e.preventDefault() - resetErrors() - - if (step === 'enter-email') { - if (isEmpty(email)) - return setError((prev) => ({ - ...prev, - email: 'لطفا ایمیل/شماره موبایل خود را وارد کنید.', - })) - - onSendOtp() - } else if (step === 'enter-otp') { - if (isEmpty(otp) || isLessThan(otp, 6)) - return setError((prev) => ({ - ...prev, - otp: 'لطفا کد ارسال شده را وارد کنید.', - })) - - onVerifyOtp() - } - } - - const onSendOtp = async () => { - try { - if (isEmail(email)) { - await requestOtp({ email }) - } else { - await requestOtp({ phone: email }) - } - setStep('enter-otp') - } catch (err: any) { - const content = translateError(err) - setError({ - api: content as string, - email: null, - otp: null, - }) - } - } - - const onVerifyOtp = async () => { - try { - setError({ email: null, otp: null, api: null }) - let response: any - if (isEmail(email)) { - response = await verifyOtp({ email, code: otp }) - } else { - response = await verifyOtp({ phone: email, code: otp }) - } - - if (response.isNewUser) { - callEvent('openWizardModal') - await sleep(500) - } - - login(response.data) - } catch (err: any) { - const content = translateError(err) - if (typeof content === 'string') { - if (err.response?.data?.message === 'INVALID_OTP_CODE') { - setError({ - email: null, - otp: 'کد تایید نامعتبر است. لطفا دوباره تلاش کنید.', - api: null, - }) - } else setError({ email: null, otp: null, api: content }) - } else { - if (Object.keys(content).length === 0) { - setError({ - email: null, - otp: null, - api: 'کد تایید نامعتبر است. لطفا دوباره تلاش کنید.', - }) - return - } - setError({ - email: null, - otp: null, - api: `${Object.keys(content)[0]}: ${Object.values(content)[0]}`, - }) - } - } - } - - const onSetOtp = (value: string) => { - setOtp(value) - setError((prev) => ({ ...prev, otp: null })) - } - - if (step === 'enter-email') - return ( -
-
-
- -
-
-

- ورود یا ثبت نام -

-

- ایمیل یا شماره موبایلتان را وارد کنید -

-
-
- - {authStatus?.content && ( -
- - {authStatus.content} -
- )} - -
-
- - - - -
- -
-
- ) - - return ( -
-
- - -
-

- تایید کد ورود -

-

- کد تایید به - - {email} - - ارسال شد. -

-
-
- -
-
- - - - -
{' '} - - -
- - -
- ) -} - -export default AuthOtp diff --git a/src/layouts/setting/tabs/account/auth-form/auth-password.tsx b/src/layouts/setting/tabs/account/auth-form/auth-password.tsx deleted file mode 100644 index 82620586f..000000000 --- a/src/layouts/setting/tabs/account/auth-form/auth-password.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import { useState } from 'react' -import Analytics from '@/analytics' -import { TextInput } from '@/components/text-input' -import { useAuth } from '@/context/auth.context' -import { useSignIn } from '@/services/hooks/auth/auth-service.hook' -import { translateError } from '@/common/utils/translate-error' -import InputTextError from './components/input-text-error' -import { isEmail, isEmpty, isLessThan } from '@/common/utils/validators' -import { Icon } from '@/src/icons' -import { Button } from '@/components/ui' - -export default function AuthPassword() { - const [email, setEmail] = useState('') - const [password, setPassword] = useState('') - const [error, setError] = useState<{ - email: string | null - password: string | null - api: string | null - }>({ email: null, password: null, api: null }) - - const { login } = useAuth() - const { mutateAsync: signInMutation, isPending } = useSignIn() - - const resetErrors = () => { - setError({ email: null, password: null, api: null }) - } - - const validateInputs = () => { - // Email validation - if (isEmpty(email)) - return setError((prev) => ({ - ...prev, - email: 'لطفا ایمیل خود را وارد کنید.', - })) - - if (!isEmail(email)) - return setError((prev) => ({ - ...prev, - email: 'لطفا یک ایمیل معتبر وارد کنید.', - })) - - // Password validation - if (isEmpty(password)) - return setError((prev) => ({ - ...prev, - password: 'لطفا رمز عبور خود را وارد کنید.', - })) - - if (isLessThan(password, 6)) - return setError((prev) => ({ - ...prev, - password: 'رمز عبور باید حداقل ۶ کاراکتر باشد.', - })) - } - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - resetErrors() - validateInputs() - try { - const response = await signInMutation({ - email, - password, - }) - console.log('Sign-in response:', response) - login(response.data) - Analytics.event('sign_in') - } catch (err) { - const content = translateError(err) - console.log('Sign-in error content:', content) - if (typeof content === 'string') { - setError({ - email: null, - password: null, - api: content, - }) - } else { - if (Object.keys(content).length === 0) { - setError({ - email: null, - password: null, - api: 'خطای ناشناخته رخ داد. لطفا دوباره تلاش کنید.', - }) - return - } - setError({ - email: content.email, - password: content.password, - api: null, - }) - } - } - } - - return ( -
-
- -
-

- ورود با رمز عبور -

-

- با ایمیل و رمز عبور خود وارد شوید -

-
-
- - {error.api && ( -
- {error.api} -
- )} - -
-
- - - - -
- -
- - - - -
- - -
-
- ) -} diff --git a/src/layouts/setting/tabs/account/auth-form/components/login-google.button.tsx b/src/layouts/setting/tabs/account/auth-form/components/login-google.button.tsx index 098e8b89a..1f35da2c8 100644 --- a/src/layouts/setting/tabs/account/auth-form/components/login-google.button.tsx +++ b/src/layouts/setting/tabs/account/auth-form/components/login-google.button.tsx @@ -84,22 +84,22 @@ export default function LoginGoogleButton() { type="button" onClick={loginGoogle} disabled={isLoading} - className="group px-4 md:px-8 py-2.5 md:py-3 rounded-2xl text-sm md:text-base font-medium shadow-md hover:shadow-lg w-full flex items-center justify-center border-2 border-content bg-content hover:bg-base-200 transition-all duration-200 gap-1.5 md:gap-2 cursor-pointer active:scale-95 group" + className="w-full h-11 px-4 rounded-xl text-xs md:text-sm font-medium flex items-center justify-center border border-base-300/80 bg-base-100 hover:bg-base-200/80 active:scale-[0.99] transition-all duration-150 gap-2.5 cursor-pointer text-content shadow-xs" > -
+
{isLoading ? ( - + ) : ( )}
- - {isLoading ? 'درحال پردازش...' : 'ورود با گوگل'} + + {isLoading ? 'درحال ورود...' : 'ورود با حساب گوگل'} ) diff --git a/src/layouts/setting/tabs/account/auth-form/components/login-otp-button.tsx b/src/layouts/setting/tabs/account/auth-form/components/login-otp-button.tsx deleted file mode 100644 index a5690c5bd..000000000 --- a/src/layouts/setting/tabs/account/auth-form/components/login-otp-button.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import Analytics from '@/analytics' -import { Icon } from '@/src/icons' - -type LoginOtpButtonProps = { - setShowPasswordForm: (show: boolean) => void -} - -const LoginOtpButton: React.FC = ({ setShowPasswordForm }) => { - const handleShowOtpForm = () => { - setShowPasswordForm(false) - Analytics.event('auth_method_changed_to_otp') - } - - return ( - - ) -} - -export default LoginOtpButton diff --git a/src/layouts/setting/tabs/account/auth-form/components/login-password.button.tsx b/src/layouts/setting/tabs/account/auth-form/components/login-password.button.tsx deleted file mode 100644 index 1d61b23ce..000000000 --- a/src/layouts/setting/tabs/account/auth-form/components/login-password.button.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import Analytics from '@/analytics' -import { Icon } from '@/src/icons' - -type LoginPasswordButtonProps = { - setShowPasswordForm: (show: boolean) => void -} - -const LoginPasswordButton: React.FC = ({ - setShowPasswordForm, -}) => { - const handleShowPasswordForm = () => { - setShowPasswordForm(true) - Analytics.event('auth_method_changed_to_password') - } - - return ( - - ) -} - -export default LoginPasswordButton diff --git a/src/layouts/setting/tabs/account/auth-form/components/other-auth-options.container.tsx b/src/layouts/setting/tabs/account/auth-form/components/other-auth-options.container.tsx deleted file mode 100644 index 642f76c4f..000000000 --- a/src/layouts/setting/tabs/account/auth-form/components/other-auth-options.container.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import LoginGoogleButton from './login-google.button' -import LoginPasswordButton from './login-password.button' -import LoginOtpButton from './login-otp-button' - -type OtherOptionsContainerProps = { - setShowPasswordForm: React.Dispatch> - showPasswordForm: boolean -} - -const OtherOptionsContainer: React.FC = ({ - setShowPasswordForm, - showPasswordForm, -}) => { - return ( -
-
-
-
- {showPasswordForm ? ( - - ) : ( - - )} - -
-
- ) -} - -export default OtherOptionsContainer diff --git a/src/layouts/setting/tabs/account/components/account-verification-status.tsx b/src/layouts/setting/tabs/account/components/account-verification-status.tsx index 7ffb2109b..9d2fffe0a 100644 --- a/src/layouts/setting/tabs/account/components/account-verification-status.tsx +++ b/src/layouts/setting/tabs/account/components/account-verification-status.tsx @@ -1,5 +1,5 @@ import { Button, SectionPanel } from '@/components/ui' -import { Icon } from '@/src/icons' +import { Icon } from '@/icons' interface AccountVerificationStatusProps { sendVerificationMutation: { @@ -29,7 +29,8 @@ export const AccountVerificationStatus = ({ - {showEditBadge('avatar') && ( - - )} -
-
-

- {user?.name || 'کاربر'} -

-

- @{user?.username || '-'} -

-
-
-
-
- -
-
- - شروعِ ماجرا از{' '} - {moment(user?.joinedAt).locale('fa').format('jMMMM jYYYY')} - -
-
-
+ { + setShowGallery(true) + Analytics.event('gallery_avatar_opened') + }} + showEditBadge={showEditBadge} + />
{ label="علایق" editable value={ -
+
{user?.interests?.map((i) => ( {}} @@ -246,10 +262,24 @@ export const ProfileDisplay = () => {
)} - {showAvatar && ( - setShowAvatar(false)} show={true} /> + {cropImage && ( + )} + setShowGallery(false)} + type="AVATAR" + title="گالری آواتارها" + onSelect={onSelectAvatarAsset} + selectedAssetUrl={user?.avatar} + /> + onCloseModal()} />
) diff --git a/src/layouts/setting/tabs/account/components/profile-header.tsx b/src/layouts/setting/tabs/account/components/profile-header.tsx new file mode 100644 index 000000000..30d03bcf9 --- /dev/null +++ b/src/layouts/setting/tabs/account/components/profile-header.tsx @@ -0,0 +1,209 @@ +import { useRef, useState } from 'react' +import moment from 'jalali-moment' +import { + AvatarComponent, + Button, + FloatingBadge, + PopoverMenu, + PopoverMenuItem, + Tooltip, +} from '@/components/ui' +import { UserCoin } from '@/components/user-coin' +import { Icon } from '@/icons' +import { useAuth } from '@/context/auth.context' +import { formatVipExpiryDate, formatVipRemaining } from '@/common/utils/vip-expiry' +import { callEvent } from '@/common/utils/call-event' + +interface ProfileHeaderProps { + onUploadFile: (file: File) => void + onSelectFromGallery: () => void + showEditBadge: (field: string) => boolean +} +const slots = [ + { dx: 70, dy: -48, baseRotate: 15 }, + { dx: -75, dy: -22, baseRotate: -20 }, + { dx: -80, dy: 45, baseRotate: -10 }, + { dx: 75, dy: 32, baseRotate: 20 }, + { dx: 0, dy: -70, baseRotate: 5 }, + { dx: -38, dy: 65, baseRotate: -15 }, + { dx: 55, dy: 65, baseRotate: 10 }, + { dx: -92, dy: 10, baseRotate: 25 }, +] +export const ProfileHeader = ({ + onUploadFile, + onSelectFromGallery, + showEditBadge, +}: ProfileHeaderProps) => { + const { user } = useAuth() + const [menuOpen, setMenuOpen] = useState(false) + const avatarAnchorRef = useRef(null) + const fileInputRef = useRef(null) + + const badges = user?.badges || [] + + const seed = (user?.username?.length || 0) + (user?.name?.length || 0) + + const vipRemaining = formatVipRemaining(user?.vipExpiresAt) + const vipExpiryDate = formatVipExpiryDate(user?.vipExpiresAt) + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (file) { + onUploadFile(file) + } + e.target.value = '' + } + + return ( +
+ + +
+ +
+ + {user?.vipExpiresAt ? ( +
+ + + +
+ ) : user?.isVip ? ( +
+ +
+ ) : null} + +
+ + شروعِ ماجرا از{' '} + {moment(user?.joinedAt).locale('fa').format('jMMMM jYYYY')} + +
+ +
+
+
+ setMenuOpen((prev) => !prev)} + className="w-16 h-16 text-2xl transition-all cursor-pointer ring-4 ring-primary/20" + /> + +
+ + setMenuOpen(false)} + triggerRef={avatarAnchorRef} + placement="bottom-center" + width={200} + > + } + label="بارگذاری از دستگاه" + onClick={() => { + setMenuOpen(false) + fileInputRef.current?.click() + }} + /> + } + label="انتخاب از گالری" + onClick={() => { + setMenuOpen(false) + onSelectFromGallery() + }} + /> + + + {showEditBadge('avatar') && ( + + )} + + {(() => { + return badges.map((badge, i) => { + const slot = slots[i % slots.length] + const jitterX = ((seed * 3 + i * 7) % 11) - 5 + const jitterY = ((seed * 5 + i * 11) % 11) - 5 + const rotate = slot.baseRotate + ((seed + i * 13) % 11) - 5 + const left = 104 + slot.dx + jitterX - 16 + const top = 74 + slot.dy + jitterY - 16 + return ( +
+ +
+ ) + }) + })()} + +
+

+ {user?.name || 'کاربر'} +

+

+ @{user?.username || '-'} +

+
+
+
+
+ ) +} diff --git a/src/layouts/setting/tabs/account/tabs/connection/connections-tab.tsx b/src/layouts/setting/tabs/account/tabs/connection/connections-tab.tsx index ab83b7d9b..e0b7b69a4 100644 --- a/src/layouts/setting/tabs/account/tabs/connection/connections-tab.tsx +++ b/src/layouts/setting/tabs/account/tabs/connection/connections-tab.tsx @@ -1,6 +1,6 @@ import { SectionPanel } from '@/components/ui' import { Connections } from '../user-profile/connections/connections' -import { Icon } from '@/src/icons' +import { Icon } from '@/icons' export const ConnectionPlatformsTab = () => { return ( diff --git a/src/layouts/setting/tabs/account/tabs/rewards/components/referral-code-section.tsx b/src/layouts/setting/tabs/account/tabs/rewards/components/referral-code-section.tsx index 2fbdd0821..0e0fe4f1e 100644 --- a/src/layouts/setting/tabs/account/tabs/rewards/components/referral-code-section.tsx +++ b/src/layouts/setting/tabs/account/tabs/rewards/components/referral-code-section.tsx @@ -10,7 +10,7 @@ export const ReferralCodeSection = ({ code, className }: ReferralCodeSectionProp const handleCopyCode = async () => { try { await navigator.clipboard.writeText(code) - showToast('کد دعوت کپی شد!', 'success') + showToast('کد دعوت کپی شد', 'success') } catch (error) { console.error('Failed to copy code:', error) showToast('خطا در کپی کردن کد', 'error') @@ -33,7 +33,7 @@ export const ReferralCodeSection = ({ code, className }: ReferralCodeSectionProp

کد دعوت

{code} @@ -43,14 +43,13 @@ export const ReferralCodeSection = ({ code, className }: ReferralCodeSectionProp onClick={handleCopyCode} size="sm" rounded={'xl'} - variant={'primary'} + color={'primary'} > کپی کد

- از این کد برای دعوت دوستان خود استفاده کنید و هر دو ویج‌کوین دریافت - کنید!{' '} + با فرستادن این کد برای دوستات، هم خودت و هم دوستت ویج‌کوین می‌گیرید

diff --git a/src/components/auth/require-verification.tsx b/src/layouts/setting/tabs/account/tabs/rewards/components/require-verification.tsx similarity index 98% rename from src/components/auth/require-verification.tsx rename to src/layouts/setting/tabs/account/tabs/rewards/components/require-verification.tsx index 8908fc0d2..7fec3ce38 100644 --- a/src/components/auth/require-verification.tsx +++ b/src/layouts/setting/tabs/account/tabs/rewards/components/require-verification.tsx @@ -3,7 +3,7 @@ import type { ReactNode } from 'react' import { callEvent } from '@/common/utils/call-event' import { useAuth } from '@/context/auth.context' import { Button } from '@/components/ui' -import { Icon } from '@/src/icons' +import { Icon } from '@/icons' interface RequireVerificationProps { children: ReactNode diff --git a/src/layouts/setting/tabs/account/tabs/rewards/components/tasks.tsx b/src/layouts/setting/tabs/account/tabs/rewards/components/tasks.tsx index 14e64597a..70fc02413 100644 --- a/src/layouts/setting/tabs/account/tabs/rewards/components/tasks.tsx +++ b/src/layouts/setting/tabs/account/tabs/rewards/components/tasks.tsx @@ -1,7 +1,7 @@ -import { ConfigKey } from '@/common/constant/config.key' +import { ConfigKey } from '@/common/constants/config.key' import { SectionPanel } from '@/components/ui' import type { Task } from '@/services/hooks/user/referrals-service.hook' -import { Icon } from '@/src/icons' +import { Icon } from '@/icons' interface Prop { tasks: Task[] diff --git a/src/layouts/setting/tabs/account/tabs/rewards/rewards-tab.tsx b/src/layouts/setting/tabs/account/tabs/rewards/rewards-tab.tsx index ebf3fa3c8..9665593fe 100644 --- a/src/layouts/setting/tabs/account/tabs/rewards/rewards-tab.tsx +++ b/src/layouts/setting/tabs/account/tabs/rewards/rewards-tab.tsx @@ -1,11 +1,11 @@ -import { ConfigKey } from '@/common/constant/config.key' +import { ConfigKey } from '@/common/constants/config.key' import { SectionPanel } from '@/components/ui' import { useGetReferrals } from '@/services/hooks/user/referrals-service.hook' import { ReferralCodeSection } from './components/referral-code-section' import { RewardTasks } from './components/tasks' -import { RequireVerification } from '@/components/auth/require-verification' +import { RequireVerification } from './components/require-verification' import { useAuth } from '@/context/auth.context' -import { Icon } from '@/src/icons' +import { Icon } from '@/icons' export const RewardsTab = () => { const { user } = useAuth() diff --git a/src/layouts/setting/tabs/account/tabs/user-profile/connections/components/connection-modal.tsx b/src/layouts/setting/tabs/account/tabs/user-profile/connections/components/connection-modal.tsx index a66bfdde4..fad7185bd 100644 --- a/src/layouts/setting/tabs/account/tabs/user-profile/connections/components/connection-modal.tsx +++ b/src/layouts/setting/tabs/account/tabs/user-profile/connections/components/connection-modal.tsx @@ -109,7 +109,7 @@ export function ConnectionModal({ } className="flex-2 h-9 text-sm" rounded={'2xl'} - variant={platform.connected ? 'danger' : 'primary'} + color={platform.connected ? 'danger' : 'primary'} > {platform.connected ? 'قطع اتصال' : 'تایید و شروع اتصال'} @@ -117,7 +117,6 @@ export function ConnectionModal({ size="sm" onClick={onClose} disabled={isLoading} - variant={'default'} rounded={'2xl'} className="flex-1 h-9" > diff --git a/src/layouts/setting/tabs/account/tabs/user-profile/connections/connections.tsx b/src/layouts/setting/tabs/account/tabs/user-profile/connections/connections.tsx index b3b97eba5..87ef0c16c 100644 --- a/src/layouts/setting/tabs/account/tabs/user-profile/connections/connections.tsx +++ b/src/layouts/setting/tabs/account/tabs/user-profile/connections/connections.tsx @@ -141,7 +141,7 @@ export function Connections() { ${ platform.connected ? 'bg-error/10 text-error' - : 'bg-primary text-white' + : 'bg-primary text-primary-content' } ${!platform.isActive && !platform.connected ? 'bg-base-300! text-muted' : ''}`} > {platform.isLoading ? ( diff --git a/src/layouts/setting/tabs/account/tabs/user-profile/user-profile.tsx b/src/layouts/setting/tabs/account/tabs/user-profile/user-profile.tsx index ec9b8ad7c..b561dc64d 100644 --- a/src/layouts/setting/tabs/account/tabs/user-profile/user-profile.tsx +++ b/src/layouts/setting/tabs/account/tabs/user-profile/user-profile.tsx @@ -12,7 +12,8 @@ import { ReferralCodeSection } from '../rewards/components/referral-code-section import { showToast } from '@/common/toast' import { translateError } from '@/common/utils/translate-error' import { ConfirmationModal } from '@/components/ui' -import { Icon } from '@/src/icons' +import { VipBannerCard } from './vip-banner-card' +import { Icon } from '@/icons' export const UserProfile = () => { const { logout } = useAuth() @@ -70,7 +71,7 @@ export const UserProfile = () => {

{getMessageError()}

+
+
+ ) +} diff --git a/src/layouts/setting/tabs/account/user-account.modal.tsx b/src/layouts/setting/tabs/account/user-account.modal.tsx index 05c1ea846..fb351d3a3 100644 --- a/src/layouts/setting/tabs/account/user-account.modal.tsx +++ b/src/layouts/setting/tabs/account/user-account.modal.tsx @@ -1,37 +1,27 @@ +import { useEffect } from 'react' import { Modal } from '@/components/ui' import { useAuth } from '@/context/auth.context' import AuthForm from './auth-form/auth-form' -import { SettingModal } from '../../setting-modal' interface FriendSettingModalProps { isOpen: boolean onClose: () => void selectedTab?: string | null } -export const UserAccountModal = ({ - isOpen, - onClose, - selectedTab, -}: FriendSettingModalProps) => { +export const UserAccountModal = ({ isOpen, onClose }: FriendSettingModalProps) => { const { isAuthenticated } = useAuth() + useEffect(() => { + if (isAuthenticated && isOpen) { + onClose() + } + }, [isAuthenticated, isOpen, onClose]) + if (!isAuthenticated) return ( - + ) - return ( - - ) + return null } diff --git a/src/layouts/setting/tabs/appearance/appearance.tsx b/src/layouts/setting/tabs/appearance/appearance.tsx index 15f9ddc3c..ddcab0ea4 100644 --- a/src/layouts/setting/tabs/appearance/appearance.tsx +++ b/src/layouts/setting/tabs/appearance/appearance.tsx @@ -3,22 +3,19 @@ import { useGetUserInventory } from '@/services/hooks/market/get-user-inventory. import { BrowserTitleSelector } from './components/browser-title-selector' import { FontSelector } from './components/font-selector' import { ThemeSelector } from './components/theme-selector' -import { UISelector } from './components/ui-selector' -import { ContentAlignmentSettings } from './components/content-alignment-settings' + export function AppearanceSettingTab() { const { isAuthenticated } = useAuth() const { data } = useGetUserInventory(isAuthenticated) return (
- -
) } diff --git a/src/layouts/setting/tabs/appearance/components/browser-title-selector.tsx b/src/layouts/setting/tabs/appearance/components/browser-title-selector.tsx index e0192c110..eb19c3242 100644 --- a/src/layouts/setting/tabs/appearance/components/browser-title-selector.tsx +++ b/src/layouts/setting/tabs/appearance/components/browser-title-selector.tsx @@ -4,14 +4,14 @@ import Analytics from '@/analytics' import { getFromStorage, setToStorage } from '@/common/storage' import { callEvent } from '@/common/utils/call-event' import { ItemSelector } from '@/components/ui' -import { renderBrowserTitlePreview } from '@/components/market/title/title-render-preview' +import { renderBrowserTitlePreview } from '@/components/browser-title-preview' import { SectionPanel } from '@/components/ui' import { safeAwait } from '@/services/api' import { useChangeBrowserTitle } from '@/services/hooks/extension/update-setting.hook' import type { UserInventoryItem } from '@/services/hooks/market/market.interface' import { translateError } from '@/common/utils/translate-error' import { showToast } from '@/common/toast' -import { Icon } from '@/src/icons' +import { Icon } from '@/icons' interface BrowserTitle { id: string diff --git a/src/layouts/setting/tabs/appearance/components/content-alignment-settings.tsx b/src/layouts/setting/tabs/appearance/components/content-alignment-settings.tsx deleted file mode 100644 index b03df9afd..000000000 --- a/src/layouts/setting/tabs/appearance/components/content-alignment-settings.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { ItemSelector } from '@/components/ui' -import { SectionPanel } from '@/components/ui' -import { UI, useAppearanceSetting } from '@/context/appearance.context' - -export function ContentAlignmentSettings() { - const { contentAlignment, setContentAlignment, ui } = useAppearanceSetting() - if (ui === UI.SIMPLE) return null - return ( - -
-

موقعیت عمودی محتوا

-
- setContentAlignment('center')} - label="وسط" - key="center" - className="w-1/2" - description="چیدمان محتوا در وسط صفحه قرار می‌گیرد." - /> - setContentAlignment('top')} - label="بالا" - key="top" - className="w-1/2" - description="چیدمان محتوا در بالا صفحه قرار می‌گیرد." - /> -
-
-
- ) -} diff --git a/src/layouts/setting/tabs/appearance/components/font-selector.tsx b/src/layouts/setting/tabs/appearance/components/font-selector.tsx index 222419d2c..d82ebbe58 100644 --- a/src/layouts/setting/tabs/appearance/components/font-selector.tsx +++ b/src/layouts/setting/tabs/appearance/components/font-selector.tsx @@ -5,7 +5,7 @@ import { ItemSelector } from '@/components/ui' import { SectionPanel } from '@/components/ui' import { useAppearanceSetting } from '@/context/appearance.context' import type { UserInventoryItem } from '@/services/hooks/market/market.interface' -import { Icon } from '@/src/icons' +import { Icon } from '@/icons' interface FontItem { label: string diff --git a/src/layouts/setting/tabs/appearance/components/theme-selector.tsx b/src/layouts/setting/tabs/appearance/components/theme-selector.tsx index b772bf27f..2983e97e0 100644 --- a/src/layouts/setting/tabs/appearance/components/theme-selector.tsx +++ b/src/layouts/setting/tabs/appearance/components/theme-selector.tsx @@ -1,4 +1,3 @@ -import type React from 'react' import { useEffect, useState } from 'react' import Analytics from '@/analytics' import { callEvent } from '@/common/utils/call-event' @@ -6,7 +5,7 @@ import { ItemSelector } from '@/components/ui' import { SectionPanel } from '@/components/ui' import { useTheme } from '@/context/theme.context' import type { UserInventoryItem } from '@/services/hooks/market/market.interface' -import { Icon } from '@/src/icons' +import { Icon } from '@/icons' interface ThemeItem { id: string diff --git a/src/layouts/setting/tabs/appearance/components/ui-selector.tsx b/src/layouts/setting/tabs/appearance/components/ui-selector.tsx deleted file mode 100644 index d366c056d..000000000 --- a/src/layouts/setting/tabs/appearance/components/ui-selector.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { ItemSelector } from '@/components/ui' -import { SectionPanel } from '@/components/ui' -import { UI, useAppearanceSetting } from '@/context/appearance.context' -import { Icon } from '@/src/icons' - -export function UISelector() { - const { setUI, ui } = useAppearanceSetting() - function onClick(item: UI) { - setUI(item as any) - } - - return ( - -

رابط کاربری

- - جدید - -
- } - size="sm" - > -
-
-

- ظاهر محیط افزونه خود را بر اساس نیازتان شخصی‌سازی کنید. -

-
- -
- onClick(UI.ADVANCED)} - label={ -
- - پیشفرض -
- } - description="همه‌چیز جلوی چشمته؛ ابزارها، ویجت‌ها و آزادی کامل برای چیدمان." - /> - onClick(UI.SIMPLE)} - label={ -
- - ساده و خلوت -
- } - description="خلوت، سریع و چشم‌نواز؛ برای وقتی که تمرکز مهمه." - /> -
-
- - ) -} diff --git a/src/layouts/setting/tabs/general/components/select-city.tsx b/src/layouts/setting/tabs/general/components/select-city.tsx index 1fda723ef..54754cfb6 100644 --- a/src/layouts/setting/tabs/general/components/select-city.tsx +++ b/src/layouts/setting/tabs/general/components/select-city.tsx @@ -3,14 +3,14 @@ import Analytics from '@/analytics' import { IconLoading } from '@/components/ui' import { Modal } from '@/components/ui' import { SectionPanel } from '@/components/ui' -import { useGetCitiesList } from '@/services/hooks/cities/get-cities-list' +import { useGetCitiesList } from '@/services/hooks/cities/get-cities-list.hook' import { useAuth } from '@/context/auth.context' import { AuthRequiredModal } from '@/components/auth/auth-required-modal' import { useSetCity } from '@/services/hooks/user/user-service.hook' -import { TextInput } from '@/components/text-input' +import { TextInput } from '@/components/ui' import { showToast } from '@/common/toast' import { translateError } from '@/common/utils/translate-error' -import { Icon } from '@/src/icons' +import { Icon } from '@/icons' interface SelectedCity { city: string @@ -113,13 +113,11 @@ export function SelectCity({ size }: Prop) {
)}
- {showAuthModal && ( - setShowAuthModal(!showAuthModal)} - message="برای انتخاب شهر، لطفا وارد حساب کاربری خود شوید." - /> - )} + setShowAuthModal(!showAuthModal)} + message="برای انتخاب شهر اول وارد حسابت شو" + /> { diff --git a/src/layouts/setting/tabs/shortcuts/shortcuts.tsx b/src/layouts/setting/tabs/shortcuts/shortcuts.tsx index 2a311d45a..75bb7c1c3 100644 --- a/src/layouts/setting/tabs/shortcuts/shortcuts.tsx +++ b/src/layouts/setting/tabs/shortcuts/shortcuts.tsx @@ -56,13 +56,6 @@ export function ShortcutsTab() { description: 'تغییر تم', category: 'ظاهری', }, - { - id: 'toggle_theme', - windowsKey: 'CTRL + ALT + Y', - macKey: '⌘ + ALT + Y', - description: 'تغییر رابط کاربری', - category: 'ظاهری', - }, ] const categories = shortcuts.reduce( @@ -99,10 +92,7 @@ export function ShortcutsTab() { {shortcut.description} -
+
{formatShortcut( isMac ? shortcut.macKey diff --git a/src/layouts/setting/tabs/vip/vip-plan-card.tsx b/src/layouts/setting/tabs/vip/vip-plan-card.tsx new file mode 100644 index 000000000..004a9a7c1 --- /dev/null +++ b/src/layouts/setting/tabs/vip/vip-plan-card.tsx @@ -0,0 +1,69 @@ +import { cn } from '@/common/utils/cn' +import { Icon } from '@/icons' +import type { VipPlan } from '@/services/hooks/market/market-vip.interface' + +interface VipPlanCardProps { + plan: VipPlan + isSelected: boolean + onSelect: () => void +} + +const fmt = (n: number) => new Intl.NumberFormat('fa-IR').format(n) + +export function VipPlanCard({ plan, isSelected, onSelect }: VipPlanCardProps) { + const badgeText = plan.meta?.badge === 'popular' ? 'محبوب' : plan.meta?.badge + + return ( +
+
+
+
+ {isSelected && } +
+ +

+ {plan.title} +

+
+ + {badgeText && ( + + {badgeText} + + )} +
+ +
+
+ + {fmt(plan.price)} + + تومان +
+

{fmt(plan.days)} روز اعتبار

+
+
+ ) +} diff --git a/src/layouts/setting/tabs/vip/vip-tab.tsx b/src/layouts/setting/tabs/vip/vip-tab.tsx new file mode 100644 index 000000000..2d661d329 --- /dev/null +++ b/src/layouts/setting/tabs/vip/vip-tab.tsx @@ -0,0 +1,401 @@ +import { useState, useEffect } from 'react' +import Analytics from '@/analytics' +import { Button } from '@/components/ui' +import { useAuth } from '@/context/auth.context' +import { showToast } from '@/common/toast' +import { translateError } from '@/common/utils/translate-error' +import { callEvent } from '@/common/utils/call-event' +import { Icon } from '@/icons' +import type { IconName } from '@/icons/types' +import { + useGetVipPlans, + usePurchaseVipPlan, +} from '@/services/hooks/market/market-vip.hook' +import type { VipPlan } from '@/services/hooks/market/market-vip.interface' +import { cn } from '@/common/utils/cn' +import vipPreviewImg from '@/assets/images/pro-preview.jpg' + +interface VipFeatureSlide { + id: string + tabTitle: string + icon: IconName + title: string + description: string + bullets: string[] +} + +const VIP_LABEL = 'پرو' + +const VIP_FEATURES: VipFeatureSlide[] = [ + { + id: 'unlimited', + tabTitle: 'ویجت‌های نامحدود', + icon: 'infinity', + title: 'ویجت‌های نامحدود', + description: + 'ویجت‌های موردعلاقه‌ات رو بدون محدودیت اضافه کن و صفحه‌ات رو دقیقا همون‌طور که می‌خوای بچین', + bullets: ['بدون محدودیت در تعداد ویجت‌ها', 'چیدمان آزاد و دلخواه'], + }, + { + id: 'customization', + tabTitle: 'شخصی‌سازی پیشرفته', + icon: 'brush', + title: 'صفحه‌ات رو مطابق سلیقه‌ات بساز', + description: + 'با سایزها، مدل‌ها و استایل‌های بیشتر، ظاهر ویجت‌ها و صفحه‌ات رو شخصی‌سازی کن', + bullets: [ + `سایزها و مدل‌های اختصاصی ${VIP_LABEL}`, + 'گزینه‌های بیشتر برای شخصی‌سازی ظاهر ویجت‌ها', + ], + }, + { + id: 'wallpaper_sync', + tabTitle: 'والپیپر شخصی', + icon: 'save', + title: 'والپیپرهای شخصی‌ات، همه‌جا', + description: + 'والپیپرهایی که از سیستم خودت انتخاب می‌کنی رو بدون محدودیت حجم ذخیره کن و روی همه سیستم‌هات داشته باش', + bullets: [ + 'حذف محدودیت حجم والپیپرهای شخصی', + 'دسترسی به والپیپرهای شخصی روی همه سیستم‌ها', + ], + }, + { + id: 'market_access', + tabTitle: 'مارکت ویجتیفای', + icon: 'shoppingBag', + title: 'دنیایی از آیتم‌های جذاب', + description: + 'تم‌ها، فونت‌ها، والپیپرها و آیتم‌های متنوع رو از مارکت ویجتیفای انتخاب و استفاده کن', + bullets: [ + 'دسترسی به مجموعه متنوع آیتم‌های مارکت', + 'استفاده از آیتم‌های منتخب بدون خرید جداگانه', + ], + }, + // { + // id: 'upcoming_features', + // tabTitle: 'قابلیت‌های جدید', + // icon: 'crown', + // title: 'امکانات بیشتر، به‌زودی', + // description: `قابلیت‌های جدید و امکانات کاربردی بیشتری در حال اضافه شدن به ویجتیفای ${VIP_LABEL} هستن`, + // bullets: [ + // 'افزایش ظرفیت عادت‌ها و بوکمارک مشترک', + // 'ذخیره وویس برای یادداشت‌ها و تسک‌ها', + // 'قابلیت‌ها و آیتم‌های جدید', + // ], + // }, +] + +const fmt = (n: number) => new Intl.NumberFormat('fa-IR').format(n) + +export function VipTab() { + const { isAuthenticated, refetchUser } = useAuth() + const [activeSlideIndex, setActiveSlideIndex] = useState(0) + const [selectedPlan, setSelectedPlan] = useState(null) + + const { data: plans, isLoading, refetch } = useGetVipPlans() + const { mutate: purchasePlan, isPending } = usePurchaseVipPlan() + + useEffect(() => { + Analytics.event('vip_tab_opened') + }, []) + + useEffect(() => { + if (plans?.length && !selectedPlan) { + const popular = + plans.find((p) => p.meta?.isPopular || p.meta?.badge === 'popular') || + plans[0] + setSelectedPlan(popular) + } + }, [plans, selectedPlan]) + + const currentSlide = VIP_FEATURES[activeSlideIndex] + + const handleNextSlide = () => { + setActiveSlideIndex((prev) => (prev + 1) % VIP_FEATURES.length) + } + + const handlePrevSlide = () => { + setActiveSlideIndex( + (prev) => (prev - 1 + VIP_FEATURES.length) % VIP_FEATURES.length + ) + } + + const handlePurchase = () => { + if (!selectedPlan) { + showToast('لطفاً یک پلن رو انتخاب کن', 'error') + return + } + + if (!isAuthenticated) { + Analytics.event('vip_plan_purchase_unauthenticated') + showToast(`برای خرید اشتراک ${VIP_LABEL} باید وارد حساب کاربری بشی`, 'error') + callEvent('openSettings', 'profile') + return + } + + purchasePlan( + { packageId: selectedPlan.id }, + { + onSuccess: () => { + showToast('در حال انتقال به درگاه پرداخت...', 'success') + Analytics.event('vip_plan_purchased') + refetchUser() + refetch() + }, + onError: (error) => { + showToast( + (translateError(error) as string) || + `خطا در خرید اشتراک ${VIP_LABEL}`, + 'error' + ) + Analytics.event('vip_plan_purchase_failed') + }, + } + ) + } + + return ( +
+
+
+
+
+
+ +
+

+ ویجتیفای {VIP_LABEL} +

+
+ +

+ ویجتیفای رو مطابق سلیقه خودت بساز +

+ +

+ با {VIP_LABEL} به امکانات پیشرفته دسترسی داشته باش و تجربه‌ای + سریع‌تر، زیباتر و شخصی‌تر داشته باش +

+
+ +
+
+
+
+ +
+

+ {currentSlide.title} +

+
+ +

+ {currentSlide.description} +

+
+ +
+ {currentSlide.bullets.map((bullet, idx) => ( +
+
+ +
+ {bullet} +
+ ))} +
+ +
+ + +
+ {VIP_FEATURES.map((_, idx) => ( +
+ + +
+
+
+ +
+ {`پیش‌نمایش +
+ +
+ +
+
+
+ +
+

+ پلن مناسب خودت رو انتخاب کن +

+ + {isLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+
+
+
+ ))} +
+ ) : plans?.length ? ( +
+ {plans.map((plan) => { + const isSelected = selectedPlan?.id === plan.id + const isPopular = + plan.meta?.isPopular || plan.meta?.badge === 'popular' + + return ( +
setSelectedPlan(plan)} + className={cn( + 'relative flex flex-col justify-between p-3.5 rounded-2xl border transition-all cursor-pointer text-right min-h-24 group', + isSelected + ? 'border-primary bg-primary/5 ring-1 ring-primary shadow-xs' + : 'border-base-content/10 bg-base-300/20 hover:border-primary/40 hover:bg-base-300/40' + )} + > + {isPopular && ( +
+ محبوب‌ترین +
+ )} + +
+
+
+ {plan.title} +
+ + {fmt(plan.days)} روز اعتبار + +
+ +
+ {isSelected && ( + + )} +
+
+ +
+
+ + {fmt(plan.price)} + + + تومان + +
+
+
+ ) + })} +
+ ) : ( +
+

+ در حال حاضر پلن فعالی موجود نیست +

+
+ )} +
+ +
+
+
+ +
+
+ + {selectedPlan?.title || 'اشتراک'} {VIP_LABEL} + + + دسترسی کامل به تمام امکانات {VIP_LABEL} + +
+
+ +
+
+ مبلغ قابل پرداخت +
+ + {selectedPlan ? fmt(selectedPlan.price) : '۰'} + + تومان +
+
+ +
+ +
+
+
+
+ ) +} + +export const ProTab = VipTab diff --git a/src/layouts/setting/tabs/wallpapers/components/coin-purchase-modal.tsx b/src/layouts/setting/tabs/wallpapers/components/coin-purchase-modal.tsx index 3fe106b53..7ab7f0cea 100644 --- a/src/layouts/setting/tabs/wallpapers/components/coin-purchase-modal.tsx +++ b/src/layouts/setting/tabs/wallpapers/components/coin-purchase-modal.tsx @@ -2,7 +2,7 @@ import { callEvent } from '@/common/utils/call-event' import type { Wallpaper } from '@/common/wallpaper.interface' import { Button, Modal } from '@/components/ui' import { useAuth } from '@/context/auth.context' -import { UserCoin } from '@/layouts/setting/tabs/account/components/user-coin' +import { UserCoin } from '@/components/user-coin' import { HoverPlayVideo } from '../tab/gallery/components/hover-play-video' interface CoinPurchaseModalProps { @@ -20,14 +20,26 @@ export function CoinPurchaseModal({ onPurchase, isPurchasing = false, }: CoinPurchaseModalProps) { - const { isAuthenticated } = useAuth() + const { isAuthenticated, user } = useAuth() if (!wallpaper) return null + const userCoins = user?.coins || 0 + const wallpaperPrice = wallpaper.coin || 0 + const canAfford = userCoins >= wallpaperPrice + const onLogin = () => { onClose() callEvent('openProfile') } + const handleOpenCoins = () => { + onClose() + callEvent('openMarketModal') + setTimeout(() => { + callEvent('market_change_tab', 'coins') + }, 100) + } + return (
-
+
{wallpaper.type === 'IMAGE' ? ( {wallpaper.name ) : ( { e.stopPropagation() }} @@ -62,45 +74,62 @@ export function CoinPurchaseModal({ )}
-
+
-

- {wallpaper.name || 'تصویر تصویر زمینه'} +

+ {wallpaper.name || 'تصویر زمینه'}

- + {wallpaperPrice > 0 && ( + + )}
-

- این تصویر زمینه زیبا را با ویج‌کوین باز کنید و برای همیشه استفاده - 🎨 کنید +

+ این تصویر زمینه را با ویج‌کوین باز کنید و برای همیشه از آن استفاده + کنید

-
+ {isAuthenticated && !canAfford && wallpaperPrice > 0 && ( +
+ + موجودی ویج‌کوین ناکافیه ({wallpaperPrice - userCoins} ویج‌کوین + کسری داری) + + +
+ )} + +
{isAuthenticated ? ( <> ) : ( @@ -108,8 +137,8 @@ export function CoinPurchaseModal({ size="md" onClick={onLogin} className="w-full" - variant={'primary'} - rounded={'2xl'} + color="primary" + rounded="2xl" > ورود به حساب کاربری diff --git a/src/layouts/setting/tabs/wallpapers/components/upload-area.component.tsx b/src/layouts/setting/tabs/wallpapers/components/upload-area.component.tsx index 82d271b5d..78a48939f 100644 --- a/src/layouts/setting/tabs/wallpapers/components/upload-area.component.tsx +++ b/src/layouts/setting/tabs/wallpapers/components/upload-area.component.tsx @@ -1,27 +1,37 @@ -import { useRef } from 'react' +import { useRef, useState } from 'react' import type { Wallpaper } from '@/common/wallpaper.interface' import { useWallpaperUpload } from '../hooks/use-wallpaper-upload' -import { MediaPreview } from './media-preview.component' -import { Icon } from '@/src/icons' -import { Button } from '@/components/ui' - -const MAX_SIZE = 2 -const MAX_FILE_SIZE = MAX_SIZE * 1024 * 1024 +import { useRemoveCustomWallpaper } from '@/services/hooks/wallpapers/upload-custom-wallpaper.hook' +import { safeAwait } from '@/services/api' +import { showToast } from '@/common/toast' +import { translateError } from '@/common/utils/translate-error' +import { UploadLoading } from './upload/upload-loading.component' +import { UploadEmpty } from './upload/upload-empty.component' +import { UploadActive } from './upload/upload-active.component' interface UploadAreaProps { customWallpaper: Wallpaper | null onWallpaperChange: (newWallpaper: Wallpaper) => void + onWallpaperRemove?: () => void | Promise } -export function UploadArea({ customWallpaper, onWallpaperChange }: UploadAreaProps) { +export function UploadArea({ + customWallpaper, + onWallpaperChange, + onWallpaperRemove, +}: UploadAreaProps) { + const [isDragging, setIsDragging] = useState(false) const fileInputRef = useRef(null) - const { processFile } = useWallpaperUpload({ - onWallpaperChange, - max_size: MAX_SIZE, - max_file_size: MAX_FILE_SIZE, - }) + const { mutateAsync: removeCustomWallpaper, isPending: isRemoving } = + useRemoveCustomWallpaper() + + const { processFile, isUploading, isLoadingConfig, isVip, vipMaxSize, freeMaxSize } = + useWallpaperUpload({ + onWallpaperChange, + }) const handleFileSelect = () => { + if (isUploading || isRemoving || isLoadingConfig) return if (fileInputRef.current) { fileInputRef.current.click() } @@ -35,73 +45,76 @@ export function UploadArea({ customWallpaper, onWallpaperChange }: UploadAreaPro } } - if (!customWallpaper) { - return ( -
- - -
- ) + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + if (!isDragging) setIsDragging(true) } - return ( -
-
-
- -
+ const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragging(false) + } + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault() + e.stopPropagation() + setIsDragging(false) + const file = e.dataTransfer.files?.[0] + if (file) { + processFile(file) + } + } -
- {customWallpaper.type === 'IMAGE' ? 'تصویر' : 'ویدیو'} -
-
+ const handleRemove = async () => { + if (isUploading || isRemoving) return + if (isVip && customWallpaper?.src?.startsWith('http')) { + const [error] = await safeAwait(removeCustomWallpaper()) + if (error) { + showToast(translateError(error) as string, 'error') + return + } + } + if (onWallpaperRemove) { + await onWallpaperRemove() + } + showToast('تصویر زمینه حذف شد', 'info') + } -
-

تصویر زمینه فعال

-

- {customWallpaper.name || 'بدون نام'} -

-
+ return ( + <> + {isLoadingConfig ? ( + + ) : !customWallpaper ? ( + + ) : ( + + )} -
- -
-
-
+ ) } diff --git a/src/layouts/setting/tabs/wallpapers/components/upload/pro-tooltip.component.tsx b/src/layouts/setting/tabs/wallpapers/components/upload/pro-tooltip.component.tsx new file mode 100644 index 000000000..cc47e581c --- /dev/null +++ b/src/layouts/setting/tabs/wallpapers/components/upload/pro-tooltip.component.tsx @@ -0,0 +1,32 @@ +import { Icon } from '@/icons' + +interface ProTooltipProps { + vipMaxSize: number +} + +export function ProTooltipContent({ vipMaxSize }: ProTooltipProps) { + return ( +
+
+ + + + امکانات نسخه پرو +
+
+
+ + عکس، گیف و ویدیو تا {vipMaxSize} مگابایت +
+
+ + ذخیره ابری بدون اشغال حافظه مرورگر +
+
+ + همگام‌سازی خودکار روی تمام دستگاه‌ها +
+
+
+ ) +} diff --git a/src/layouts/setting/tabs/wallpapers/components/upload/upload-active.component.tsx b/src/layouts/setting/tabs/wallpapers/components/upload/upload-active.component.tsx new file mode 100644 index 000000000..3570b88c4 --- /dev/null +++ b/src/layouts/setting/tabs/wallpapers/components/upload/upload-active.component.tsx @@ -0,0 +1,95 @@ +import type { Wallpaper } from '@/common/wallpaper.interface' +import { MediaPreview } from '../media-preview.component' +import { Icon } from '@/icons' +import { Button, Tooltip, VipBadge } from '@/components/ui' + +interface UploadActiveProps { + customWallpaper: Wallpaper + isUploading: boolean + isRemoving: boolean + onFileSelect: () => void + onRemove: () => void +} + +export function UploadActive({ + customWallpaper, + isUploading, + isRemoving, + onFileSelect, + onRemove, +}: UploadActiveProps) { + const isCloudWallpaper = Boolean(customWallpaper.src?.startsWith('http')) + + return ( +
+
+
+
+ +
+ + + {customWallpaper.type === 'IMAGE' ? 'عکس' : 'ویدیو'} + + + {isCloudWallpaper && ( +
+ +
+ )} +
+ +
+

+ {customWallpaper.type === 'IMAGE' + ? 'پس‌زمینه فعلی' + : 'ویدیو پس‌زمینه فعلی'} +

+
+ {isCloudWallpaper ? ( + + + همگام‌سازی شده با سرور + + ) : ( + + + ذخیره محلی + + + )} +
+
+
+ +
+ + + +
+
+
+ ) +} diff --git a/src/layouts/setting/tabs/wallpapers/components/upload/upload-empty.component.tsx b/src/layouts/setting/tabs/wallpapers/components/upload/upload-empty.component.tsx new file mode 100644 index 000000000..7ed2a6c8d --- /dev/null +++ b/src/layouts/setting/tabs/wallpapers/components/upload/upload-empty.component.tsx @@ -0,0 +1,145 @@ +import { Icon } from '@/icons' +import { Button, IconLoading, Tooltip } from '@/components/ui' +import { callEvent } from '@/common/utils/call-event' +import { cn } from '@/common/utils/cn' +import { ProTooltipContent } from './pro-tooltip.component' + +interface UploadEmptyProps { + isDragging: boolean + isUploading: boolean + isVip: boolean + vipMaxSize: number + freeMaxSize: number + onFileSelect: () => void + onDragOver: (e: React.DragEvent) => void + onDragLeave: (e: React.DragEvent) => void + onDrop: (e: React.DragEvent) => void +} + +export function UploadEmpty({ + isDragging, + isUploading, + isVip, + vipMaxSize, + freeMaxSize, + onFileSelect, + onDragOver, + onDragLeave, + onDrop, +}: UploadEmptyProps) { + return ( +
+
+
+
+ {isUploading ? ( + + ) : ( +
+ + + {isDragging ? 'رهاش کن' : 'آپلود'} + +
+ )} +
+ +
+

+ {isDragging + ? 'فایل رو همین‌جا رها کن' + : isVip + ? 'انتخاب عکس یا ویدیوی دلخواه' + : 'انتخاب عکس دلخواه'} +

+
+ {isVip ? ( + <> + + + تا {vipMaxSize} مگابایت + + + + + همگام سازی با اکانت + + + + ) : ( + <> + + + فقط عکس (تا {freeMaxSize} مگابایت) + + + + } + position="top" + > + + + + )} +
+
+
+ +
+ +
+
+
+ ) +} diff --git a/src/layouts/setting/tabs/wallpapers/components/upload/upload-loading.component.tsx b/src/layouts/setting/tabs/wallpapers/components/upload/upload-loading.component.tsx new file mode 100644 index 000000000..ca4be9158 --- /dev/null +++ b/src/layouts/setting/tabs/wallpapers/components/upload/upload-loading.component.tsx @@ -0,0 +1,12 @@ +import { IconLoading } from '@/components/ui' + +export function UploadLoading() { + return ( +
+
+ + در حال دریافت تنظیمات... +
+
+ ) +} diff --git a/src/layouts/setting/tabs/wallpapers/components/wallpaper-gallery.component.tsx b/src/layouts/setting/tabs/wallpapers/components/wallpaper-gallery.component.tsx deleted file mode 100644 index c37804112..000000000 --- a/src/layouts/setting/tabs/wallpapers/components/wallpaper-gallery.component.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { useRef } from 'react' -import type { Wallpaper } from '@/common/wallpaper.interface' -import { WallpaperItem } from '../tab/gallery/components/wallpaper-item/wallpaper-item' - -interface WallpaperGalleryProps { - isLoading: boolean - error: unknown - wallpapers: Wallpaper[] - selectedBackground: Wallpaper | null - onSelectBackground: (wallpaper: Wallpaper) => void - onPreviewBackground: (wallpaper: Wallpaper) => void -} - -export function WallpaperGallery({ - wallpapers, - selectedBackground, - onSelectBackground, - onPreviewBackground, -}: WallpaperGalleryProps) { - const galleryRef = useRef(null) - - return ( -
-
-
- {wallpapers.map((wallpaper) => ( -
- -
- ))} -
-
-
- ) -} diff --git a/src/layouts/setting/tabs/wallpapers/components/wallpaper-header.tsx b/src/layouts/setting/tabs/wallpapers/components/wallpaper-header.tsx index 63c5ef0f5..e18d57d1c 100644 --- a/src/layouts/setting/tabs/wallpapers/components/wallpaper-header.tsx +++ b/src/layouts/setting/tabs/wallpapers/components/wallpaper-header.tsx @@ -2,24 +2,30 @@ import { UploadArea } from './upload-area.component' import { useWallpaperContext } from '@/context/wallpaper.context' export function WallpaperHeader() { - const { handleCustomWallpaperChange, customWallpaper } = useWallpaperContext() + const { + handleCustomWallpaperChange, + handleRemoveCustomWallpaper, + customWallpaper, + } = useWallpaperContext() + return (

- انتخاب از سیستم + تصویر سفارشی

تصویر زمینه‌ها

- تصویر زمینه مورد علاقه خود را انتخاب کنید + تصویر زمینه مورد علاقه‌ت رو انتخاب کن

diff --git a/src/layouts/setting/tabs/wallpapers/components/wallpaper-sidebar.tsx b/src/layouts/setting/tabs/wallpapers/components/wallpaper-sidebar.tsx index d2858f722..3ac905f1e 100644 --- a/src/layouts/setting/tabs/wallpapers/components/wallpaper-sidebar.tsx +++ b/src/layouts/setting/tabs/wallpapers/components/wallpaper-sidebar.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import type { Category } from '@/common/wallpaper.interface' -import { Icon } from '@/src/icons' +import { Icon } from '@/icons' import { NewBadge, TabNavigation } from '@/components/ui' type FilterType = 'all' | 'image' | 'video' @@ -52,7 +52,7 @@ export function WallpaperSidebar({
{totalCount !== undefined && totalCount > 0 && ( = { 'to-bl': 'to bottom left', } +let currentlyAppliedWallpaper: StoredWallpaper | null = null + +function isSameWallpaper(a: StoredWallpaper | null, b: StoredWallpaper | null) { + if (!a || !b) return false + if (a.id !== b.id || a.type !== b.type || a.src !== b.src) return false + if (a.type === 'GRADIENT') { + return ( + a.gradient?.from === b.gradient?.from && + a.gradient?.to === b.gradient?.to && + a.gradient?.direction === b.gradient?.direction + ) + } + return true +} + function applyWallpaper(wallpaper: StoredWallpaper) { + if (isSameWallpaper(currentlyAppliedWallpaper, wallpaper)) { + if (wallpaper.type === 'VIDEO') { + const video = document.getElementById( + 'background-video' + ) as HTMLVideoElement | null + if (video && video.src === wallpaper.src) { + return + } + } else { + return + } + } + + currentlyAppliedWallpaper = wallpaper pinWallpaperForOffline(wallpaper) const existingVideo = document.getElementById('background-video') diff --git a/src/layouts/setting/tabs/wallpapers/hooks/use-wallpaper-upload.ts b/src/layouts/setting/tabs/wallpapers/hooks/use-wallpaper-upload.ts index 0d717d4a0..cc02e7508 100644 --- a/src/layouts/setting/tabs/wallpapers/hooks/use-wallpaper-upload.ts +++ b/src/layouts/setting/tabs/wallpapers/hooks/use-wallpaper-upload.ts @@ -1,32 +1,89 @@ import type { Wallpaper } from '@/common/wallpaper.interface' import Analytics from '../../../../../analytics' import { showToast } from '@/common/toast' +import { useAuth } from '@/context/auth.context' +import { useUploadCustomWallpaper } from '@/services/hooks/wallpapers/upload-custom-wallpaper.hook' +import { useGetWallpaperConfig } from '@/services/hooks/wallpapers/get-wallpaper-config.hook' +import { safeAwait } from '@/services/api' +import { translateError } from '@/common/utils/translate-error' +import { callEvent } from '@/common/utils/call-event' +import { AxiosError } from 'axios' + +const DEFAULT_FREE_MAX_SIZE = 2 +const DEFAULT_VIP_MAX_SIZE = 40 interface UseWallpaperUploadProps { onWallpaperChange: (wallpaper: Wallpaper) => void - max_size: number - max_file_size: number } -export function useWallpaperUpload({ - onWallpaperChange, - max_file_size, - max_size, -}: UseWallpaperUploadProps) { - const processFile = (file: File) => { +export function useWallpaperUpload({ onWallpaperChange }: UseWallpaperUploadProps) { + const { isAuthenticated, isVip } = useAuth() + const { data: config, isLoading: isLoadingConfig } = useGetWallpaperConfig() + const { mutateAsync: uploadCustomWallpaper, isPending: isUploading } = + useUploadCustomWallpaper() + + const freeMaxSize = config?.maxUploadSizeFree ?? DEFAULT_FREE_MAX_SIZE + const vipMaxSize = + config?.maxUploadSizeVip ?? config?.maxUploadSizeVip ?? DEFAULT_VIP_MAX_SIZE + + const processFile = async (file: File) => { const isImage = file.type.startsWith('image/') - // const isVideo = file.type.startsWith('video/') + const isVideo = file.type.startsWith('video/') + + if (isAuthenticated && isVip) { + if (!isImage && !isVideo) { + showToast('لطفاً یه فایل عکس، گیف یا ویدیو انتخاب کن', 'error') + return + } + + if (file.size > vipMaxSize * 1024 * 1024) { + showToast(`حجم فایل نباید بیشتر از ${vipMaxSize} مگابایت باشه`, 'error') + return + } + + const [error, uploadedWallpaper] = await safeAwait( + uploadCustomWallpaper(file) + ) + + if (error) { + showToast(translateError(error) as string, 'error') + return + } + + if (uploadedWallpaper) { + onWallpaperChange(uploadedWallpaper) + showToast( + isVideo + ? 'ویدیو ذخیره شد و با اکانتت همگام‌سازی شد' + : 'عکس ذخیره شد و با اکانتت همگام‌سازی شد', + 'success' + ) + Analytics.event('custom_wallpaper_selected') + } + return + } + + if (isVideo) { + showToast('برای گذاشتن ویدیو به عنوان پس‌زمینه اشتراک پرو لازمه', 'info') + callEvent('openSettings', 'vip') + return + } if (!isImage) { - showToast('لطفا یک فایل تصویری انتخاب کنید', 'error') + showToast('لطفاً یه عکس انتخاب کن', 'error') return } - if (file.size > max_file_size) { - showToast( - `حجم فایل نباید بیشتر از ${max_size} مگابایت باشد. حجم فعلی: ${(file.size / (1024 * 1024)).toFixed(1)} مگابایت`, - 'error' - ) + if (file.size > freeMaxSize * 1024 * 1024) { + if (file.size <= vipMaxSize * 1024 * 1024) { + showToast( + `برای آپلود فایل تا ${vipMaxSize} مگابایت اشتراک پرو لازمه`, + 'info' + ) + callEvent('openSettings', 'vip') + } else { + showToast(`حجم فایل نباید بیشتر از ${freeMaxSize} مگابایت باشه`, 'error') + } return } @@ -34,20 +91,27 @@ export function useWallpaperUpload({ reader.onload = () => { const newCustomWallpaper: Wallpaper = { id: 'custom-wallpaper', - type: isImage ? 'IMAGE' : 'VIDEO', + type: 'IMAGE', previewSrc: '', src: reader.result as string, - name: isImage ? 'تصویر سیستم' : 'ویدیو سیستم', + name: 'عکس دلخواه', isCustom: true, } onWallpaperChange(newCustomWallpaper) - Analytics.event('custom_wallpaper_selected') } reader.readAsDataURL(file) } - return { processFile } + return { + processFile, + isUploading, + isLoadingConfig, + isVip: Boolean(isAuthenticated && isVip), + freeMaxSize, + vipMaxSize, + maxSize: isAuthenticated && isVip ? vipMaxSize : freeMaxSize, + } } diff --git a/src/layouts/setting/tabs/wallpapers/hooks/use-wallpaper.tsx b/src/layouts/setting/tabs/wallpapers/hooks/use-wallpaper.tsx deleted file mode 100644 index 9ca93a4dc..000000000 --- a/src/layouts/setting/tabs/wallpapers/hooks/use-wallpaper.tsx +++ /dev/null @@ -1,167 +0,0 @@ -import type { AxiosError } from 'axios' -import { useEffect, useState } from 'react' -import { getFromStorage, setToStorage } from '@/common/storage' -import { callEvent, listenEvent } from '@/common/utils/call-event' -import type { StoredWallpaper, Wallpaper } from '@/common/wallpaper.interface' -import { safeAwait } from '@/services/api' -import { useChangeWallpaper } from '@/services/hooks/extension/update-setting.hook' -import { translateError } from '@/common/utils/translate-error' -import Analytics from '../../../../../analytics' -import { showToast } from '@/common/toast' -import { useQueryClient } from '@tanstack/react-query' -import { playAlarm } from '@/common/play-alarm' -import { useAuth } from '@/context/auth.context' - -export function useWallpaper(fetchedWallpapers: Wallpaper[] | undefined) { - const queryClient = useQueryClient() - const { isAuthenticated } = useAuth() - const [selectedBackground, setSelectedBackground] = useState(null) - const { mutateAsync } = useChangeWallpaper() - const [customWallpaper, setCustomWallpaper] = useState(null) - const [currentStoredWallpaper, setCurrentStoredWallpaper] = - useState(null) - - useEffect(() => { - async function getWallpaper() { - const wallpaper: StoredWallpaper | null = await getFromStorage('wallpaper') - if (wallpaper) { - setCurrentStoredWallpaper(wallpaper) - if (wallpaper.id === 'custom-wallpaper') { - const customWp = await getFromStorage('customWallpaper') - if (customWp) { - setCustomWallpaper(customWp) - setSelectedBackground(customWp) - } - } else if (wallpaper.type === 'GRADIENT' && wallpaper.gradient) { - const gradientWallpaper: Wallpaper = { - id: wallpaper.id, - name: wallpaper.id.includes('custom') - ? 'گرادیان سفارشی' - : 'گرادیان', - type: 'GRADIENT', - src: '', - previewSrc: '', - gradient: wallpaper.gradient, - } - setSelectedBackground(gradientWallpaper) - } else { - if (fetchedWallpapers) { - const foundWallpaper = fetchedWallpapers.find( - (wp) => wp.id === wallpaper.id - ) - if (foundWallpaper) { - setSelectedBackground(foundWallpaper) - } - } - } - } - } - - getWallpaper() - - const event = listenEvent('resetWallpaper', async () => { - const wallpaper: StoredWallpaper | null = await getFromStorage('wallpaper') - if (wallpaper) callEvent('wallpaper_change', wallpaper) - }) - - return () => { - event() - } - }, []) - - const allWallpapers = () => { - if (!fetchedWallpapers) return [] - - if (customWallpaper) { - return [...fetchedWallpapers, customWallpaper] - } - - return fetchedWallpapers - } - - useEffect(() => { - if (!selectedBackground) return - - const wallpaperData: StoredWallpaper = { - id: selectedBackground.id, - type: selectedBackground.type, - src: selectedBackground.src, - } - - if (selectedBackground.type === 'GRADIENT' && selectedBackground.gradient) { - wallpaperData.gradient = selectedBackground.gradient - } - setToStorage('wallpaper', wallpaperData) - - if (selectedBackground.id === 'custom-wallpaper') { - setToStorage('customWallpaper', selectedBackground) - } - - callEvent('wallpaper_change', wallpaperData) - }, [selectedBackground]) - - const handleSelectBackground = async (wallpaper: Wallpaper) => { - if (wallpaper.isCustom) { - setToBackground(wallpaper) - return - } - if (wallpaper.coin && !isAuthenticated) { - return showToast( - 'برای انتخاب این تصویر تصویر زمینه باید وارد حساب کاربری شوید.', - 'error' - ) - } - let isSet = false - if (!wallpaper.coin || wallpaper.isOwned) { - setToBackground(wallpaper) - isSet = true - } - - if (isAuthenticated) { - const wallpaperId = - wallpaper.type === 'GRADIENT' ? 'custom-wallpaper' : wallpaper.id - - const [error, responseWallpaper] = await safeAwait( - mutateAsync({ wallpaperId }) - ) - if (error) { - showToast(translateError(error) as string, 'error') - return - } - - if (wallpaper.coin && !wallpaper.isOwned) { - showToast('هووورا! تصویر زمینه فعال شد 🎉', 'success') - queryClient.invalidateQueries({ queryKey: ['userProfile'] }) - playAlarm('market') - } - - if (!isSet) setToBackground(responseWallpaper) - } - - Analytics.event('wallpaper_changed') - } - - const handlePreviewBackground = (wallpaper: Wallpaper) => { - setSelectedBackground(wallpaper) - Analytics.event('wallpaper_previewed') - } - - const setToBackground = (wallpaper: Wallpaper) => { - setSelectedBackground(wallpaper) - } - - const handleCustomWallpaperChange = (newWallpaper: Wallpaper) => { - setCustomWallpaper(newWallpaper) - handleSelectBackground(newWallpaper) - } - - return { - selectedBackground, - customWallpaper, - currentStoredWallpaper, - allWallpapers, - handleSelectBackground, - handlePreviewBackground, - handleCustomWallpaperChange, - } -} diff --git a/src/layouts/setting/tabs/wallpapers/tab/gallery/components/wallpaper-item/wallpaper-item.tsx b/src/layouts/setting/tabs/wallpapers/tab/gallery/components/wallpaper-item/wallpaper-item.tsx index 13c8c831e..8799e2cfe 100644 --- a/src/layouts/setting/tabs/wallpapers/tab/gallery/components/wallpaper-item/wallpaper-item.tsx +++ b/src/layouts/setting/tabs/wallpapers/tab/gallery/components/wallpaper-item/wallpaper-item.tsx @@ -1,12 +1,10 @@ import React, { useEffect, useRef, useState } from 'react' import type { Wallpaper } from '@/common/wallpaper.interface' -import { UserCoin } from '@/layouts/setting/tabs/account/components/user-coin' +import { UserCoin } from '@/components/user-coin' import { CoinPurchaseModal } from '@/layouts/setting/tabs/wallpapers/components/coin-purchase-modal' -import { useLazyLoad } from '../../../../hooks/use-lazy-load' -import { Tooltip } from '@/components/ui' +import { useLazyLoad } from '@/hooks/use-lazy-load' import { HoverPlayVideo } from '../hover-play-video' -import { Icon } from '@/src/icons' -import { UI } from '@/context/appearance.context' +import { Icon } from '@/icons' interface WallpaperItemProps { wallpaper: Wallpaper @@ -41,7 +39,7 @@ function WallpaperItemFu({ const itemOutlineStyle = isSelected ? 'ring-2 ring-primary/80 ring-offset-blue-100' - : 'ring-1 ring-base-content/10 group-hover:ring-primary/70' + : 'ring-1 ring-base-content/10 hover:ring-primary/70' useEffect(() => { if (loaded && videoRef.current && isSelected) { @@ -95,7 +93,7 @@ function WallpaperItemFu({
)} {error && ( -
+

خطا در بارگذاری

@@ -154,34 +152,9 @@ function WallpaperItemFu({ ) : null}
- {wallpaper.extensionUI ? ( -
- - {wallpaper.extensionUI === UI.SIMPLE ? ( - - ) : ( - - )} - -
- ) : null} {isSelected && ( -
+
)} diff --git a/src/layouts/setting/tabs/wallpapers/tab/gallery/components/wallpaper-item/wallpaper-view.tsx b/src/layouts/setting/tabs/wallpapers/tab/gallery/components/wallpaper-item/wallpaper-view.tsx index d686691be..2b915e7fb 100644 --- a/src/layouts/setting/tabs/wallpapers/tab/gallery/components/wallpaper-item/wallpaper-view.tsx +++ b/src/layouts/setting/tabs/wallpapers/tab/gallery/components/wallpaper-item/wallpaper-view.tsx @@ -8,7 +8,7 @@ import { WallpaperItem } from './wallpaper-item' import { usePreviewHandler } from '@/hooks/use-preview-handler' import { MarketItemType } from '@/services/hooks/market/market.interface' import Analytics from '@/analytics' -import { Icon } from '@/src/icons' +import { Icon } from '@/icons' interface WallpaperViewProps { selectedCategoryId: string | null @@ -115,14 +115,14 @@ export function WallpaperView({ return (
{isLoading ? (
{MakeSkeleton(6)}
) : filteredWallpapers.length === 0 ? (
- +

تصویر زمینه‌ای یافت نشد

فیلترهای انتخابی را تغییر دهید @@ -149,7 +149,7 @@ export function WallpaperView({

)} -
+
نمایش {filteredWallpapers.length.toLocaleString('fa-IR')}{' '} تصویر زمینه @@ -165,7 +165,7 @@ function MakeSkeleton(count: number) { return [...Array(count)].map((_, idx) => (
)) } diff --git a/src/layouts/setting/tabs/wallpapers/tab/gallery/gallery.tab.tsx b/src/layouts/setting/tabs/wallpapers/tab/gallery/gallery.tab.tsx index e0a8ea4a3..fef952b76 100644 --- a/src/layouts/setting/tabs/wallpapers/tab/gallery/gallery.tab.tsx +++ b/src/layouts/setting/tabs/wallpapers/tab/gallery/gallery.tab.tsx @@ -12,10 +12,10 @@ export function GalleryTab() { const { data: categories } = useGetWallpaperCategories() return ( -
+
-
+
-
+
- import('./widgets/calendar-simplify').then((module) => ({ - default: module.SimplifyCalendar, - })) -) - -const ArzSimplify = React.lazy(() => - import('./widgets/arz-simplify').then((module) => ({ - default: module.ArzSimplify, - })) -) - -const Habit = React.lazy(() => - import('./widgets/habit-simplify').then((module) => ({ - default: module.HabitSimplify, - })) -) - -const NewsSimplify = React.lazy(() => - import('./widgets/news-simplify').then((module) => ({ - default: module.NewsSimplify, - })) -) - -const tabs = [ - { - id: 'todos', - label: 'تسک ها', - icon: , - element: TodosLayout, - }, - { - id: 'habit', - label: 'عادت ها', - icon: , - element: Habit, - }, - - { - id: 'calendar', - label: 'تقویم', - icon: , - element: Calendar, - }, - { - id: 'currency', - label: 'ارزها', - icon: , - element: ArzSimplify, - }, - { - id: 'news', - label: 'اخبار', - icon: , - element: NewsSimplify, - }, -] - -type TabId = string - -export function SimpleTools() { - const [tab, setTab] = useState(null) - - const onChangeTab = (newTab: TabId) => { - setTab(newTab) - Analytics.event('yadkar_change_tab') - setToStorage('widget_tab', newTab) - } - - const ElementData = tabs.find((f) => f.id === tab)?.element - - useEffect(() => { - async function load() { - const tabFromStorage = await getFromStorage('widget_tab') - if (!tabFromStorage) { - setTab('calendar') - } else { - setTab(tabFromStorage) - } - } - - load() - }, []) - - return ( - -
- - {ElementData && ( - - در حال بارگذاری... -
- } - > - - - )} - - -
- -
-
- - ) -} diff --git a/src/layouts/simplify/widgets/arz-simplify.tsx b/src/layouts/simplify/widgets/arz-simplify.tsx deleted file mode 100644 index 745fea463..000000000 --- a/src/layouts/simplify/widgets/arz-simplify.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { WigiArzLayout } from '@/layouts/widgets/wigi-arz/wigi_arz.layout' - -export function ArzSimplify() { - return ( -
- -
- ) -} diff --git a/src/layouts/simplify/widgets/calendar-simplify.tsx b/src/layouts/simplify/widgets/calendar-simplify.tsx deleted file mode 100644 index 24efe9253..000000000 --- a/src/layouts/simplify/widgets/calendar-simplify.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import { CalendarHeader } from '../../widgets/calendar/components/calendar-header' -import { CalendarGrid } from '../../widgets/calendar/components/calendar-grid' -import { useDate } from '@/context/date.context' -import Analytics from '@/analytics' -import { GoogleCalendarView } from '@/layouts/widgets/calendar/components/google-calendar/google-calendar-view' -import { Icon } from '@/src/icons' - -export function SimplifyCalendar() { - const { currentDate, selectedDate, setCurrentDate, setSelectedDate, goToToday } = - useDate() - const [activeTab, setActiveTab] = useState('calendar') - - const onSetActiveTab = (tab: string) => { - setActiveTab(tab) - Analytics.event(`calendar_tab_switch_to_${tab}`) - } - - return ( -
-
- {activeTab === 'calendar' ? ( - <> - -
- -
- - ) : ( - - )} -
-
- -
-
- ) -} - -interface CalendarTabSelectorProps { - activeTab: string - setActiveTab: (tab: string) => void -} - -const CalendarTabSelector: React.FC = ({ - activeTab, - setActiveTab, -}) => { - return ( -
- - - -
- ) -} diff --git a/src/layouts/simplify/widgets/habit-simplify.tsx b/src/layouts/simplify/widgets/habit-simplify.tsx deleted file mode 100644 index 4bb9f168c..000000000 --- a/src/layouts/simplify/widgets/habit-simplify.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { HabitsContent } from '@/layouts/widgets/habit/habits.layout' - -export function HabitSimplify() { - return ( -
- -
- ) -} diff --git a/src/layouts/simplify/widgets/network-simplify.tsx b/src/layouts/simplify/widgets/network-simplify.tsx deleted file mode 100644 index 7e2062586..000000000 --- a/src/layouts/simplify/widgets/network-simplify.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { NetworkLayout } from '@/layouts/widgets/network/network.layout' - -export function NetworkSimplify() { - return ( -
- {/*
- -
*/} - -
- ) -} diff --git a/src/layouts/simplify/widgets/news-simplify.tsx b/src/layouts/simplify/widgets/news-simplify.tsx deleted file mode 100644 index 6eb8393ec..000000000 --- a/src/layouts/simplify/widgets/news-simplify.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { callEvent } from '@/common/utils/call-event' -import { Button } from '@/components/ui' -import { WidgetTabKeys } from '@/layouts/widgets-settings/constant/tab-keys' -import { NewsLayout } from '@/layouts/widgets/news/news.layout' -import { Icon } from '@/src/icons' - -export function NewsSimplify() { - const onClick = () => { - callEvent('openWidgetsSettings', { - tab: WidgetTabKeys.news_settings, - }) - } - return ( -
-
- -
- - -
- ) -} diff --git a/src/layouts/simplify/wigipad-simplify.tsx b/src/layouts/simplify/wigipad-simplify.tsx deleted file mode 100644 index 283ea68d8..000000000 --- a/src/layouts/simplify/wigipad-simplify.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { WidgetContainer } from '../widgets/widget-container' -import { NotificationCenter } from '../widgetify-card/notification-center/notification-center' -import { useGetNotifications } from '@/services/hooks/extension/get-notifications.hook' -import { RenderWigiPadItem } from '../widgets/wigi-pad/info-panel/components/ann-item' -import { DateDisplay } from '../widgets/wigi-pad/date-display/date.display' -import { ClockDisplay } from '../widgets/wigi-pad/clock-display/clock-display' - -export function SimplifyYadkar() { - const { data: fetchedData } = useGetNotifications() - - const hasBanner = !!fetchedData?.wigipadBanner - - return ( - -
- {hasBanner ? ( - <> -
-
- - ) : null} - - -
- -
-
-
- {fetchedData?.wigiPad?.map((notification, index) => ( - - ))} - -
-
-
- - ) -} diff --git a/src/layouts/widgetify-card/pets/README.md b/src/layouts/widgetify-card/pets/README.md deleted file mode 100644 index 6f959f90e..000000000 --- a/src/layouts/widgetify-card/pets/README.md +++ /dev/null @@ -1,208 +0,0 @@ -# Pet System Architecture - -This document describes how the pet system is designed and how to add new pets to the application. - -## Overview - -The pet system uses a modular architecture that allows for easy addition of new pet types. Each pet shares common behavior logic but can have unique characteristics, animations, and collectibles. - -## Directory Structure - -``` -/pets - /core - base-pet.tsx - Contains the shared pet behavior logic, collectible rendering, and base container - pet-types.ts - Contains type definitions for pets - /pet-item - pet-chicken.tsx - Chicken pet implementation - pet-dog.tsx - Dog pet implementation - ... (other pet implementations) - pet.context.tsx - Context provider for pet settings - pet-factory.tsx - Factory for creating pet components - pet.tsx - Main pet component -``` - -## How to Add a New Pet - -To add a new pet to the system: - -1. **Add the pet type to the enum in pet.context.tsx**: - -```tsx -export enum PetTypes { - DOG_AKITA = 'dog-akita', - CHICKEN = 'chicken', - YOUR_NEW_PET = 'your-new-pet', // Add your new pet here -} -``` - -2. **Add a default name for your pet**: - -```tsx -const DEFAULT_SETTINGS: PetSettings = { - enablePets: true, - petType: null, - petOptions: { - [PetTypes.DOG_AKITA]: { - name: 'آکیتا', - }, - [PetTypes.CHICKEN]: { - name: 'قدقدپور', - }, - [PetTypes.YOUR_NEW_PET]: { // Add your new pet here - name: 'Your Pet Name', - }, - }, -} -``` - -3. **Create a new pet component file** in the `/pet-item` directory: - -The easiest way is to copy the `pet-template.tsx` file and modify it for your new pet. Here's a simplified example: - -```tsx -// pet-item/pet-your-new-pet.tsx -import idle from '@/assets/animals/your-pet/your_pet_idle_8fps.gif' -import running from '@/assets/animals/your-pet/your_pet_run_8fps.gif' -import swipe from '@/assets/animals/your-pet/your_pet_swipe_8fps.gif' -import walking from '@/assets/animals/your-pet/your_pet_walk_fast_8fps.gif' -import { YourCollectibleIcon } from 'react-icons/xx' -import { BasePetContainer, useBasePetLogic } from '../core/base-pet' -import { PetSpeed } from '../core/pet-types' -import { usePetContext } from '../pet.context' - -export const YourPetComponent = () => { - const { getCurrentPetName } = usePetContext() - - // Define animations, dimensions, durations and assets for your pet - const petAnimations = { - idle, - walk: walking, - run: running, - swipe, - stand: swipe, - climb: walking - } - - const petDimensions = { - size: 32, - walkSpeed: PetSpeed.NORMAL, - runSpeed: PetSpeed.VERY_FAST, - climbSpeed: 1.2, - maxHeight: 100 - } - - const petDurations = { - walk: { min: 3000, max: 8000 }, - run: { min: 1500, max: 4000 }, - rest: { min: 5000, max: 10000 }, - climb: { min: 4000, max: 7000 } - } - - const petAssets = { - collectibleIcon: YourCollectibleIcon, - collectibleSize: 24, - collectibleFallSpeed: 2, - collectibleColor: 'blue-500', // Tailwind color class - } - - // Use the base pet logic - const { - containerRef, - petRef, - position, - direction, - showName, - collectibles, - getAnimationForCurrentAction, - dimensions, - assets - } = useBasePetLogic({ - name: getCurrentPetName() || 'Your Pet Default Name', - animations: petAnimations, - dimensions: petDimensions, - durations: petDurations, - assets: petAssets - }) - - // Use the BasePetContainer component for consistent rendering - return ( - - ) -} -``` - -4. **Update the PetFactory** in `pet-factory.tsx` to include your new pet: - -```tsx -// Add the import for your new pet component -const YourPetComponent = React.lazy(() => import('./pet-item/pet-your-new-pet').then(module => ({ default: module.YourPetComponent }))); - -// Add the case for your new pet in the switch statement -switch (petType) { - case PetTypes.DOG_AKITA: - PetComponent = DogComponent; - break; - case PetTypes.CHICKEN: - PetComponent = ChickenComponent; - break; - case PetTypes.YOUR_NEW_PET: - PetComponent = YourPetComponent; - break; - default: - return null; -} -``` - -5. **Add your pet animations** to the assets folder: - - Ensure your animations follow the naming convention: `__8fps.gif` - - Place them in the appropriate folder: `@/assets/animals/your-pet/` - -## Pet Behaviors and Animations - -Pets share these common behaviors: -- Walking/running -- Resting (idle or sitting) -- Climbing walls -- Chasing collectible items -- Interaction with users when clicked - -Each pet needs at least these animations: -- Idle -- Walk -- Run -- Stand/Swipe - -## Customizing Pet Behavior - -You can customize various aspects of your pet: - -- **Speed**: Choose from predefined speeds in `PetSpeed` Enum -- **Size**: Adjust the pixel size of the pet -- **Animation durations**: Control how long each behavior lasts -- **Collectibles**: Define what the pet chases and collects - -## Styling and Positioning - -All pets use a common container that positions them at the bottom of the screen. You can customize this if needed for specific pet types. - -## Creating Pet Assets - -For a cohesive look, pet animations should: -- Be pixel art style -- Have a transparent background -- Run at 8 frames per second -- Include all the required actions (idle, walk, run, etc.) -- Be sized appropriately for the dimension settings diff --git a/src/layouts/widgetify-card/pets/core/base-pet.tsx b/src/layouts/widgetify-card/pets/core/base-pet.tsx deleted file mode 100644 index 55fa43b76..000000000 --- a/src/layouts/widgetify-card/pets/core/base-pet.tsx +++ /dev/null @@ -1,701 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react' -import { PetTooltip } from '../components/pet-tooltip' -import { - type CollectibleItem, - type PetAnimations, - type PetAssets, - PetBehavior, - type PetDimensions, - type PetDurations, - type Position, -} from './pet-types' -import { UI, useAppearanceSetting } from '@/context/appearance.context' - -/** How fast a pet still airborne (e.g. mid-climb) settles back to the ground. */ -const FALL_SPEED = 1.5 - -function randomDuration({ min, max }: { min: number; max: number }): number { - return Math.floor(Math.random() * (max - min) + min) -} - -export interface BasePetProps { - name: string - animations: PetAnimations - dimensions: PetDimensions - durations: PetDurations - assets: PetAssets - onCollectibleCollection: (collectedItemId: number) => void - onLevelDownHungryState: () => void - isHungry: boolean -} - -interface CollectiblesRendererProps { - collectibles: CollectibleItem[] - assets: PetAssets -} - -export const CollectiblesRenderer: React.FC = ({ - collectibles, - assets, -}) => { - const CollectibleIcon = assets.collectibleIcon - - return ( - <> - {collectibles.map( - (item) => - !item.collected && ( -
- {CollectibleIcon} -
- ) - )} - - ) -} - -interface BasePetContainerProps { - name: string - containerRef: React.RefObject - petRef: React.RefObject - position: Position - direction: number - showName?: boolean - collectibles: CollectibleItem[] - getAnimationForCurrentAction: () => string - dimensions: PetDimensions - assets: PetAssets - isHungry: boolean - hungryLevel: number | undefined -} - -export const BasePetContainer: React.FC = ({ - name, - containerRef, - petRef, - position, - direction, - showName, - collectibles, - getAnimationForCurrentAction, - dimensions, - assets, - isHungry, -}) => { - const showToolTip = showName || isHungry - const { ui } = useAppearanceSetting() - - const currentSrc = getAnimationForCurrentAction() - const [loadedSrcs, setLoadedSrcs] = useState(() => - currentSrc ? [currentSrc] : [] - ) - - useEffect(() => { - if (!currentSrc) return - setLoadedSrcs((prev) => - prev.includes(currentSrc) ? prev : [...prev, currentSrc] - ) - }, [currentSrc]) - - return ( - - ) -} - -export function useBasePetLogic({ - animations, - dimensions, - durations, - assets, - onCollectibleCollection, - isHungry = false, - onLevelDownHungryState, -}: BasePetProps) { - const containerRef = useRef(null) - const petRef = useRef(null) - - const [position, setPosition] = useState({ x: 30, y: 0 }) - const [direction, setDirection] = useState(1) - const [action, setAction] = useState('idle') - const [actionTimer, setActionTimer] = useState(0) - const [behaviorState, setBehaviorState] = useState(PetBehavior.RESTING) - // Whether a climb is heading back down. Keeps the pet in the CLIMBING behavior - // for the whole up-and-down trip so it never idles mid-air — see climbWall. - const [isDescending, setIsDescending] = useState(false) - const [targetX, setTargetX] = useState(null) - const [isMovingToTarget, setIsMovingToTarget] = useState(false) - const [showName, setShowName] = useState(false) - - const [collectibles, setCollectibles] = useState([]) - const [collectibleIdCounter, setCollectibleIdCounter] = useState(0) - - const getMovementBounds = useCallback(() => { - const container = containerRef.current - const visibleHeight = container?.offsetHeight ?? dimensions.maxHeight - - return { - minX: 10, - maxX: (container?.offsetWidth || 300) - dimensions.size - 10, - minY: 0, - // The container clips overflow (see BasePetContainer's h-16/h-8), which is - // shorter than several pets' configured maxHeight (80-100px). Climbing past - // it pushed pets above the visible area, so they'd reappear "floating" back - // down into frame — clamp to whichever is actually smaller. - maxY: Math.max(0, Math.min(dimensions.maxHeight, visibleHeight - dimensions.size)), - } - }, [dimensions.size, dimensions.maxHeight]) - - const isNearWall = useCallback(() => { - const bounds = getMovementBounds() - return position.x <= bounds.minX + 5 || position.x >= bounds.maxX - 5 - }, [getMovementBounds, position.x]) - - const getCurrentSpeed = useCallback(() => { - return action === 'run' ? dimensions.runSpeed : dimensions.walkSpeed - }, [action, dimensions.runSpeed, dimensions.walkSpeed]) - - const updateAction = useCallback((newAction: keyof PetAnimations) => { - setAction((currentAction) => { - if (currentAction !== newAction) { - return newAction - } - return currentAction - }) - }, []) - - const updateBehaviorState = useCallback((newBehaviorState: PetBehavior) => { - setBehaviorState((currentBehaviorState) => { - if (currentBehaviorState !== newBehaviorState) { - return newBehaviorState - } - return currentBehaviorState - }) - }, []) - - const handleClick = useCallback( - (e: MouseEvent) => { - const container = containerRef.current - if (container) { - if (collectibles.length > 2) { - setCollectibles([]) - return - } - - const rect = container.getBoundingClientRect() - const clickX = e.clientX - rect.left - const bounds = getMovementBounds() - const clampedX = Math.max(bounds.minX, Math.min(bounds.maxX, clickX)) - - const newCollectible: CollectibleItem = { - id: collectibleIdCounter, - x: clampedX, - y: -assets.collectibleSize, - collected: false, - dropping: true, - } - - setCollectibles((prev) => [...prev, newCollectible]) - setCollectibleIdCounter((prev) => prev + 1) - - // Only force an immediate chase reaction while grounded. If the pet is - // mid-climb, forcing 'run' here would move it horizontally while still - // airborne (the same "flying" bug climbWall guards against) — let it - // land first; updateBehavior picks up the waiting collectible on its own - // the moment position.y reaches 0. - if (position.y === 0) { - if (action === 'sit' || action === 'idle') { - updateAction('stand') - setTimeout(() => { - updateAction('run') - updateBehaviorState(PetBehavior.CHASING) - }, 300) - } else { - updateAction('run') - updateBehaviorState(PetBehavior.CHASING) - } - } - } - }, - [ - collectibleIdCounter, - assets.collectibleSize, - action, - position.y, - getMovementBounds, - updateAction, - updateBehaviorState, - ] - ) - - const findNearestCollectible = useCallback( - (currentCollectibles: CollectibleItem[]) => { - const availableCollectibles = currentCollectibles.filter( - (item) => !item.collected && !item.dropping && item.y <= 5 - ) - - if (availableCollectibles.length === 0) return null - - let nearest = availableCollectibles[0] - let minDistance = Math.abs(position.x - nearest.x) - - for (let i = 1; i < availableCollectibles.length; i++) { - const distance = Math.abs(position.x - availableCollectibles[i].x) - if (distance < minDistance) { - minDistance = distance - nearest = availableCollectibles[i] - } - } - return nearest - }, - [position.x] - ) - - const handleCollectibleCollection = useCallback( - (collectedItemId: number) => { - setCollectibles((prevCollectibles) => - prevCollectibles.map((collectible) => - collectible.id === collectedItemId - ? { ...collectible, collected: true } - : collectible - ) - ) - updateAction('stand') - if (onCollectibleCollection) { - onCollectibleCollection(collectedItemId) - } - setTimeout( - () => - updateAction(behaviorState === PetBehavior.CHASING ? 'run' : 'walk'), - 500 - ) - }, - [updateAction, behaviorState] - ) - - const updateCollectibles = useCallback(() => { - setCollectibles((prevCollectibles) => { - let collectiblesChanged = false - const updatedCollectibles = prevCollectibles.map((collectible) => { - if (collectible.collected) return collectible - - if (collectible.dropping) { - const newY = collectible.y + assets.collectibleFallSpeed - if (newY >= 0) { - collectiblesChanged = true - return { ...collectible, y: 0, dropping: false } - } - collectiblesChanged = true - return { ...collectible, y: newY } - } - - const distance = Math.abs(collectible.x - position.x) - if ( - !collectible.collected && - !collectible.dropping && - distance < dimensions.size / 1.5 - ) { - handleCollectibleCollection(collectible.id) - // The actual collection marking will be handled by handleCollectibleCollection's setCollectibles - // We return the original collectible here to avoid immediate re-render issues before the state update from handleCollectibleCollection - return collectible - } - return collectible - }) - - if (collectiblesChanged) { - return updatedCollectibles - } - return prevCollectibles - }) - }, [ - assets.collectibleFallSpeed, - position.x, - dimensions.size, - handleCollectibleCollection, - ]) - - // Effect for removing collected items after a delay - useEffect(() => { - const collectedItems = collectibles.filter((c) => c.collected) - if (collectedItems.length > 0) { - const timer = setTimeout(() => { - setCollectibles((prev) => prev.filter((c) => !c.collected)) - }, 2000) // Delay before removing - return () => clearTimeout(timer) - } - }, [collectibles]) - - const roamOrRest = useCallback(() => { - // Always land before switching to anything else — never leave the pet - // paused mid-climb. climbWall handles the actual descent and transitions - // to RESTING itself once position.y reaches 0. - if (behaviorState === PetBehavior.CLIMBING) { - setIsDescending(true) - return - } - - if (isHungry) { - updateBehaviorState(PetBehavior.RESTING) - updateAction('sit') - setActionTimer(randomDuration(durations.rest)) - return - } - - const random = Math.random() - if (behaviorState === PetBehavior.ROAMING) { - if (isNearWall() && random > 0.7 && animations.climb) { - setIsDescending(false) - updateBehaviorState(PetBehavior.CLIMBING) - updateAction('climb') - setActionTimer(randomDuration(durations.climb)) - } else { - updateBehaviorState(PetBehavior.RESTING) - const shouldSit = Math.random() > 0.5 && animations.sit - updateAction(shouldSit ? 'sit' : 'idle') - setActionTimer(randomDuration(durations.rest)) - } - } else { - // Includes PetBehavior.RESTING or initial state - updateBehaviorState(PetBehavior.ROAMING) - const shouldRun = Math.random() > 0.6 - updateAction(shouldRun ? 'run' : 'walk') - setActionTimer(randomDuration(shouldRun ? durations.run : durations.walk)) - } - - if (onLevelDownHungryState) { - onLevelDownHungryState() - } - }, [ - behaviorState, - isHungry, - isNearWall, - animations.climb, - animations.sit, - durations, - updateAction, - updateBehaviorState, - ]) - - const updateBehavior = useCallback(() => { - const nearestCollectible = findNearestCollectible(collectibles) - - // Only chase while grounded. Reacting to a collectible mid-climb used to - // switch straight to 'run' and move horizontally while the pet was still - // airborne — i.e. it would fly toward the treat. Once landed, this same - // check runs again next tick and starts the chase normally. - if (nearestCollectible && position.y === 0) { - if (behaviorState !== PetBehavior.CHASING) { - updateBehaviorState(PetBehavior.CHASING) - updateAction('run') - } - setTargetX(nearestCollectible.x) - setIsMovingToTarget(true) - return - } - - // If was chasing but no more collectibles, rest - if (behaviorState === PetBehavior.CHASING) { - updateBehaviorState(PetBehavior.RESTING) - updateAction('idle') - setActionTimer(2000) // Rest for 2 seconds - setIsMovingToTarget(false) // Stop chasing - setTargetX(null) - return - } - - if (!isMovingToTarget && actionTimer <= 0) { - roamOrRest() - } else if (!isMovingToTarget) { - setActionTimer((prev) => prev - 16) // Using 16ms interval from physicsUpdate - } - }, [ - collectibles, - findNearestCollectible, - behaviorState, - isMovingToTarget, - actionTimer, - position.y, - updateAction, - updateBehaviorState, - roamOrRest, - ]) - - const movePet = useCallback( - (currentPosition: Position, currentDirection: number) => { - const bounds = getMovementBounds() - let newX = currentPosition.x + currentDirection * getCurrentSpeed() - let newDirection = currentDirection - - if (newX >= bounds.maxX) { - newDirection = -1 - newX = bounds.maxX - } else if (newX <= bounds.minX) { - newDirection = 1 - newX = bounds.minX - } - if (newDirection !== currentDirection) { - setDirection(newDirection) - } - const newY = - currentPosition.y > 0 - ? Math.max(0, currentPosition.y - FALL_SPEED) - : 0 - return { x: newX, y: newY } - }, - [getMovementBounds, getCurrentSpeed] - ) - - const moveToTarget = useCallback( - (currentPosition: Position) => { - if (targetX === null) return currentPosition // Should not happen if isMovingToTarget is true - - const bounds = getMovementBounds() - const delta = targetX - currentPosition.x - const distance = Math.abs(delta) - const speed = action === 'run' ? dimensions.runSpeed : dimensions.walkSpeed - - if (distance <= speed) { - setIsMovingToTarget(false) - setTargetX(null) - // After reaching target, decide next action based on behavior - if (behaviorState === PetBehavior.CHASING) { - const nextCollectible = findNearestCollectible(collectibles) - if (!nextCollectible) { - updateAction('idle') // No more collectibles to chase - updateBehaviorState(PetBehavior.RESTING) - } else { - // If there's another collectible, updateBehavior will handle it in the next tick - } - } else { - updateAction('idle') // Default to idle if not chasing - } - return { x: targetX, y: currentPosition.y } - } - - const newDirection = delta > 0 ? 1 : -1 - if (newDirection !== direction) { - setDirection(newDirection) - } - - const newX = currentPosition.x + newDirection * speed - return { - x: Math.max(bounds.minX, Math.min(bounds.maxX, newX)), - y: currentPosition.y, - } - }, - [ - targetX, - action, - dimensions.runSpeed, - dimensions.walkSpeed, - behaviorState, - collectibles, - direction, - getMovementBounds, - findNearestCollectible, - updateAction, - updateBehaviorState, - ] - ) - - const climbWall = useCallback( - (currentPosition: Position, currentDirection: number) => { - const bounds = getMovementBounds() - const wallX = currentDirection === 1 ? bounds.maxX : bounds.minX - - if (isDescending) { - const newY = currentPosition.y - dimensions.climbSpeed - if (newY <= 0) { - // Landed. Hand off to the normal rest cycle here, at y=0, instead of - // switching to an idle/stand pose earlier and leaving gravity to - // trickle the position down separately — that gap is what made pets - // (most visibly the dog) look like they were floating back to earth. - setIsDescending(false) - updateBehaviorState(PetBehavior.RESTING) - updateAction(animations.stand ? 'stand' : 'idle') - setActionTimer(randomDuration(durations.rest)) - return { x: wallX, y: 0 } - } - return { x: wallX, y: newY } - } - - const newY = Math.min(currentPosition.y + dimensions.climbSpeed, bounds.maxY) - return { x: wallX, y: newY } - }, - [ - isDescending, - dimensions.climbSpeed, - durations.rest, - animations.stand, - getMovementBounds, - updateBehaviorState, - updateAction, - ] - ) - - /** Safety net for a pet ever left with y > 0 outside an active climb. */ - const applyGravity = useCallback((currentPosition: Position) => { - if (currentPosition.y > 0) { - return { ...currentPosition, y: Math.max(0, currentPosition.y - FALL_SPEED) } - } - return currentPosition - }, []) - - const physicsUpdate = useCallback(() => { - updateCollectibles() - updateBehavior() - - setPosition((prevPosition) => { - let newPosition = { ...prevPosition } - - if (isMovingToTarget && (action === 'walk' || action === 'run')) { - newPosition = moveToTarget(prevPosition) - } else if (action === 'walk' || action === 'run') { - newPosition = movePet(prevPosition, direction) - } else if ( - action === 'climb' && - behaviorState === PetBehavior.CLIMBING && - animations.climb - ) { - newPosition = climbWall(prevPosition, direction) - } else if (behaviorState !== PetBehavior.CLIMBING && prevPosition.y > 0) { - newPosition = applyGravity(prevPosition) - } - return newPosition - }) - }, [ - updateCollectibles, - updateBehavior, - isMovingToTarget, - action, - moveToTarget, - movePet, - direction, - behaviorState, - animations.climb, - climbWall, - applyGravity, - ]) - - // physicsUpdate gets a new identity on almost every tick (it transitively - // depends on position/behaviorState via updateBehavior/movePet/etc.), so an - // effect keyed on it would tear down and recreate the timer ~60 times a - // second. Keeping the latest version in a ref lets the loop itself mount - // once. requestAnimationFrame also pauses automatically while the tab is - // backgrounded, unlike setInterval. - const physicsUpdateRef = useRef(physicsUpdate) - physicsUpdateRef.current = physicsUpdate - - useEffect(() => { - let frameId: number - let lastTick = performance.now() - - const loop = (now: number) => { - if (now - lastTick >= 16) { - lastTick = now - physicsUpdateRef.current() - } - frameId = requestAnimationFrame(loop) - } - - frameId = requestAnimationFrame(loop) - return () => cancelAnimationFrame(frameId) - }, []) - - useEffect(() => { - const container = containerRef.current - - if (container) { - container.addEventListener('click', handleClick) - } - - return () => { - if (container) { - container.removeEventListener('click', handleClick) - } - } - }, [handleClick]) - - useEffect(() => { - const petElement = petRef.current - if (!petElement) return - - const handleMouseEnter = () => { - setShowName(true) - } - - const handleMouseLeave = () => { - setShowName(false) - } - - petElement.addEventListener('mouseenter', handleMouseEnter) - petElement.addEventListener('mouseleave', handleMouseLeave) - - return () => { - petElement.removeEventListener('mouseenter', handleMouseEnter) - petElement.removeEventListener('mouseleave', handleMouseLeave) - } - }, [petRef]) // Added petRef to dependency array - - const getAnimationForCurrentAction = useCallback(() => { - return animations[action] || animations.idle - }, [animations, action]) - - return { - containerRef, - petRef, - position, - direction, - action, - showName, - collectibles, - getAnimationForCurrentAction, - dimensions, - assets, - } -} diff --git a/src/layouts/widgetify-card/pets/pet.context.tsx b/src/layouts/widgetify-card/pets/pet.context.tsx deleted file mode 100644 index 5bebbf787..000000000 --- a/src/layouts/widgetify-card/pets/pet.context.tsx +++ /dev/null @@ -1,282 +0,0 @@ -import { createContext, useContext, useEffect, useState } from 'react' -import { getFromStorage, setToStorage } from '@/common/storage' -import { listenEvent } from '@/common/utils/call-event' - -export enum PetTypes { - DOG_AKITA = 'dog-akita', - CHICKEN = 'chicken', - CRAB = 'crab', - FROG = 'frog', - CAT = 'cat', -} -export interface PetSettings { - petType: PetTypes | null - petOptions: Record< - PetTypes, - { - name: string - emoji: string - type: 'dog' | 'chicken' | 'crab' | 'frog' | 'cat' - hungryState: { - level: number // e.g., 0-100 - lastHungerTick: number | null //timestamp - } - } - > -} - -interface PetSettingsContextType extends PetSettings { - getCurrentPetName: (petType: PetTypes) => string - levelUpHungryState: (petType: PetTypes) => void - levelDownHungryState: (petType: PetTypes) => void - isPetHungry: (petType: PetTypes) => boolean - getPetHungryState: (petType: PetTypes) => { - level: number - lastHungerTick: number | null - } | null - isEnabled: boolean - setIsEnabled: (value: boolean) => void -} -export const BASE_PET_OPTIONS: PetSettings = { - petType: PetTypes.DOG_AKITA, - petOptions: { - [PetTypes.DOG_AKITA]: { - name: 'آکیتا', - emoji: '🐶', - type: 'dog', - hungryState: { - level: 100, - lastHungerTick: null, - }, - }, - [PetTypes.CHICKEN]: { - name: 'قدقدپور', - emoji: '🐔', - type: 'chicken', - hungryState: { - level: 100, - lastHungerTick: null, - }, - }, - [PetTypes.CRAB]: { - name: 'چنگولی', - emoji: '🦀', - type: 'crab', - hungryState: { - level: 100, - lastHungerTick: null, - }, - }, - [PetTypes.CAT]: { - name: 'زردآلو', - emoji: '🐈', - type: 'cat', - hungryState: { - level: 100, - lastHungerTick: null, - }, - }, - [PetTypes.FROG]: { - name: 'قوری', - emoji: '🐸', - type: 'frog', - hungryState: { - level: 100, - lastHungerTick: null, - }, - }, - }, -} - -const PetContext = createContext(undefined) - -export function PetProvider({ children }: { children: React.ReactNode }) { - const [settings, setSettings] = useState({ - ...BASE_PET_OPTIONS, - }) - const [isEnabled, setIsEnabled] = useState(false) - - useEffect(() => { - async function load() { - // const storedPets = await getFromStorage('pets') - const [storedPets, petState] = await Promise.all([ - getFromStorage('pets'), - getFromStorage('petState'), - ]) - if (storedPets) { - if (!storedPets.petOptions['dog-akita'].hungryState) { - setToStorage('pets', { - ...BASE_PET_OPTIONS, - }) - setSettings({ - ...BASE_PET_OPTIONS, - }) - } else { - setSettings({ - ...BASE_PET_OPTIONS, - ...storedPets, - petOptions: { - ...BASE_PET_OPTIONS.petOptions, - ...(storedPets.petOptions || {}), - }, - }) - } - } else { - const initialSettings = { - ...BASE_PET_OPTIONS, - petType: PetTypes.DOG_AKITA, - } - setSettings(initialSettings) - await setToStorage('pets', initialSettings) - } - - if (typeof petState === 'boolean') { - setIsEnabled(petState) - } else { - setIsEnabled(true) - } - } - - load() - }, []) - - useEffect(() => { - const event = listenEvent('updatedPetSettings', (data) => { - if (data) { - setSettings((prevSettings) => { - const newSettings = { ...prevSettings } - - if (data.petName && data.petType) { - newSettings.petOptions = { - ...newSettings.petOptions, - [data.petType]: { - ...newSettings.petOptions[data.petType], - name: data.petName, - }, - } - } - - const updatedSettings: PetSettings = { - ...newSettings, - petType: - data.petType !== undefined - ? data.petType - : newSettings.petType, - } - - setToStorage('pets', updatedSettings) - - return updatedSettings - }) - } - }) - - const event2 = listenEvent('updatedPetState', (data) => { - setIsEnabled(data) - setToStorage('petState', data) - }) - - return () => { - event() - event2() - } - }, []) - - const getCurrentPetName = (petType: PetTypes) => { - const CurrentPet = settings.petOptions[petType] - if (CurrentPet) { - return CurrentPet.name - } - return '' - } - - const levelUpHungryState = (petType: PetTypes) => { - setSettings((prevSettings) => { - const newSettings = { ...prevSettings } - - const pet = newSettings.petOptions[petType] - - if (pet && pet.hungryState?.level < 100) { - pet.hungryState.level += [10, 15, 25][Math.floor(Math.random() * 2)] - } - - if (pet.hungryState?.level > 100) { - pet.hungryState.level = 100 - } - - setToStorage('pets', newSettings) - - return newSettings - }) - } - - const levelDownHungryState = (petType: PetTypes) => { - setSettings((prevSettings) => { - const newSettings = { ...prevSettings } - if (!newSettings.petType) { - return prevSettings - } - - const pet = newSettings.petOptions[petType] - - const PER_SEC = 40 * 1000 - - if (pet?.hungryState?.lastHungerTick) { - const timeDiff = Date.now() - pet.hungryState.lastHungerTick - if (timeDiff < PER_SEC) { - return prevSettings - } - } - - if (pet && pet.hungryState?.level > 0) { - const hungerDecrease = 1 - pet.hungryState.level -= hungerDecrease - pet.hungryState.lastHungerTick = Date.now() - } - - setToStorage('pets', newSettings) - - return newSettings - }) - } - - const getPetHungryState = (petType: PetTypes) => { - const pet = settings.petOptions[petType] - if (pet) { - return pet.hungryState - } - return null - } - - const isPetHungry = (petType: PetTypes): boolean => { - const pet = settings.petOptions[petType] - if (pet.hungryState?.level > 0) { - return false - } - - return true - } - - const contextValue: PetSettingsContextType = { - ...settings, - isEnabled, - getCurrentPetName, - levelUpHungryState, - isPetHungry, - levelDownHungryState, - getPetHungryState, - setIsEnabled, - } - - return {children} -} - -export function usePetContext() { - const context = useContext(PetContext) - - if (!context) { - throw new Error('usePetContext must be used within a PetProvider') - } - - return context -} diff --git a/src/layouts/widgetify-card/pets/pet.tsx b/src/layouts/widgetify-card/pets/pet.tsx deleted file mode 100644 index 9e99b4679..000000000 --- a/src/layouts/widgetify-card/pets/pet.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { usePetContext } from './pet.context' -import { PetFactory } from './pet-factory' -import { useGeneralSetting } from '@/context/general-setting.context' - -export function Pet() { - const { isOptimalMode } = useGeneralSetting() - const { isEnabled } = usePetContext() - - if (!isEnabled || isOptimalMode) return null - - return -} diff --git a/src/layouts/widgetify-card/pets/setting/pet-setting.tsx b/src/layouts/widgetify-card/pets/setting/pet-setting.tsx deleted file mode 100644 index 79b3e1ff7..000000000 --- a/src/layouts/widgetify-card/pets/setting/pet-setting.tsx +++ /dev/null @@ -1,121 +0,0 @@ -import { useEffect, useState } from 'react' -import { getFromStorage } from '@/common/storage' -import { callEvent } from '@/common/utils/call-event' -import { ItemSelector } from '@/components/ui' -import { TextInput } from '@/components/text-input' -import { ToggleSwitch } from '@/components/ui' -import { BASE_PET_OPTIONS, PetTypes } from '@/layouts/widgetify-card/pets/pet.context' - -export function PetSettings() { - const [enablePets, setEnablePets] = useState(true) - const [petType, setPetType] = useState(PetTypes.DOG_AKITA) - const [petName, setPetName] = useState('') - - useEffect(() => { - async function load() { - const [storedPets, petState] = await Promise.all([ - getFromStorage('pets'), - getFromStorage('petState'), - ]) - if (storedPets?.petOptions) { - const type = storedPets.petType || PetTypes.DOG_AKITA - setPetType(type) - setPetName(storedPets.petOptions[type].name) - } - if (typeof petState === 'boolean') { - setEnablePets(petState) - } else { - setEnablePets(true) - } - } - - load() - }, []) - - async function onChangeEnablePets(value: boolean) { - callEvent('updatedPetState', value) - setEnablePets(value) - } - - async function onChangePetName(value: string) { - callEvent('updatedPetSettings', { - petName: value, - petType, - }) - setPetName(value) - } - - async function onChangePetType(value: PetTypes) { - const storedPets = await getFromStorage('pets') - if (storedPets?.petOptions[value]) { - setPetName(storedPets.petOptions[value].name) - } - setPetType(value) - - callEvent('updatedPetSettings', { - petType: value, - }) - } - const persianType: Record = { - dog: 'سگ', - chicken: 'مرغ', - crab: 'خرچنگ', - } - const availablePets = Object.entries(BASE_PET_OPTIONS.petOptions).map( - ([key, value]) => ({ - value: key as PetTypes, - label: `${value.emoji} ${persianType[value.type] || ''} - ${value.name}`, - }) - ) - - return ( -
-
-
- نمایش حیوان خانگی - - نمایش حیوان خانگی تعاملی روی صفحه اصلی - -
- onChangeEnablePets(!enablePets)} - /> -
- -
-

نوع حیوان خانگی

-
- {availablePets.map((pet) => ( - onChangePetType(pet.value)} - key={pet.value} - label={pet.label} - className="!w-full !h-12 !p-2.5 !text-sm text-center" - /> - ))} -
- -

نام حیوان خانگی

- onChangePetName(value)} - placeholder={'اسم دلخواه...'} - /> - -
-

- 💡 نکات مراقبت: -

-
    -
  • • برای بازی با حیوان خانگی، روی آن کلیک کنید
  • -
  • • برای غذا دادن به حیوان، در محیط اطراف کلیک کنید
  • -
-
-
-
- ) -} diff --git a/src/layouts/widgetify-card/pets/utils/sound-effects.ts b/src/layouts/widgetify-card/pets/utils/sound-effects.ts deleted file mode 100644 index f705756b1..000000000 --- a/src/layouts/widgetify-card/pets/utils/sound-effects.ts +++ /dev/null @@ -1,53 +0,0 @@ -let audioContext: AudioContext | null = null - -function getAudioContext(): AudioContext { - if (!audioContext) { - audioContext = new (window.AudioContext || (window as any).webkitAudioContext)() - } - return audioContext -} - -export function playFoodDropSound(): void { - try { - const ctx = getAudioContext() - const oscillator = ctx.createOscillator() - const gainNode = ctx.createGain() - - oscillator.connect(gainNode) - gainNode.connect(ctx.destination) - - oscillator.frequency.setValueAtTime(800, ctx.currentTime) - oscillator.frequency.exponentialRampToValueAtTime(400, ctx.currentTime + 0.2) - - gainNode.gain.setValueAtTime(0.1, ctx.currentTime) - gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.2) - - oscillator.start(ctx.currentTime) - oscillator.stop(ctx.currentTime + 0.2) - } catch (error) { - console.debug('Audio context not available:', error) - } -} - -export function playCollectSound(): void { - try { - const ctx = getAudioContext() - const oscillator = ctx.createOscillator() - const gainNode = ctx.createGain() - - oscillator.connect(gainNode) - gainNode.connect(ctx.destination) - - // Create a pleasant collect sound - oscillator.frequency.setValueAtTime(600, ctx.currentTime) - oscillator.frequency.exponentialRampToValueAtTime(1000, ctx.currentTime + 0.1) - - gainNode.gain.setValueAtTime(0.08, ctx.currentTime) - gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.1) - - oscillator.start(ctx.currentTime) - oscillator.stop(ctx.currentTime + 0.1) - } catch (error) { - console.debug('Audio context not available:', error) - } -} diff --git a/src/layouts/widgetify-card/widgetify.layout.tsx b/src/layouts/widgetify-card/widgetify.layout.tsx deleted file mode 100644 index 3eac98871..000000000 --- a/src/layouts/widgetify-card/widgetify.layout.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { WidgetContainer } from '../widgets/widget-container' -import { NotificationCenter } from './notification-center/notification-center' -import { Pet } from './pets/pet' -import { PetProvider } from './pets/pet.context' - -export const WidgetifyLayout = () => { - return ( - -
- { - - - - } - -
-
- -
-
-
-
- ) -} diff --git a/src/layouts/widgets-manager/add-widget-modal/actions.tsx b/src/layouts/widgets-manager/add-widget-modal/actions.tsx new file mode 100644 index 000000000..de706936d --- /dev/null +++ b/src/layouts/widgets-manager/add-widget-modal/actions.tsx @@ -0,0 +1,148 @@ +import { Button } from '@/components/ui' +import { callEvent } from '@/common/utils/call-event' +import type { WidgetSize } from '@/layouts/widgets/layout-engine/types' +import { Icon } from '@/icons' + +interface AddWidgetActionsProps { + isVipRequired: boolean + isVip: boolean + isEditMode: boolean + isLimitReached: boolean + canAddCustom: boolean + isCurrentlyActive: boolean + isDuplicateRestricted?: boolean + selectedSize: WidgetSize + isLoading?: boolean + onSave: () => void + onRemove: () => void +} + +function ProUpgradeButton({ label }: { label: string }) { + return ( + + ) +} + +function RemoveFromPageButton({ onRemove }: { onRemove: () => void }) { + return ( + + ) +} + +export function AddWidgetActions({ + isVipRequired, + isVip, + isEditMode, + isLimitReached, + canAddCustom, + isCurrentlyActive, + isDuplicateRestricted = false, + selectedSize, + isLoading = false, + onSave, + onRemove, +}: AddWidgetActionsProps) { + if (isVipRequired && !isVip) { + return ( + + ) + } + + if (isEditMode) { + return ( + + ) + } + + if (isCurrentlyActive && (isLimitReached || isDuplicateRestricted)) { + return ( +
+ + +
+ ) + } + + if (isLimitReached) { + return + } + + if (canAddCustom) { + return ( + + ) + } + + if (isCurrentlyActive) { + return + } + + return ( + + ) +} diff --git a/src/layouts/widgets-manager/add-widget-modal/index.tsx b/src/layouts/widgets-manager/add-widget-modal/index.tsx new file mode 100644 index 000000000..2a79b5a40 --- /dev/null +++ b/src/layouts/widgets-manager/add-widget-modal/index.tsx @@ -0,0 +1,341 @@ +import type React from 'react' +import { useEffect, useMemo, useState } from 'react' +import { Button, Modal } from '@/components/ui' +import { callEvent } from '@/common/utils/call-event' +import { useAuth } from '@/context/auth.context' +import { useOptionalFreeWidgets } from '@/context/free-widget/free-widget.context' +import type { + WidgetCategory, + WidgetSize, + WidgetVariantOption, +} from '@/layouts/widgets/layout-engine/types' +import { WIDGET_DEFINITIONS } from '@/layouts/widgets/widget-registry' +import { Icon } from '@/icons' +import type { WidgetTabKeys } from '@/layouts/widgets-settings/tab-keys' +import { WidgetHelpModal } from '../widget-help.modal' +import { useWidgetVipResolver } from '@/services/hooks/widgets/widget-catalog.hook' +import type { AddWidgetModalProps } from './types' +import { AddWidgetSidebar } from './sidebar' +import { AddWidgetOptions } from './options' +import { AddWidgetPreview } from './preview' +import { AddWidgetActions } from './actions' + +export function AddWidgetModal({ isOpen, editTarget, onClose }: AddWidgetModalProps) { + const { isVip } = useAuth() + const { isWidgetVipOnly, isVariantVipOnly, isSizeVipOnly, maxFreeWidgets } = + useWidgetVipResolver(isOpen) + const freeWidgets = useOptionalFreeWidgets() + + const runtimeLayout = freeWidgets?.runtimeLayout || [] + const addWidget = freeWidgets?.addWidget + const updateWidgetVariant = freeWidgets?.updateWidgetVariant + const removeWidget = freeWidgets?.removeWidget + + const allDefinitions = useMemo(() => { + return Object.values(WIDGET_DEFINITIONS) + }, []) + + const [selectedId, setSelectedId] = useState(allDefinitions[0]?.id || '') + + const selectedDef = + WIDGET_DEFINITIONS[selectedId as keyof typeof WIDGET_DEFINITIONS] || + allDefinitions[0] + + const [selectedSize, setSelectedSize] = useState( + selectedDef?.defaultSize || { w: 2, h: 2 } + ) + const [selectedVariant, setSelectedVariant] = useState( + selectedDef?.variants?.[0] || null + ) + const [activeCategory, setActiveCategory] = useState('all') + const [isHelpOpen, setIsHelpOpen] = useState(false) + const [isLoading, setIsLoading] = useState(false) + + useEffect(() => { + if (!isOpen) { + setIsLoading(false) + } + }, [isOpen]) + + useEffect(() => { + if (editTarget) { + const def = + WIDGET_DEFINITIONS[editTarget.widgetId as keyof typeof WIDGET_DEFINITIONS] + if (def) { + setSelectedId(def.id) + const currentWidget = runtimeLayout.find( + (w) => w.instanceId === editTarget.instanceId + ) + if (def.variants && def.variants.length > 0) { + const match = + def.variants.find((v) => { + if (currentWidget?.meta?.variant) { + return v.meta?.variant === currentWidget.meta.variant + } + return ( + v.size.w === currentWidget?.size.w && + v.size.h === currentWidget?.size.h + ) + }) || def.variants[0] + setSelectedVariant(match) + setSelectedSize(match.size) + } else { + setSelectedVariant(null) + setSelectedSize(currentWidget?.size || def.defaultSize) + } + } + } + }, [editTarget, runtimeLayout]) + + const isEditMode = Boolean(editTarget) + + const handleCategoryChange = (categoryId: WidgetCategory) => { + setActiveCategory(categoryId) + } + + const handleSelectWidget = (id: string) => { + if (isEditMode) return + setSelectedId(id) + const def = WIDGET_DEFINITIONS[id as keyof typeof WIDGET_DEFINITIONS] + if (def) { + if (def.variants && def.variants.length > 0) { + setSelectedVariant(def.variants[0]) + setSelectedSize(def.variants[0].size) + } else { + setSelectedVariant(null) + setSelectedSize(def.defaultSize) + } + } + } + + const handleOpenWidgetSettings = ( + e: React.MouseEvent, + settingsTab?: WidgetTabKeys + ) => { + e.stopPropagation() + if (settingsTab) { + callEvent('openWidgetsSettings', { tab: settingsTab }) + } + } + + const handleOpenSelectedSettings = () => { + if (selectedDef?.settingsTab) { + callEvent('openWidgetsSettings', { tab: selectedDef.settingsTab }) + } + } + + const handleVariantChange = (variant: WidgetVariantOption) => { + setSelectedVariant(variant) + setSelectedSize(variant.size) + } + + const handleSizeChange = (sizeOption: WidgetSize) => { + setSelectedVariant(null) + setSelectedSize(sizeOption) + } + + const activeCount = runtimeLayout.filter((w) => w.id === selectedDef?.id).length + + const isCurrentlyActive = activeCount > 0 + const isDuplicateRestricted = + Boolean(selectedDef?.canDuplicate) && isCurrentlyActive && !isVip + + const canAddCustom = isVip + ? Boolean(selectedDef?.canDuplicate || !isCurrentlyActive) + : !isCurrentlyActive + + const isCurrentWidgetVipOnly = isWidgetVipOnly(selectedDef?.id) + const isCurrentVariantVipOnly = isVariantVipOnly(selectedDef?.id, selectedVariant?.id) + const isCurrentSizeVipOnly = + !selectedVariant && isSizeVipOnly(selectedDef?.id, selectedSize) + const isVipRequired = + isCurrentWidgetVipOnly || isCurrentVariantVipOnly || isCurrentSizeVipOnly + + const isLimitReached = !isVip && runtimeLayout.length >= maxFreeWidgets + + const handleRemove = () => { + if (!selectedDef || !removeWidget) return + const target = runtimeLayout.find((w) => w.id === selectedDef.id) + if (!target) return + removeWidget(target.instanceId) + } + + const handleSave = async () => { + if (!selectedDef || isLoading) return + + if (isVipRequired && !isVip) { + callEvent('openSettings', 'vip') + return + } + + if (isEditMode && editTarget && updateWidgetVariant) { + const success = updateWidgetVariant( + editTarget.instanceId, + selectedSize, + selectedVariant?.meta + ) + if (success) { + onClose() + } + return + } + + if (isLimitReached) { + callEvent('openSettings', 'vip') + return + } + + if (!canAddCustom || !addWidget) return + + setIsLoading(true) + try { + const success = await addWidget( + selectedDef.id, + undefined, + selectedSize, + selectedVariant?.meta + ) + if (success) { + onClose() + } + } finally { + setIsLoading(false) + } + } + + const filteredDefinitions = useMemo(() => { + if (activeCategory === 'all') return allDefinitions + return allDefinitions.filter((def) => def.category === activeCategory) + }, [allDefinitions, activeCategory]) + + const previewSize = selectedSize + + return ( + <> + + + {editTarget + ? 'تغییر مدل و استایل ویجت' + : 'مدیریت و افزودن ویجت‌ها'} + + +
+ } + size="xl" + direction="rtl" + closeOnBackdropClick + className="max-w-4xl md:max-w-5xl" + > +
+ + +
+ {selectedDef ? ( +
+
+
+
+ + {selectedDef.emoji} + +
+

+ {selectedDef.label} +

+

+ {selectedDef.canDuplicate + ? 'امکان افزودن چندین نمونه از این ویجت وجود دارد' + : 'ویجت تکی صفحه اصلی'} +

+
+
+ + {selectedDef.settingsTab && ( + + )} +
+ + + + +
+ +
+ +
+
+ ) : null} +
+
+ + + {isHelpOpen && ( + setIsHelpOpen(false)} + /> + )} + + ) +} + +export type { AddWidgetModalProps } from './types' diff --git a/src/layouts/widgets-manager/add-widget-modal/options.tsx b/src/layouts/widgets-manager/add-widget-modal/options.tsx new file mode 100644 index 000000000..f288713ea --- /dev/null +++ b/src/layouts/widgets-manager/add-widget-modal/options.tsx @@ -0,0 +1,115 @@ +import { Chip, VipBadge } from '@/components/ui' +import { cn } from '@/common/utils/cn' +import type { + WidgetDefinition, + WidgetSize, + WidgetVariantOption, +} from '@/layouts/widgets/layout-engine/types' + +interface AddWidgetOptionsProps { + definition: WidgetDefinition + selectedSize: WidgetSize + selectedVariant: WidgetVariantOption | null + isVip?: boolean + onSelectSize: (size: WidgetSize) => void + onSelectVariant: (variant: WidgetVariantOption) => void + isVariantVipOnly: (widgetKey: string, variantId?: string) => boolean + isSizeVipOnly: (widgetKey: string, size?: WidgetSize) => boolean +} + +export function AddWidgetOptions({ + definition, + selectedSize, + selectedVariant, + isVip = false, + onSelectSize, + onSelectVariant, + isVariantVipOnly, + isSizeVipOnly, +}: AddWidgetOptionsProps) { + if (definition.variants && definition.variants.length > 0) { + return ( +
+ + انتخاب مدل و استایل: + +
+ {definition.variants.map((variant) => { + const isCurrent = + selectedVariant?.id === variant.id || + (!selectedVariant && + selectedSize.w === variant.size.w && + selectedSize.h === variant.size.h) + const isVipBadge = + !isVip && isVariantVipOnly(definition.id, variant.id) + + return ( + + ) + })} +
+
+ ) + } + + return ( +
+ انتخاب اندازه ویجت: +
+ {definition.allowedSizes.map((sizeOption) => { + const isCurrentSize = + selectedSize.w === sizeOption.w && selectedSize.h === sizeOption.h + const isDefault = + definition.defaultSize.w === sizeOption.w && + definition.defaultSize.h === sizeOption.h + const isVipBadge = !isVip && isSizeVipOnly(definition.id, sizeOption) + + return ( + onSelectSize(sizeOption)} + key={`${sizeOption.w}x${sizeOption.h}`} + className={cn( + 'py-1 flex items-center gap-1', + isVipBadge && !isCurrentSize && 'border-vip/30' + )} + selected={isCurrentSize} + > + + {sizeOption.w} × {sizeOption.h} + + {isVipBadge && ( + + )} + {isDefault && !isCurrentSize && !isVipBadge && ( + + (پیش‌فرض) + + )} + + ) + })} +
+
+ ) +} diff --git a/src/layouts/widgets-manager/add-widget-modal/preview.tsx b/src/layouts/widgets-manager/add-widget-modal/preview.tsx new file mode 100644 index 000000000..9f366830b --- /dev/null +++ b/src/layouts/widgets-manager/add-widget-modal/preview.tsx @@ -0,0 +1,65 @@ +import type { + WidgetDefinition, + WidgetSize, + WidgetVariantOption, +} from '@/layouts/widgets/layout-engine/types' + +interface AddWidgetPreviewProps { + definition: WidgetDefinition + previewSize: WidgetSize + selectedVariant: WidgetVariantOption | null +} + +export function AddWidgetPreview({ + definition, + previewSize, + selectedVariant, +}: AddWidgetPreviewProps) { + const getPreviewDimensions = (size: WidgetSize) => { + const cellW = 125 + const cellH = 96 + const gap = 8 + + const width = size.w * cellW + (size.w - 1) * gap + const height = size.h * cellH + (size.h - 1) * gap + + const maxPreviewW = 540 + const maxPreviewH = 260 + const scale = Math.min(1, maxPreviewW / width, maxPreviewH / height) + + return { + width: `${width}px`, + height: `${height}px`, + transform: scale < 1 ? `scale(${scale})` : undefined, + transformOrigin: 'center center', + } + } + + return ( +
+
+ پیش‌نمایش در اندازه {previewSize.w}×{previewSize.h} +
+ +
+
+ {definition.node( + 'preview-sample', + previewSize, + selectedVariant?.meta + )} +
+
+
+ ) +} diff --git a/src/layouts/widgets-manager/add-widget-modal/sidebar.tsx b/src/layouts/widgets-manager/add-widget-modal/sidebar.tsx new file mode 100644 index 000000000..5ea701271 --- /dev/null +++ b/src/layouts/widgets-manager/add-widget-modal/sidebar.tsx @@ -0,0 +1,130 @@ +import type React from 'react' +import { Button, VipBadge } from '@/components/ui' +import { cn } from '@/common/utils/cn' +import type { + WidgetCategory, + WidgetDefinition, +} from '@/layouts/widgets/layout-engine/types' +import type { WidgetTabKeys } from '@/layouts/widgets-settings/tab-keys' +import { Icon } from '@/icons' +import { CATEGORIES } from './types' + +interface AddWidgetSidebarProps { + activeCategory: WidgetCategory + onSelectCategory: (category: WidgetCategory) => void + definitions: WidgetDefinition[] + selectedId: string + onSelectWidget: (id: string) => void + runtimeLayout: { id: string }[] + isVip?: boolean + isWidgetVipOnly: (id: string) => boolean + onOpenWidgetSettings: (e: React.MouseEvent, settingsTab?: WidgetTabKeys) => void +} + +export function AddWidgetSidebar({ + activeCategory, + onSelectCategory, + definitions, + selectedId, + onSelectWidget, + runtimeLayout, + isVip = false, + isWidgetVipOnly, + onOpenWidgetSettings, +}: AddWidgetSidebarProps) { + return ( +
+
+ {CATEGORIES.map((cat) => ( + + ))} +
+ +
+ {definitions.map((def) => { + const isSelected = def.id === selectedId + const count = runtimeLayout.filter((w) => w.id === def.id).length + const isActive = count > 0 + + return ( +
onSelectWidget(def.id)} + className={cn( + 'w-full flex items-center justify-between p-2.5 rounded-2xl border text-right transition-all duration-150 cursor-pointer', + isSelected + ? 'bg-primary/10 border-primary shadow-xs' + : 'bg-base-200/60 hover:bg-base-200 border-base-content/10' + )} + > +
+ {def.emoji} + + {def.label} + +
+ +
+ {!isVip && isWidgetVipOnly(def.id) && ( + + )} + {def.settingsTab && ( + + )} + {def.canDuplicate ? ( + + + {isActive ? `${count} فعال` : 'تکرار'} + + + ) : isActive ? ( + + فعال + + ) : null} +
+
+ ) + })} +
+
+ ) +} diff --git a/src/layouts/widgets-manager/add-widget-modal/types.ts b/src/layouts/widgets-manager/add-widget-modal/types.ts new file mode 100644 index 000000000..40c68fe49 --- /dev/null +++ b/src/layouts/widgets-manager/add-widget-modal/types.ts @@ -0,0 +1,23 @@ +import type { WidgetCategory } from '@/layouts/widgets/layout-engine/types' + +export interface AddWidgetModalProps { + isOpen: boolean + editTarget?: { + instanceId: string + widgetId: string + } | null + onClose: () => void +} + +export interface CategoryItem { + id: WidgetCategory + label: string +} + +export const CATEGORIES: CategoryItem[] = [ + { id: 'all', label: 'همه' }, + { id: 'time', label: 'زمان و تاریخ' }, + { id: 'productivity', label: 'ابزار و تسک' }, + { id: 'info', label: 'اطلاعات و رسانه' }, + { id: 'lifestyle', label: 'سرگرمی' }, +] diff --git a/src/layouts/widgets-manager/index.ts b/src/layouts/widgets-manager/index.ts new file mode 100644 index 000000000..58488271f --- /dev/null +++ b/src/layouts/widgets-manager/index.ts @@ -0,0 +1,3 @@ +export { AddWidgetModal } from './add-widget-modal/index' +export type { AddWidgetModalProps } from './add-widget-modal/types' +export { WidgetHelpModal } from './widget-help.modal' diff --git a/src/layouts/widgets-manager/widget-help.modal.tsx b/src/layouts/widgets-manager/widget-help.modal.tsx new file mode 100644 index 000000000..bb0f67d9c --- /dev/null +++ b/src/layouts/widgets-manager/widget-help.modal.tsx @@ -0,0 +1,234 @@ +import { memo, useState } from 'react' +import { Modal, Button, Chip } from '@/components/ui' +import { Icon } from '@/icons' +import { cn } from '@/common/utils/cn' + +interface WidgetHelpModalProps { + isOpen: boolean + onClose: () => void +} + +type TabType = 'move' | 'resize' | 'advanced' + +const TABS: { + id: TabType + label: string + icon: 'outlineDrag' | 'maximize' | 'settings' +}[] = [ + { id: 'move', label: 'جابه‌جایی و چیدمان', icon: 'outlineDrag' }, + { id: 'resize', label: 'تغییر اندازه', icon: 'maximize' }, + { id: 'advanced', label: 'مدل‌ها و تنظیمات', icon: 'settings' }, +] + +function WidgetHelpModalComponent({ isOpen, onClose }: WidgetHelpModalProps) { + const [activeTab, setActiveTab] = useState('move') + + return ( + +
+
+ {TABS.map((tab) => ( + + ))} +
+ + {activeTab === 'move' && ( +
+
+
+ +
+
+ + ۱ + +

+ روی فضای خالی صفحه کلیک راست کن و «ویرایش ویجت‌ها» رو + بزن +

+
+ +
+ + ۲ + +

+ وقتی صفحه وارد حالت ویرایش شد، ویجت‌ها رو با درگ کردن + به خانه و موقعیت دلخواهت ببر +

+
+ +
+ + ۳ + +

+ در آخر دکمه «پایان» نوار پایین صفحه رو بزن تا چیدمان + قفل و ذخیره بشه +

+
+
+
+ )} + + {activeTab === 'resize' && ( +
+
+
+
+ 📆 + تقویم +
+
+ + تغییر اندازه + +
+ {}} + selected + className="py-0.5 text-[10px]" + > + 1 × 1 + + {}} + className="py-0.5 text-[10px]" + > + 2 × 1 + + {}} + className="py-0.5 text-[10px]" + > + 2 × 3 + +
+
+
+ +
+
+ + ۱ + +

+ روی ویجت موردنظرت کلیک راست کن +

+
+ +
+ + ۲ + +

+ از بخش «تغییر اندازه» منو، سایز دلخواهت رو انتخاب کن + تا ویجت بلافاصله تغییر شکل بده +

+
+
+
+ )} + + {activeTab === 'advanced' && ( +
+
+
+
+ 📝 + یادداشت +
+
+
+ + تغییر مدل و استایل +
+
+ + تنظیمات +
+
+
+ +
+
+ + ۱ + +

+ برای ویجت‌های چنداستایله (مثل استیک نوت و ساعت)، روی + ویجت کلیک راست کن و «تغییر مدل و استایل» رو بزن +

+
+ +
+ + ۲ + +

+ توی مودال پیشرفته، مدل، تم و سایز دلخواهت رو انتخاب و + ذخیره کن +

+
+ +
+ + ۳ + +

+ برای تنظیمات اختصاصی ویجت‌ها (مثل شهر آب و هوا یا + ارزها)، گزینه «تنظیمات» رو انتخاب کن +

+
+
+
+ )} + +
+ +
+
+ + ) +} + +export const WidgetHelpModal = memo(WidgetHelpModalComponent) diff --git a/src/layouts/widgets-settings/manage-widgets/manage-widgets.tsx b/src/layouts/widgets-settings/manage-widgets/manage-widgets.tsx deleted file mode 100644 index cee222464..000000000 --- a/src/layouts/widgets-settings/manage-widgets/manage-widgets.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { SectionPanel } from '@/components/ui' -import { useAuth } from '@/context/auth.context' -import { - MAX_VISIBLE_WIDGETS, - useWidgetVisibility, - type WidgetItem, - type WidgetKeys, - widgetItems, -} from '@/context/widget-visibility.context' -import { ItemSelector } from '@/components/ui' -import { WidgetSettingWrapper } from '../widget-settings-wrapper' -import { UI, useAppearanceSetting } from '@/context/appearance.context' - -export function ManageWidgets() { - const { isAuthenticated } = useAuth() - const { ui } = useAppearanceSetting() - const { visibility, toggleWidget } = useWidgetVisibility() - return ( - - {ui === UI.SIMPLE && ( -
- در حالت ظاهری ساده، امکان مدیریت ویجت ها نیست! -
- )} - -
- {widgetItems.map((widget) => ( - - ))} -
-
-
- ) -} - -interface WidgetItemComponentProps { - widget: WidgetItem - visibility: string[] - toggleWidget: (widgetId: WidgetKeys) => void - isAuthenticated: boolean -} - -function WidgetItemComponent({ - widget, - visibility, - toggleWidget, - isAuthenticated, -}: WidgetItemComponentProps) { - const isActive = visibility.includes(widget.id) - const canToggle = - isAuthenticated || isActive || visibility.length < MAX_VISIBLE_WIDGETS - - const isDisabled = widget.disabled || false - const isSoon = widget.soon || false - const isBeta = widget.isBeta || false - - const finalCanToggle = canToggle && !isDisabled - - let extraClasses = '' - if (isDisabled) { - extraClasses += ' border-error/50 bg-error/10' - } - if (isSoon) { - extraClasses += 'border border-warning/50' - } - - return ( - finalCanToggle && toggleWidget(widget.id)} - label={ -
- - {widget.emoji} {widget.label} - -
- {widget.isNew && ( - - جدید - - )} - {widget.popular && ( - - محبوب - - )} - {isDisabled && ( - غیرفعال - )} - {isSoon && ( - به زودی - )} - {isBeta && ( - آزمایشی - )} -
-
- } - /> - ) -} diff --git a/src/layouts/widgets-settings/constant/tab-keys.ts b/src/layouts/widgets-settings/tab-keys.ts similarity index 84% rename from src/layouts/widgets-settings/constant/tab-keys.ts rename to src/layouts/widgets-settings/tab-keys.ts index debde2632..9593a1715 100644 --- a/src/layouts/widgets-settings/constant/tab-keys.ts +++ b/src/layouts/widgets-settings/tab-keys.ts @@ -1,8 +1,8 @@ export enum WidgetTabKeys { widget_management = 'widget_management', - wigiPad = 'wigiPad', wigiArz = 'wigiArz', news_settings = 'news_settings', weather_settings = 'weather_settings', + combo_settings = 'combo_settings', Pet = 'pet_settings', } diff --git a/src/layouts/widgets-settings/widget-settings-modal.tsx b/src/layouts/widgets-settings/widget-settings-modal.tsx index f4a4fcb30..966efde21 100644 --- a/src/layouts/widgets-settings/widget-settings-modal.tsx +++ b/src/layouts/widgets-settings/widget-settings-modal.tsx @@ -1,101 +1,86 @@ -import Analytics from '@/analytics' -import { callEvent } from '@/common/utils/call-event' +import type React from 'react' import { Modal } from '@/components/ui' -import { type TabItem, TabManager } from '@/components/tab-manager' -import { PetSettings } from '../widgetify-card/pets/setting/pet-setting' +import { PetSettings } from '../widgets/pet/pet-setting' import { RssFeedSetting } from '../widgets/news/rss-feed-setting' import { WeatherSetting } from '../widgets/weather/weather-setting' import { WigiArzSetting } from '../widgets/wigi-arz/wigi-arz-setting' -import { WigiPadSetting } from '../widgets/wigi-pad/wigi-pad-setting' -import { WidgetTabKeys } from './constant/tab-keys' -import { ManageWidgets } from './manage-widgets/manage-widgets' -import { Icon } from '@/src/icons' +import { ComboSetting } from '../widgets/combo-widget/combo-setting' +import { WidgetTabKeys } from './tab-keys' + +interface WidgetSettingModalConfig { + title: string + size: 'sm' | 'md' | 'lg' | 'xl' + Component: React.ComponentType +} + +const WIDGET_SETTING_MODALS: Record = { + [WidgetTabKeys.Pet]: { + title: 'تنظیمات حیوان خانگی', + size: 'lg', + Component: PetSettings, + }, + [WidgetTabKeys.weather_settings]: { + title: 'تنظیمات آب و هوا', + size: 'lg', + Component: WeatherSetting, + }, + [WidgetTabKeys.wigiArz]: { + title: 'تنظیمات ویجی ارز', + size: 'lg', + Component: WigiArzSetting, + }, + [WidgetTabKeys.news_settings]: { + title: 'تنظیمات ویجی نیوز', + size: 'lg', + Component: RssFeedSetting, + }, + [WidgetTabKeys.combo_settings]: { + title: 'تنظیمات ویجت ترکیبی', + size: 'lg', + Component: ComboSetting, + }, +} interface WidgetSettingsModalProps { isOpen: boolean onClose: () => void - selectedTab: string | null + selectedTab: WidgetTabKeys | null + activeSettingTab?: WidgetTabKeys | null + instanceId?: string + size?: { w: number; h: number } + onCloseSetting?: () => void } -const tabs: TabItem[] = [ - { - parentName: 'مدیریت ویجت‌ها', - children: [ - { - label: 'مدیریت ویجت ها', - element: , - value: WidgetTabKeys.widget_management, - icon: , - }, - { - label: 'ویجی پد', - element: , - value: WidgetTabKeys.wigiPad, - icon: , - }, - { - label: 'ویجی ارز', - element: , - value: WidgetTabKeys.wigiArz, - icon: , - }, - { - label: 'ویجی نیوز', - element: , - value: WidgetTabKeys.news_settings, - icon: , - }, - { - label: 'ویجت آب و هوا', - element: , - value: WidgetTabKeys.weather_settings, - icon: , - }, - { - label: 'حیوان خانگی', - value: WidgetTabKeys.Pet, - icon: , - element: , - }, - ], - }, -] export function WidgetSettingsModal({ isOpen, onClose, selectedTab, + activeSettingTab, + instanceId, + size, + onCloseSetting, }: WidgetSettingsModalProps) { - function onClickSettings() { - onClose() - callEvent('openSettings') - Analytics.event('open_settings_from_widgets_settings_modal') - } + const settingKey = + activeSettingTab || + (selectedTab && selectedTab !== WidgetTabKeys.widget_management + ? selectedTab + : null) + + const activeSettingConfig = settingKey ? WIDGET_SETTING_MODALS[settingKey] : null + const handleCloseSetting = onCloseSetting || onClose return ( - - - + {activeSettingConfig && ( + + )} ) } diff --git a/src/layouts/widgets/__tests__/instance-id.test.ts b/src/layouts/widgets/__tests__/instance-id.test.ts new file mode 100644 index 000000000..10d7c15ba --- /dev/null +++ b/src/layouts/widgets/__tests__/instance-id.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from 'bun:test' +import { + applyInstanceIdMap, + buildInstanceIdMap, + dedupeInstanceIds, + isServerInstanceId, +} from '../instance-id' +import { resolveLayoutChange } from '../layout-engine' +import type { StoredWidget } from '../layout-engine/types' +import { WidgetKeys } from '../layout-engine/types' + +function widget( + instanceId: string, + id: WidgetKeys = WidgetKeys.clock, + col = 0, + row = 0 +): StoredWidget { + return { id, instanceId, position: { col, row }, size: { w: 2, h: 1 } } +} + +const SERVER_A = 'aaaaaaaaaaaaaaaaaaaaaaaa' +const SERVER_B = 'bbbbbbbbbbbbbbbbbbbbbbbb' + +describe('isServerInstanceId', () => { + it('accepts only 24-char hex ids', () => { + expect(isServerInstanceId(SERVER_A)).toBe(true) + expect(isServerInstanceId('clock-abc123')).toBe(false) + expect(isServerInstanceId('')).toBe(false) + expect(isServerInstanceId(undefined)).toBe(false) + }) +}) + +describe('buildInstanceIdMap', () => { + it('never assigns a server id already held by another widget', () => { + const layout = [ + widget(SERVER_A, WidgetKeys.clock), + widget('clock-local', WidgetKeys.clock), + ] + const synced = [ + { instanceId: SERVER_A, widgetKey: WidgetKeys.clock }, + { instanceId: SERVER_B, widgetKey: WidgetKeys.clock }, + ] + + const idMap = buildInstanceIdMap(layout, synced) + + expect(idMap.get('clock-local')).toBe(SERVER_B) + + const updated = applyInstanceIdMap(layout, idMap) + const ids = updated.map((w) => w.instanceId) + expect(new Set(ids).size).toBe(ids.length) + }) + + it('does not map two local widgets onto the same server id', () => { + const layout = [widget('clock-1', WidgetKeys.clock), widget('clock-2', WidgetKeys.clock)] + const synced = [{ instanceId: SERVER_A, widgetKey: WidgetKeys.clock }] + + const idMap = buildInstanceIdMap(layout, synced) + + expect(idMap.size).toBe(1) + const updated = applyInstanceIdMap(layout, idMap) + const ids = updated.map((w) => w.instanceId) + expect(new Set(ids).size).toBe(ids.length) + }) + + it('leaves widgets untouched when there is nothing to map', () => { + const layout = [widget(SERVER_A), widget(SERVER_B)] + const synced = [ + { instanceId: SERVER_A, widgetKey: WidgetKeys.clock }, + { instanceId: SERVER_B, widgetKey: WidgetKeys.clock }, + ] + + expect(buildInstanceIdMap(layout, synced).size).toBe(0) + expect(applyInstanceIdMap(layout, new Map())).toBe(layout) + }) + + it('does not borrow a server id belonging to a different widget type', () => { + const layout = [widget('search-local', WidgetKeys.search)] + const synced = [{ instanceId: SERVER_A, widgetKey: WidgetKeys.clock }] + + expect(buildInstanceIdMap(layout, synced).size).toBe(0) + }) +}) + +describe('dedupeInstanceIds', () => { + it('repairs a layout where a duplicate shares the original instance id', () => { + const layout = [ + widget(SERVER_A, WidgetKeys.clock, 0, 0), + widget(SERVER_A, WidgetKeys.clock, 2, 0), + ] + + const repaired = dedupeInstanceIds(layout) + const ids = repaired.map((w) => w.instanceId) + + expect(new Set(ids).size).toBe(2) + expect(repaired[0].instanceId).toBe(SERVER_A) + expect(repaired[1].instanceId).not.toBe(SERVER_A) + expect(repaired[1].widgetId).toBe(repaired[1].instanceId) + }) + + it('returns the same array when every id is unique', () => { + const layout = [widget(SERVER_A), widget(SERVER_B)] + expect(dedupeInstanceIds(layout)).toBe(layout) + }) +}) + +describe('duplicate operation', () => { + it('keeps the server issued instance id instead of inventing one', () => { + const layout = [widget(SERVER_A, WidgetKeys.clock)] + + const result = resolveLayoutChange({ + layout, + operation: 'duplicate', + instanceId: SERVER_A, + newWidget: { + id: WidgetKeys.clock, + instanceId: SERVER_B, + widgetId: SERVER_B, + position: { col: 0, row: 0 }, + size: { w: 2, h: 1 }, + }, + cols: 8, + }) + + expect(result).not.toBeNull() + expect(result!.map((w) => w.instanceId).sort()).toEqual( + [SERVER_A, SERVER_B].sort() + ) + }) + + it('removing the duplicate leaves the original in place', () => { + const layout = [widget(SERVER_A, WidgetKeys.clock)] + + const duplicated = resolveLayoutChange({ + layout, + operation: 'duplicate', + instanceId: SERVER_A, + newWidget: { + id: WidgetKeys.clock, + instanceId: SERVER_B, + widgetId: SERVER_B, + position: { col: 0, row: 0 }, + size: { w: 2, h: 1 }, + }, + cols: 8, + })! + + const afterRemove = resolveLayoutChange({ + layout: duplicated, + operation: 'remove', + instanceId: SERVER_B, + cols: 8, + }) + + expect(afterRemove).not.toBeNull() + expect(afterRemove!.length).toBe(1) + expect(afterRemove![0].instanceId).toBe(SERVER_A) + }) + + it('rejects a duplicate whose instance id is already taken', () => { + const layout = [widget(SERVER_A, WidgetKeys.clock)] + + const result = resolveLayoutChange({ + layout, + operation: 'duplicate', + instanceId: SERVER_A, + newWidget: { + id: WidgetKeys.clock, + instanceId: SERVER_A, + widgetId: SERVER_A, + position: { col: 0, row: 0 }, + size: { w: 2, h: 1 }, + }, + cols: 8, + }) + + expect(result).toBeNull() + }) + + it('still generates an id when no widget is supplied', () => { + const layout = [widget(SERVER_A, WidgetKeys.clock)] + + const result = resolveLayoutChange({ + layout, + operation: 'duplicate', + instanceId: SERVER_A, + cols: 8, + }) + + expect(result).not.toBeNull() + expect(result!.length).toBe(2) + expect(result![1].instanceId).not.toBe(SERVER_A) + }) +}) diff --git a/src/layouts/widgets/__tests__/migration.test.ts b/src/layouts/widgets/__tests__/migration.test.ts new file mode 100644 index 000000000..8d52efc71 --- /dev/null +++ b/src/layouts/widgets/__tests__/migration.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'bun:test' +import { validateLayout } from '../layout-engine/validation' +import { type StoredWidget, WidgetKeys } from '../layout-engine/types' + +// @ts-expect-error +const { mock, beforeEach } = await import('bun:test') + +const storageMockData: Record = {} + +mock.module('@/common/storage', () => ({ + getFromStorage: async (key: string) => storageMockData[key] ?? null, + getMultipleFromStorage: async (keys: string[]) => { + const result: Record = {} + for (const k of keys) { + if (k in storageMockData) { + result[k] = storageMockData[k] + } + } + return result + }, + setToStorage: async (key: string, value: any) => { + storageMockData[key] = value + }, +})) + +const { migrateWidgetLayoutIfNeeded } = await import('../migration') + +describe('migrateWidgetLayoutIfNeeded', () => { + beforeEach(() => { + for (const key of Object.keys(storageMockData)) { + delete storageMockData[key] + } + }) + + it('returns existing storedWidgets directly without modification', async () => { + const existing: StoredWidget[] = [ + { + id: WidgetKeys.clock, + instanceId: 'custom-clock', + position: { col: 0, row: 0 }, + size: { w: 2, h: 1 }, + }, + ] + storageMockData.storedWidgets = existing + + const result = await migrateWidgetLayoutIfNeeded() + + expect(result).toEqual(existing) + expect(storageMockData.storedWidgets).toEqual(existing) + }) + + it('migrates Simple UI mode into a valid, non-overlapping custom layout', async () => { + storageMockData.appearance = { ui: 'SIMPLE' } + + const result = await migrateWidgetLayoutIfNeeded() + + expect(result.length).toBeGreaterThan(0) + expect(validateLayout(result, 8)).toBe(true) + + const ids = result.map((w) => w.id) + expect(ids).toContain(WidgetKeys.search) + expect(ids).toContain(WidgetKeys.clock) + expect(ids).toContain(WidgetKeys.moodTracker) + expect(ids).toContain(WidgetKeys.calendar) + expect(ids).toContain(WidgetKeys.HabitTracker) + expect(ids).toContain(WidgetKeys.bookmarks) + expect(ids).toContain(WidgetKeys.yadKar) + + expect(storageMockData.appearance.ui).toBe('CUSTOM') + expect(storageMockData.storedWidgets).toEqual(result) + }) + + it('migrates classic layout using activeWidgets with custom order', async () => { + storageMockData.appearance = { ui: 'CLASSIC' } + storageMockData.activeWidgets = [ + { id: WidgetKeys.todos, order: 2 }, + { id: WidgetKeys.calendar, order: 1 }, + { id: 'unknown-widget', order: 0 }, + ] + + const result = await migrateWidgetLayoutIfNeeded() + + expect(validateLayout(result, 8)).toBe(true) + + const topWidgetKeys = [ + WidgetKeys.clock, + WidgetKeys.moodTracker, + WidgetKeys.weather, + WidgetKeys.search, + WidgetKeys.bookmarks, + WidgetKeys.photo, + WidgetKeys.pet, + ] + for (const key of topWidgetKeys) { + expect(result.some((w) => w.id === key)).toBe(true) + } + + const bottomWidgets = result.filter((w) => !topWidgetKeys.includes(w.id)) + expect(bottomWidgets.map((w) => w.id)).toEqual([ + WidgetKeys.calendar, + WidgetKeys.todos, + ]) + + expect(storageMockData.appearance.ui).toBe('CUSTOM') + }) + + it('migrates classic layout using default bottom widgets when activeWidgets is empty', async () => { + storageMockData.appearance = { ui: 'CLASSIC' } + storageMockData.activeWidgets = [] + + const result = await migrateWidgetLayoutIfNeeded() + + expect(validateLayout(result, 8)).toBe(true) + + const bottomWidgetIds = result.filter((w) => w.position.row >= 3).map((w) => w.id) + + expect(bottomWidgetIds).toContain(WidgetKeys.comboWidget) + expect(bottomWidgetIds).toContain(WidgetKeys.yadKar) + expect(bottomWidgetIds).toContain(WidgetKeys.tools) + expect(bottomWidgetIds).toContain(WidgetKeys.calendar) + }) + + it('ensures each migrated widget has unique instanceId and valid in-bounds position', async () => { + storageMockData.appearance = { ui: 'CLASSIC' } + + const result = await migrateWidgetLayoutIfNeeded() + + const instanceIds = result.map((w) => w.instanceId) + expect(new Set(instanceIds).size).toBe(instanceIds.length) + + for (const widget of result) { + expect(widget.position.col).toBeGreaterThanOrEqual(0) + expect(widget.position.col + widget.size.w).toBeLessThanOrEqual(8) + expect(widget.position.row).toBeGreaterThanOrEqual(0) + } + }) +}) diff --git a/src/layouts/widgets/calendar/calendar.tsx b/src/layouts/widgets/calendar/calendar.widget.tsx similarity index 77% rename from src/layouts/widgets/calendar/calendar.tsx rename to src/layouts/widgets/calendar/calendar.widget.tsx index bd0224979..bf8484e2c 100644 --- a/src/layouts/widgets/calendar/calendar.tsx +++ b/src/layouts/widgets/calendar/calendar.widget.tsx @@ -1,11 +1,14 @@ -import { useState } from 'react' +import React, { useState } from 'react' import { useDate } from '@/context/date.context' import { WidgetContainer } from '../widget-container' import { CalendarGrid } from './components/calendar-grid' import { CalendarHeader } from './components/calendar-header' import { GoogleCalendarView } from './components/google-calendar/google-calendar-view' +import { Calendar2x1Row } from './variants/calendar-2x1' +import { Calendar1x1 } from './variants/calendar-1x1' import Analytics from '@/analytics' -import { Icon } from '@/src/icons' +import { Icon } from '@/icons' +import type { WidgetSize } from '../layout-engine/types' interface CalendarTabSelectorProps { activeTab: string @@ -47,7 +50,13 @@ const CalendarTabSelector: React.FC = ({ ) } -const CalendarLayout: React.FC = () => { +interface CalendarLayoutProps { + size?: WidgetSize +} + +export const CalendarLayout: React.FC = ({ + size = { w: 2, h: 3 }, +}) => { const { currentDate, selectedDate, setCurrentDate, setSelectedDate, goToToday } = useDate() const [activeTab, setActiveTab] = useState('calendar') @@ -57,8 +66,24 @@ const CalendarLayout: React.FC = () => { Analytics.event(`calendar_tab_switch_to_${tab}`) } + if (size.w === 1 && size.h === 1) { + return ( + + + + ) + } + + if (size.w === 2 && size.h === 1) { + return ( + + + + ) + } + return ( - +
{activeTab === 'calendar' ? ( <> diff --git a/src/layouts/widgets/calendar/components/calendar-grid.tsx b/src/layouts/widgets/calendar/components/calendar-grid.tsx index 0adc8120e..6e1e9798b 100644 --- a/src/layouts/widgets/calendar/components/calendar-grid.tsx +++ b/src/layouts/widgets/calendar/components/calendar-grid.tsx @@ -3,7 +3,7 @@ import { useGeneralSetting } from '@/context/general-setting.context' import { useGetEvents } from '@/services/hooks/date/get-events.hook' import type React from 'react' import { useState } from 'react' -import { type WidgetifyDate, formatDateStr } from '../utils' +import { type WidgetifyDate, formatDateStr } from '../utils/date-events' import { DayItem } from './day/day' import { ClickableTooltip } from '@/components/ui' import { CalendarDayDetails } from './day/tool-tip-content' diff --git a/src/layouts/widgets/calendar/components/calendar-header.tsx b/src/layouts/widgets/calendar/components/calendar-header.tsx index ad59401e3..084328fc2 100644 --- a/src/layouts/widgets/calendar/components/calendar-header.tsx +++ b/src/layouts/widgets/calendar/components/calendar-header.tsx @@ -1,7 +1,7 @@ import { useGeneralSetting } from '@/context/general-setting.context' import type React from 'react' -import { type WidgetifyDate, getCurrentDate } from '../utils' -import { Icon } from '@/src/icons' +import { type WidgetifyDate, getCurrentDate } from '../utils/date-events' +import { Icon } from '@/icons' interface CalendarHeaderProps { currentDate: WidgetifyDate diff --git a/src/layouts/widgets/calendar/components/day/day.tsx b/src/layouts/widgets/calendar/components/day/day.tsx index f41b61c26..e612a7217 100644 --- a/src/layouts/widgets/calendar/components/day/day.tsx +++ b/src/layouts/widgets/calendar/components/day/day.tsx @@ -10,8 +10,8 @@ import { getGregorianEvents, getHijriEvents, getShamsiEvents, -} from '../../utils' -import { moodOptions } from '@/common/constant/moods' +} from '../../utils/date-events' +import { moodOptions } from '@/common/constants/moods' interface DayItemProps { day: number diff --git a/src/layouts/widgets/calendar/components/day/tool-tip-content.tsx b/src/layouts/widgets/calendar/components/day/tool-tip-content.tsx index 27c8d011d..14a6d5f03 100644 --- a/src/layouts/widgets/calendar/components/day/tool-tip-content.tsx +++ b/src/layouts/widgets/calendar/components/day/tool-tip-content.tsx @@ -5,7 +5,7 @@ import { getHijriEvents, getShamsiEvents, hijriMonthNames, -} from '../../utils' +} from '../../utils/date-events' import { useDate } from '@/context/date.context' import type React from 'react' import { useAuth } from '@/context/auth.context' @@ -19,8 +19,8 @@ import { useIsMutating } from '@tanstack/react-query' import { autoFormatErrorToast, showToast } from '@/common/toast' import type { MoodEntry } from '@/services/hooks/mood-log/get-moods.hook' import Analytics from '@/analytics' -import { moodOptions } from '@/common/constant/moods' -import { Icon } from '@/src/icons' +import { moodOptions } from '@/common/constants/moods' +import { Icon } from '@/icons' interface CalendarDayDetailsProps { events: FetchedAllEvents @@ -127,7 +127,7 @@ export const CalendarDayDetails: React.FC = ({ return (
-
+
{jalaliDay} {jalali} diff --git a/src/layouts/widgets/calendar/components/google-calendar/google-calendar-view.tsx b/src/layouts/widgets/calendar/components/google-calendar/google-calendar-view.tsx index bf6f58c3f..39291f204 100644 --- a/src/layouts/widgets/calendar/components/google-calendar/google-calendar-view.tsx +++ b/src/layouts/widgets/calendar/components/google-calendar/google-calendar-view.tsx @@ -3,12 +3,12 @@ import { useState, useEffect } from 'react' import Analytics from '@/analytics' import { useGetGoogleCalendarEvents } from '@/services/hooks/date/get-google-calendar-events.hook' import { useDate } from '@/context/date.context' -import { CalendarEvent } from './google-event.item' +import { CalendarEvent } from './google-event-item' import { useAuth } from '@/context/auth.context' import { callEvent } from '@/common/utils/call-event' -import type { WidgetifyDate } from '../../utils' -import { GoogleEventItemSkeleton } from './google-event.item-skeleton' -import { Icon } from '@/src/icons' +import type { WidgetifyDate } from '../../utils/date-events' +import { GoogleEventItemSkeleton } from './google-event-item-skeleton' +import { Icon } from '@/icons' import { Button } from '@/components/ui' export const GoogleCalendarView: React.FC = () => { @@ -60,12 +60,12 @@ export const GoogleCalendarView: React.FC = () => { const classified = [...(events ?? [])] .sort( (a, b) => - new Date(a.start.dateTime).getTime() - - new Date(b.start.dateTime).getTime() + new Date(a.start?.dateTime || a.start?.date || 0).getTime() - + new Date(b.start?.dateTime || b.start?.date || 0).getTime() ) .map((event) => { - const start = new Date(event.start.dateTime) - const end = new Date(event.end.dateTime) + const start = new Date(event.start?.dateTime || event.start?.date || 0) + const end = new Date(event.end?.dateTime || event.end?.date || 0) const isNow = todayFlag && now >= start && now <= end diff --git a/src/layouts/widgets/calendar/components/google-calendar/google-event.item-skeleton.tsx b/src/layouts/widgets/calendar/components/google-calendar/google-event-item-skeleton.tsx similarity index 100% rename from src/layouts/widgets/calendar/components/google-calendar/google-event.item-skeleton.tsx rename to src/layouts/widgets/calendar/components/google-calendar/google-event-item-skeleton.tsx diff --git a/src/layouts/widgets/calendar/components/google-calendar/google-event.item.tsx b/src/layouts/widgets/calendar/components/google-calendar/google-event-item.tsx similarity index 97% rename from src/layouts/widgets/calendar/components/google-calendar/google-event.item.tsx rename to src/layouts/widgets/calendar/components/google-calendar/google-event-item.tsx index 9af938d95..db6caca6c 100644 --- a/src/layouts/widgets/calendar/components/google-calendar/google-event.item.tsx +++ b/src/layouts/widgets/calendar/components/google-calendar/google-event-item.tsx @@ -1,5 +1,5 @@ import { Tooltip } from '@/components/ui' -import { Icon } from '@/src/icons' +import { Icon } from '@/icons' interface CalendarEventProps { event: any @@ -56,7 +56,7 @@ export const CalendarEvent = ({ e.stopPropagation() onEventClick(event) }} - className="flex items-center gap-1 px-2 py-0.5 mb-1 rounded-lg bg-primary text-white text-[9px] cursor-pointer transition-all hover:brightness-110 active:scale-95 shrink-0 font-medium" + className="flex items-center gap-1 px-2 py-0.5 mb-1 rounded-lg bg-primary text-primary-content text-[9px] cursor-pointer transition-all hover:brightness-110 active:scale-95 shrink-0 font-medium" > ورود به جلسه diff --git a/src/layouts/widgets/calendar/interface/event.interface.ts b/src/layouts/widgets/calendar/interface/event.interface.ts deleted file mode 100644 index 74b6af653..000000000 --- a/src/layouts/widgets/calendar/interface/event.interface.ts +++ /dev/null @@ -1,6 +0,0 @@ -export type Event = { - date: string - title: string - type: 'holiday' | 'event' | 'news' - description?: string -} diff --git a/src/layouts/widgets/calendar/utils.ts b/src/layouts/widgets/calendar/utils/date-events.ts similarity index 100% rename from src/layouts/widgets/calendar/utils.ts rename to src/layouts/widgets/calendar/utils/date-events.ts diff --git a/src/layouts/widgets/calendar/variants/calendar-1x1.tsx b/src/layouts/widgets/calendar/variants/calendar-1x1.tsx new file mode 100644 index 000000000..4f3e84a1a --- /dev/null +++ b/src/layouts/widgets/calendar/variants/calendar-1x1.tsx @@ -0,0 +1,34 @@ +import { useDate } from '@/context/date.context' +import { cn } from '@/common/utils/cn' + +export function Calendar1x1() { + const { today, todayIsHoliday } = useDate() + const weekDayName = today.format('dddd') + const dayNumber = today.jDate() + const monthName = today.format('jMMMM') + const dayDigits = String(dayNumber).split('') + + return ( +
+ + {monthName} + + +
+ {dayDigits.map((digit, index) => ( + {digit} + ))} +
+ + + {weekDayName} + +
+ ) +} diff --git a/src/layouts/widgets/calendar/variants/calendar-2x1.tsx b/src/layouts/widgets/calendar/variants/calendar-2x1.tsx new file mode 100644 index 000000000..9a90fcbd1 --- /dev/null +++ b/src/layouts/widgets/calendar/variants/calendar-2x1.tsx @@ -0,0 +1,151 @@ +import { useState } from 'react' +import { useDate } from '@/context/date.context' +import { useAuth } from '@/context/auth.context' +import { cn } from '@/common/utils/cn' +import { useGetEvents } from '@/services/hooks/date/get-events.hook' +import { useGetCalendarData } from '@/services/hooks/calendar/get-calendar-data.hook' +import { ClickableTooltip } from '@/components/ui' +import { CalendarDayDetails } from '../components/day/tool-tip-content' +import { getHijriEvents, getShamsiEvents } from '../utils/date-events' +import Analytics from '@/analytics' + +const dayNames = ['ش', 'ی', 'د', 'س', 'چ', 'پ', 'ج'] + +export function Calendar2x1Row() { + const { today, selectedDate, setSelectedDate } = useDate() + const { isAuthenticated } = useAuth() + const { data: events } = useGetEvents() + const [isOpenTooltip, setIsOpenTooltip] = useState(false) + const [clickedElement, setClickedElement] = useState(null) + + const startOfWeek = today.clone().startOf('week') + const weekDays = Array.from({ length: 7 }, (_, i) => + startOfWeek.clone().add(i, 'days') + ) + + const { data: calendarData, refetch } = useGetCalendarData( + isAuthenticated, + startOfWeek.clone().doAsGregorian().format('YYYY-MM-DD'), + startOfWeek.clone().add(6, 'days').doAsGregorian().format('YYYY-MM-DD') + ) + + const eventsForCalendar = events || { + gregorianEvents: [], + hijriEvents: [], + shamsiEvents: [], + } + + const handleDayClick = ( + day: (typeof weekDays)[number], + element: HTMLButtonElement + ) => { + Analytics.event('calendar_day_click') + setSelectedDate(day) + setClickedElement(element) + setIsOpenTooltip(true) + } + + return ( + <> +
+
+ {weekDays.map((day, idx) => { + const isToday = day.isSame(today, 'day') + const isSelected = selectedDate && day.isSame(selectedDate, 'day') + + const dayEvents = events + ? [ + ...getShamsiEvents(events, day), + ...getHijriEvents(events, day), + ] + : [] + const isHoliday = + day.day() === 5 || dayEvents.some((e) => e.isHoliday) + + const dayStateClass = cn( + 'group relative flex flex-col items-center justify-center gap-0.5 rounded-xl', + 'cursor-pointer transition-all duration-150 active:scale-95 h-full', + + isSelected && 'font-bold shadow-sm', + isSelected && + (isHoliday + ? 'bg-error text-error-content' + : 'bg-primary text-primary-content'), + + !isSelected && isToday && 'font-bold ring-1', + !isSelected && + isToday && + (isHoliday + ? 'bg-error/10 text-error ring-error/30' + : 'bg-primary/10 text-primary ring-primary/30'), + + !isSelected && + !isToday && + 'bg-base-200/40 hover:bg-base-200/80', + !isSelected && + !isToday && + (isHoliday ? 'text-error' : 'text-content') + ) + + return ( + + ) + })} +
+
+ + {clickedElement && ( + refetch()} + /> + } + isOpen={isOpenTooltip} + setIsOpen={setIsOpenTooltip} + /> + )} + + ) +} diff --git a/src/layouts/widgets/canvas/canvas.layout.tsx b/src/layouts/widgets/canvas/canvas.layout.tsx new file mode 100644 index 000000000..6fa6e4ce7 --- /dev/null +++ b/src/layouts/widgets/canvas/canvas.layout.tsx @@ -0,0 +1,296 @@ +import type React from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' +import { callEvent, listenEvent } from '@/common/utils/call-event' +import { useFreeWidgets } from '@/context/free-widget/free-widget.context' +import { useContainerSize } from '@/hooks/use-container-size' +import { getCanvasHeight } from '../grid-geometry' +import { WIDGET_DEFINITIONS } from '../widget-registry' +import { AddWidgetModal, WidgetHelpModal } from '@/layouts/widgets-manager' +import { PresetLayoutModal } from '../presets' +import { CanvasContextMenu } from './components/canvas-context-menu' +import { CanvasWidgetOuter } from './components/canvas-widget-outer' +import { CanvasEditToolbar } from './components/canvas-edit-toolbar' +import { GridOverlay } from './components/grid-overlay' + +export function FreeWidgetCanvas() { + const containerRef = useRef(null) + const containerSize = useContainerSize(containerRef) + + const { + runtimeLayout, + cols, + cellWidth, + cellHeight, + gap, + isListFallback, + isLoaded, + canvasMode, + setCanvasMode, + selectedInstanceId, + setSelectedInstanceId, + updateContainerWidth, + removeWidget, + setMaxRows, + } = useFreeWidgets() + + const [isAddModalOpen, setIsAddModalOpen] = useState(false) + const [isPresetModalOpen, setIsPresetModalOpen] = useState(false) + const [isHelpModalOpen, setIsHelpModalOpen] = useState(false) + + const handleClosePresetModal = useCallback(() => { + setIsPresetModalOpen(false) + }, []) + + const handleCloseHelpModal = useCallback(() => { + setIsHelpModalOpen(false) + }, []) + const [editTarget, setEditTarget] = useState<{ + instanceId: string + widgetId: string + } | null>(null) + const [canvasContextMenuPos, setCanvasContextMenuPos] = useState<{ + x: number + y: number + } | null>(null) + const pressStartedOnBackgroundRef = useRef(false) + + useEffect(() => { + if (containerSize.width > 0) { + updateContainerWidth(containerSize.width) + } + }, [containerSize.width, updateContainerWidth]) + + useEffect(() => { + const removeListener = listenEvent( + 'openAddCustomWidgetModal', + (payload?: any) => { + if (payload?.instanceId && payload?.widgetId) { + setEditTarget(payload) + } else { + setEditTarget(null) + } + setIsAddModalOpen(true) + } + ) + const removePresetListener = listenEvent('openPresetLayoutsModal', () => { + setIsPresetModalOpen(true) + }) + return () => { + removeListener() + removePresetListener() + } + }, []) + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && canvasMode === 'edit') { + callEvent('cancelWidgetDrag', null) + setSelectedInstanceId(null) + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [canvasMode, setSelectedInstanceId]) + + const isBackgroundTarget = (target: EventTarget | null) => + target === containerRef.current || + (target as HTMLElement)?.classList?.contains('canvas-background') + + const handleCanvasPointerDown = (e: React.PointerEvent) => { + pressStartedOnBackgroundRef.current = isBackgroundTarget(e.target) + } + + const handleCanvasClick = (e: React.MouseEvent) => { + const startedOnBackground = pressStartedOnBackgroundRef.current + pressStartedOnBackgroundRef.current = false + + if (!startedOnBackground || !isBackgroundTarget(e.target)) return + + if (canvasMode === 'edit') { + setCanvasMode('normal') + setSelectedInstanceId(null) + } + } + + const handleCanvasContextMenu = (e: React.MouseEvent) => { + const target = e.target as HTMLElement + if ( + target === containerRef.current || + target.classList.contains('canvas-background') + ) { + e.preventDefault() + setCanvasContextMenuPos({ x: e.clientX, y: e.clientY }) + } + } + + const maxWidgetRow = Math.max( + 0, + ...runtimeLayout.map((w) => w.position.row + w.size.h) + ) + const totalGridRows = Math.max(6, maxWidgetRow + 2) + const canvasPixelHeight = Math.max( + totalGridRows * cellHeight + Math.max(0, totalGridRows - 1) * gap, + getCanvasHeight(runtimeLayout, cellHeight, gap) + ) + + useEffect(() => { + setMaxRows(totalGridRows) + }, [totalGridRows, setMaxRows]) + + const wiggleVariants = useMemo(() => { + const variants = new Map() + for (const widget of runtimeLayout) { + let hash = 0 + for (let i = 0; i < widget.instanceId.length; i++) { + hash = (hash * 31 + widget.instanceId.charCodeAt(i)) | 0 + } + variants.set(widget.instanceId, Math.abs(hash) % 3) + } + return variants + }, [runtimeLayout]) + + if (!isLoaded) { + return
+ } + + if (isListFallback) { + const sortedList = [...runtimeLayout].sort((a, b) => { + if (a.position.row !== b.position.row) { + return a.position.row - b.position.row + } + return a.position.col - b.position.col + }) + + return ( +
+ {sortedList.map((widget) => { + const def = WIDGET_DEFINITIONS[widget.id] + if (!def) return null + + return ( +
+
+
+ {def.emoji} + {def.label} +
+ +
+
+ {def.node(widget.instanceId, widget.size)} +
+
+ ) + })} +
+ ) + } + + return ( +
+
+ {canvasMode === 'edit' && ( + setIsAddModalOpen(true)} + onOpenPresets={() => setIsPresetModalOpen(true)} + onExitEditMode={() => { + setCanvasMode('normal') + setSelectedInstanceId(null) + }} + /> + )} + + {canvasMode === 'edit' && ( + + )} + + {runtimeLayout.map((widget) => { + const def = WIDGET_DEFINITIONS[widget.id] + if (!def) return null + + return ( + + ) + })} +
+ + {canvasContextMenuPos && ( + setCanvasContextMenuPos(null)} + onToggleEditMode={() => { + setCanvasMode(canvasMode === 'edit' ? 'normal' : 'edit') + setSelectedInstanceId(null) + }} + onOpenAddWidget={() => setIsAddModalOpen(true)} + onOpenPresets={() => setIsPresetModalOpen(true)} + onOpenAppearanceSettings={() => + callEvent('openSettings', 'appearance') + } + onOpenWallpaperSettings={() => + callEvent('openSettings', 'wallpapers') + } + onOpenHelp={() => setIsHelpModalOpen(true)} + /> + )} + + { + setIsAddModalOpen(false) + setEditTarget(null) + }} + /> + + + + +
+ ) +} diff --git a/src/layouts/widgets/canvas/components/bookmark-delete-modal.tsx b/src/layouts/widgets/canvas/components/bookmark-delete-modal.tsx new file mode 100644 index 000000000..3b084e59a --- /dev/null +++ b/src/layouts/widgets/canvas/components/bookmark-delete-modal.tsx @@ -0,0 +1,26 @@ +import { ConfirmationModal } from '@/components/ui' + +interface BookmarkDeleteModalProps { + isOpen: boolean + onClose: () => void + onConfirm: () => void +} + +export function BookmarkDeleteModal({ + isOpen, + onClose, + onConfirm, +}: BookmarkDeleteModalProps) { + return ( + + ) +} diff --git a/src/layouts/widgets/canvas/components/canvas-context-menu.tsx b/src/layouts/widgets/canvas/components/canvas-context-menu.tsx new file mode 100644 index 000000000..c9ea574df --- /dev/null +++ b/src/layouts/widgets/canvas/components/canvas-context-menu.tsx @@ -0,0 +1,93 @@ +import { Icon } from '@/icons' +import { PopoverMenu, PopoverMenuItem, PopoverMenuDivider } from '@/components/ui' + +interface CanvasContextMenuProps { + x: number + y: number + canvasMode: 'normal' | 'edit' + onClose: () => void + onToggleEditMode: () => void + onOpenAddWidget: () => void + onOpenPresets?: () => void + onOpenAppearanceSettings: () => void + onOpenWallpaperSettings: () => void + onOpenHelp?: () => void +} + +export function CanvasContextMenu({ + x, + y, + canvasMode, + onClose, + onToggleEditMode, + onOpenAddWidget, + onOpenPresets, + onOpenAppearanceSettings, + onOpenWallpaperSettings, + onOpenHelp, +}: CanvasContextMenuProps) { + return ( + + } + label={canvasMode === 'edit' ? 'پایان ویرایش' : 'ویرایش ویجت‌ها'} + onClick={() => { + onToggleEditMode() + onClose() + }} + /> + + } + label="افزودن ویجت" + onClick={() => { + onOpenAddWidget() + onClose() + }} + /> + + {onOpenPresets && ( + } + label="چیدمان‌های آماده" + onClick={() => { + onOpenPresets() + onClose() + }} + /> + )} + + } + label="تنظیمات ظاهری" + onClick={() => { + onOpenAppearanceSettings() + onClose() + }} + /> + + } + label="تصویر‌ زمینه‌ها" + onClick={() => { + onOpenWallpaperSettings() + onClose() + }} + /> + + {onOpenHelp && ( + <> + + } + label="راهنمای ویجت‌ها" + onClick={() => { + onOpenHelp() + onClose() + }} + /> + + )} + + ) +} diff --git a/src/layouts/widgets/canvas/components/canvas-edit-toolbar.tsx b/src/layouts/widgets/canvas/components/canvas-edit-toolbar.tsx new file mode 100644 index 000000000..c0eb9e505 --- /dev/null +++ b/src/layouts/widgets/canvas/components/canvas-edit-toolbar.tsx @@ -0,0 +1,60 @@ +import { createPortal } from 'react-dom' +import { Icon } from '@/icons' + +interface CanvasEditToolbarProps { + onAddWidget: () => void + onOpenPresets?: () => void + onExitEditMode: () => void +} + +export function CanvasEditToolbar({ + onAddWidget, + onOpenPresets, + onExitEditMode, +}: CanvasEditToolbarProps) { + return createPortal( +
+
+ + + + + + ویرایش چیدمان + +
+ +
+ + + {onOpenPresets && ( + + )} + + +
+
, + document.body + ) +} diff --git a/src/layouts/widgets/canvas/components/canvas-widget-outer.tsx b/src/layouts/widgets/canvas/components/canvas-widget-outer.tsx new file mode 100644 index 000000000..1770e41f2 --- /dev/null +++ b/src/layouts/widgets/canvas/components/canvas-widget-outer.tsx @@ -0,0 +1,461 @@ +import type React from 'react' +import { memo, useCallback, useEffect, useRef, useState } from 'react' +import { callEvent, listenEvent } from '@/common/utils/call-event' +import { useFreeWidgetActions } from '@/context/free-widget/free-widget.context' +import { getWidgetPixelRect } from '../../grid-geometry' +import { + type StoredWidget, + type WidgetDefinition, + WidgetKeys, + type WidgetPosition, + type WidgetSize, +} from '../../layout-engine/types' +import { cn } from '@/common/utils/cn' +import { useAuth } from '@/context/auth.context' +import { Icon } from '@/icons' +import { ProBadge } from '@/components/ui' +import { useWidgetVipResolver } from '@/services/hooks/widgets/widget-catalog.hook' +import { WidgetContextMenu } from './widget-context-menu' +import { BookmarkDeleteModal } from './bookmark-delete-modal' +import { WidgetSlot } from './widget-slot' + +interface CanvasWidgetOuterProps { + widget: StoredWidget + definition: WidgetDefinition + cellWidth: number + cellHeight: number + gap: number + cols: number + canvasMode: 'normal' | 'edit' + isSelected: boolean + wiggleVariant: number +} + +const WIGGLE_CLASSES = [ + 'animate-widget-wiggle-a', + 'animate-widget-wiggle-b', + 'animate-widget-wiggle-c', +] + +function CanvasWidgetOuterImpl({ + widget, + definition, + cellWidth, + cellHeight, + gap, + cols, + canvasMode, + isSelected, + wiggleVariant, +}: CanvasWidgetOuterProps) { + const { isVip } = useAuth() + const { isWidgetVipOnly, isVariantVipOnly, isSizeVipOnly } = useWidgetVipResolver() + const { + setCanvasMode, + setSelectedInstanceId, + getGridBounds, + resizeWidget, + startDragPreview, + updateDragPreview, + endDragPreview, + duplicateWidget, + removeWidget, + } = useFreeWidgetActions() + + const isCurrentWidgetVipOnly = isWidgetVipOnly(widget.id) + const isCurrentVariantVipOnly = isVariantVipOnly(widget.id, widget.meta?.variant) + const isCurrentSizeVipOnly = isSizeVipOnly(widget.id, widget.size) + const isLocked = + !isVip && + (isCurrentWidgetVipOnly || isCurrentVariantVipOnly || isCurrentSizeVipOnly) + const isCompactSize = widget.size.w === 1 && widget.size.h === 1 + + const [isDragging, setIsDragging] = useState(false) + const [contextMenuPos, setContextMenuPos] = useState<{ + x: number + y: number + } | null>(null) + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + + const isWiggling = canvasMode === 'edit' && !isSelected + + const outerRef = useRef(null) + const pointerStartRef = useRef<{ x: number; y: number } | null>(null) + const dragStartPosRef = useRef(widget.position) + const dragBaseRectRef = useRef<{ left: number; top: number }>({ left: 0, top: 0 }) + const isDragActiveRef = useRef(false) + const activePointerIdRef = useRef(null) + const rafRef = useRef(null) + const pendingOffsetRef = useRef<{ x: number; y: number } | null>(null) + const previewPosRef = useRef(null) + + const anchorPosition = isDragging ? dragStartPosRef.current : widget.position + const pixelRect = getWidgetPixelRect( + anchorPosition, + widget.size, + cellWidth, + cellHeight, + gap + ) + + const baseTransform = `translate3d(${pixelRect.left}px, ${pixelRect.top}px, 0)` + const baseTransformRef = useRef(baseTransform) + baseTransformRef.current = baseTransform + + const resetDragState = useCallback(() => { + if (rafRef.current !== null) { + cancelAnimationFrame(rafRef.current) + rafRef.current = null + } + if (outerRef.current) { + outerRef.current.style.transform = baseTransformRef.current + } + pointerStartRef.current = null + isDragActiveRef.current = false + activePointerIdRef.current = null + pendingOffsetRef.current = null + previewPosRef.current = null + setIsDragging(false) + }, []) + + const finishDrag = useCallback( + (targetPosition: WidgetPosition | null) => { + try { + if (isDragActiveRef.current) { + endDragPreview(widget.instanceId, targetPosition) + } + } finally { + resetDragState() + } + }, + [endDragPreview, widget.instanceId, resetDragState] + ) + + useEffect(() => { + const removeListener = listenEvent('cancelWidgetDrag', () => { + finishDrag(null) + }) + return () => removeListener() + }, [finishDrag]) + + useEffect(() => { + if (!isDragging) return + + const settle = (e: PointerEvent) => { + if (e.pointerId !== activePointerIdRef.current) return + finishDrag(null) + } + + window.addEventListener('pointerup', settle) + window.addEventListener('pointercancel', settle) + return () => { + window.removeEventListener('pointerup', settle) + window.removeEventListener('pointercancel', settle) + } + }, [isDragging, finishDrag]) + + const handlePointerDown = (e: React.PointerEvent) => { + if (e.button !== 0 || canvasMode !== 'edit') return + if (isDragActiveRef.current) return + + const target = e.target as HTMLElement + if ( + target.closest('button') || + target.closest('input') || + target.closest('textarea') || + target.closest('select') || + target.closest('a') || + target.closest('[role="button"]') || + target.closest('.cursor-pointer') || + target.closest('.no-drag') + ) { + return + } + + pointerStartRef.current = { x: e.clientX, y: e.clientY } + dragStartPosRef.current = { ...widget.position } + dragBaseRectRef.current = { left: pixelRect.left, top: pixelRect.top } + isDragActiveRef.current = false + activePointerIdRef.current = e.pointerId + setSelectedInstanceId(widget.instanceId) + } + + const getTargetPosition = (offset: { x: number; y: number }): WidgetPosition => { + const unitW = cellWidth + gap + const unitH = cellHeight + gap + + const deltaCol = unitW > 0 ? Math.round(offset.x / unitW) : 0 + const deltaRow = unitH > 0 ? Math.round(offset.y / unitH) : 0 + + const { maxRows } = getGridBounds() + const rowLimit = Math.max(0, maxRows - widget.size.h) + + return { + col: Math.max( + 0, + Math.min(cols - widget.size.w, dragStartPosRef.current.col + deltaCol) + ), + row: Math.max(0, Math.min(rowLimit, dragStartPosRef.current.row + deltaRow)), + } + } + + const handlePointerMove = (e: React.PointerEvent) => { + if (canvasMode !== 'edit' || !pointerStartRef.current) return + if (e.pointerId !== activePointerIdRef.current) return + + const dx = e.clientX - pointerStartRef.current.x + const dy = e.clientY - pointerStartRef.current.y + const dist = Math.sqrt(dx * dx + dy * dy) + + const dragThreshold = 6 + + if (dist > dragThreshold) { + if (!isDragActiveRef.current) { + isDragActiveRef.current = true + previewPosRef.current = { ...dragStartPosRef.current } + startDragPreview() + setIsDragging(true) + try { + ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId) + } catch {} + } + pendingOffsetRef.current = { x: dx, y: dy } + if (rafRef.current === null) { + rafRef.current = requestAnimationFrame(() => { + rafRef.current = null + const offset = pendingOffsetRef.current + if (!offset) return + + const base = dragBaseRectRef.current + if (outerRef.current) { + outerRef.current.style.transform = `translate3d(${base.left + offset.x}px, ${base.top + offset.y}px, 0)` + } + + const target = getTargetPosition(offset) + const previous = previewPosRef.current + if ( + !previous || + target.col !== previous.col || + target.row !== previous.row + ) { + previewPosRef.current = target + updateDragPreview(widget.instanceId, target) + } + }) + } + } + } + + const handlePointerUp = (e: React.PointerEvent) => { + if (e.pointerId !== activePointerIdRef.current) return + + const dropTarget = isDragActiveRef.current + ? getTargetPosition(pendingOffsetRef.current ?? { x: 0, y: 0 }) + : null + + try { + ;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId) + } catch {} + + finishDrag(dropTarget) + } + + const handlePointerCancel = (e: React.PointerEvent) => { + if (e.pointerId !== activePointerIdRef.current) return + finishDrag(null) + } + + const handleLostPointerCapture = (e: React.PointerEvent) => { + if (e.pointerId !== activePointerIdRef.current) return + finishDrag(null) + } + + const handleContextMenu = (e: React.MouseEvent) => { + e.preventDefault() + e.stopPropagation() + setContextMenuPos({ x: e.clientX, y: e.clientY }) + } + + const handleResize = (newSize: WidgetSize) => { + resizeWidget(widget.instanceId, newSize) + } + + const handleDuplicate = () => { + duplicateWidget(widget.instanceId) + } + + const handleSettings = () => { + if (definition.settingsTab) { + callEvent('openWidgetsSettings', { + tab: definition.settingsTab, + instanceId: widget.instanceId, + size: widget.size, + }) + } + } + + const handleEditVariant = () => { + callEvent('openAddCustomWidgetModal', { + instanceId: widget.instanceId, + widgetId: widget.id, + }) + } + + const handleMove = () => { + setCanvasMode(canvasMode === 'edit' ? 'normal' : 'edit') + } + + const handleDelete = () => { + if (widget.id === WidgetKeys.bookmarks) { + setShowDeleteConfirm(true) + return + } + removeWidget(widget.instanceId) + } + + return ( + <> +
{ + if (canvasMode === 'edit') { + const target = e.target as HTMLElement + if ( + target.closest('.widget-delete-btn') || + target.closest('button[title="حذف ویجت"]') || + target.closest('.widget-size-toolbar') + ) { + return + } + e.preventDefault() + e.stopPropagation() + } + }} + > + {canvasMode === 'edit' && ( + + )} + +
+ {(!isLocked || canvasMode === 'edit') && ( + + )} + {isLocked && canvasMode === 'normal' && ( +
{ + e.stopPropagation() + callEvent('openSettings', 'vip') + }} + > +
+ + {!isCompactSize && ( + + ارتقا به اشتراک پرو + + )} +
+
+ )} + {canvasMode === 'edit' && ( +
+ )} +
+
+ + {contextMenuPos && ( + setContextMenuPos(null)} + onResize={handleResize} + onDuplicate={handleDuplicate} + onMove={handleMove} + onSettings={definition.settingsTab ? handleSettings : undefined} + onEditVariant={ + definition.variants?.length ? handleEditVariant : undefined + } + onDelete={handleDelete} + /> + )} + + {widget.id === WidgetKeys.bookmarks && showDeleteConfirm && ( + setShowDeleteConfirm(false)} + onConfirm={() => { + setShowDeleteConfirm(false) + removeWidget(widget.instanceId) + }} + /> + )} + + ) +} + +export const CanvasWidgetOuter = memo( + CanvasWidgetOuterImpl, + (a, b) => + a.widget === b.widget && + a.definition === b.definition && + a.cellWidth === b.cellWidth && + a.cellHeight === b.cellHeight && + a.gap === b.gap && + a.cols === b.cols && + a.canvasMode === b.canvasMode && + a.isSelected === b.isSelected && + a.wiggleVariant === b.wiggleVariant +) diff --git a/src/layouts/widgets/canvas/components/grid-overlay.tsx b/src/layouts/widgets/canvas/components/grid-overlay.tsx new file mode 100644 index 000000000..507a1ef36 --- /dev/null +++ b/src/layouts/widgets/canvas/components/grid-overlay.tsx @@ -0,0 +1,54 @@ +import { memo } from 'react' + +interface GridOverlayProps { + totalGridRows: number + cols: number + cellWidth: number + cellHeight: number + gap: number +} + +function GridOverlayImpl({ + totalGridRows, + cols, + cellWidth, + cellHeight, + gap, +}: GridOverlayProps) { + if (cellWidth <= 0 || cellHeight <= 0) return null + + return ( +
+ {Array.from({ length: totalGridRows }).map((_, r) => ( +
+ {Array.from({ length: cols }).map((_, c) => ( +
+ ))} +
+ ))} +
+ ) +} + +export const GridOverlay = memo(GridOverlayImpl) diff --git a/src/layouts/widgets/canvas/components/widget-context-menu.tsx b/src/layouts/widgets/canvas/components/widget-context-menu.tsx new file mode 100644 index 000000000..336ce3cf9 --- /dev/null +++ b/src/layouts/widgets/canvas/components/widget-context-menu.tsx @@ -0,0 +1,178 @@ +import { Icon } from '@/icons' +import type { + StoredWidget, + WidgetDefinition, + WidgetSize, +} from '../../layout-engine/types' +import { + Chip, + PopoverMenu, + PopoverMenuItem, + PopoverMenuDivider, + VipBadge, +} from '@/components/ui' +import { useAuth } from '@/context/auth.context' +import { useWidgetVipResolver } from '@/services/hooks/widgets/widget-catalog.hook' +import { callEvent } from '@/common/utils/call-event' +import { cn } from '@/common/utils/cn' + +interface WidgetContextMenuProps { + x: number + y: number + widget: StoredWidget + definition: WidgetDefinition + cols: number + onClose: () => void + onResize: (size: WidgetSize) => void + onDuplicate: () => void + onMove?: () => void + onSettings?: () => void + onEditVariant?: () => void + onDelete: () => void +} + +export function WidgetContextMenu({ + x, + y, + widget, + definition, + cols, + onClose, + onResize, + onDuplicate, + onMove, + onSettings, + onEditVariant, + onDelete, +}: WidgetContextMenuProps) { + const { isVip } = useAuth() + const { isSizeVipOnly } = useWidgetVipResolver() + + const hasVariants = Boolean(definition.variants && definition.variants.length > 0) + const fittingSizes = definition.allowedSizes.filter((s) => s.w <= cols) + const showResize = + (!hasVariants || definition.canResize === true) && fittingSizes.length > 1 + + return ( + +
+ + {definition.emoji} + {definition.label} + +
+ + {showResize && ( +
+ + تغییر اندازه + +
+ {fittingSizes.map((size) => { + const isCurrent = + size.w === widget.size.w && size.h === widget.size.h + const isSizeVip = !isVip && isSizeVipOnly(definition.id, size) + + return ( + { + if (isSizeVip && !isVip) { + callEvent('openSettings', 'vip') + onClose() + return + } + onResize(size) + onClose() + }} + selected={isCurrent} + className={cn( + 'py-0.5 flex items-center justify-center gap-0.5', + isSizeVip && !isCurrent && 'border-vip/30' + )} + > + + {size.w}×{size.h} + + {isSizeVip && ( + + )} + + ) + })} +
+
+ )} + + + + {onMove && ( + } + label="جابجایی" + onClick={() => { + onMove() + onClose() + }} + /> + )} + + {onEditVariant && ( + } + label="تغییر مدل و استایل" + onClick={() => { + onEditVariant() + onClose() + }} + /> + )} + + {onDuplicate && definition.canDuplicate && ( + } + label="تکرار ویجت" + badge={!isVip ? : undefined} + onClick={() => { + if (!isVip) { + callEvent('openSettings', 'vip') + onClose() + return + } + onDuplicate() + onClose() + }} + /> + )} + + {onSettings && ( + } + label="تنظیمات" + onClick={() => { + onSettings() + onClose() + }} + /> + )} + + + + } + label="حذف ویجت" + variant="danger" + onClick={() => { + onDelete() + onClose() + }} + /> +
+ ) +} diff --git a/src/layouts/widgets/canvas/components/widget-slot.tsx b/src/layouts/widgets/canvas/components/widget-slot.tsx new file mode 100644 index 000000000..ebb816f34 --- /dev/null +++ b/src/layouts/widgets/canvas/components/widget-slot.tsx @@ -0,0 +1,23 @@ +import { memo } from 'react' +import type { WidgetDefinition, WidgetSize } from '../../layout-engine/types' + +interface WidgetSlotProps { + definition: WidgetDefinition + instanceId: string + size: WidgetSize + meta?: any +} + +function WidgetSlotImpl({ definition, instanceId, size, meta }: WidgetSlotProps) { + return <>{definition.node(instanceId, size, meta)} +} + +export const WidgetSlot = memo( + WidgetSlotImpl, + (a, b) => + a.definition === b.definition && + a.instanceId === b.instanceId && + a.size.w === b.size.w && + a.size.h === b.size.h && + a.meta === b.meta +) diff --git a/src/layouts/widgets/wigi-pad/clock-display/clock-setting.interface.ts b/src/layouts/widgets/clock/clock-setting.interface.ts similarity index 99% rename from src/layouts/widgets/wigi-pad/clock-display/clock-setting.interface.ts rename to src/layouts/widgets/clock/clock-setting.interface.ts index 72c3c2c64..4dbce451f 100644 --- a/src/layouts/widgets/wigi-pad/clock-display/clock-setting.interface.ts +++ b/src/layouts/widgets/clock/clock-setting.interface.ts @@ -4,6 +4,7 @@ export interface ClockSettings { showTimeZone: boolean useSelectedFont: boolean } + export enum ClockType { Analog = 'analog', Digital = 'digital', diff --git a/src/layouts/widgets/clock/clock.widget.tsx b/src/layouts/widgets/clock/clock.widget.tsx new file mode 100644 index 000000000..568e1c8ac --- /dev/null +++ b/src/layouts/widgets/clock/clock.widget.tsx @@ -0,0 +1,153 @@ +import { useEffect, useMemo, useState } from 'react' +import { getFromStorage } from '@/common/storage' +import { listenEvent } from '@/common/utils/call-event' +import { useGeneralSetting } from '@/context/general-setting.context' +import type { FetchedTimezone } from '@/services/hooks/timezone/get-timezones.hook' +import { + type ClockSettings, + ClockType, +} from './clock-setting.interface' +import { WidgetContainer } from '../widget-container' +import type { WidgetSize } from '../layout-engine/types' +import { Clock1x1 } from './variants/clock-1x1' +import { Clock2x1 } from './variants/clock-2x1' +import { ClockAnalog } from './variants/clock-analog' +import { ClockFlip } from './variants/clock-flip' +import { getTimeZoneLabel } from '@/common/utils/get-timezone-label' + +interface ClockWidgetProps { + size?: WidgetSize + meta?: { + variant?: string + } +} + +export function ClockWidget({ size = { w: 2, h: 1 }, meta }: ClockWidgetProps) { + const [clockSettings, setClockSettings] = useState(null) + const { selected_timezone: timezone, isOptimalMode } = useGeneralSetting() + + useEffect(() => { + async function load() { + const clockFromStore = await getFromStorage('clock') + if (clockFromStore) { + setClockSettings(clockFromStore) + } else { + setClockSettings({ + clockType: ClockType.Digital, + showSeconds: false, + showTimeZone: false, + useSelectedFont: true, + }) + } + } + + const event = listenEvent('clockSettingsChanged', (data) => { + setClockSettings({ + clockType: data.clockType, + showSeconds: data.showSeconds, + showTimeZone: data.showTimeZone, + useSelectedFont: data.useSelectedFont, + }) + }) + + load() + + return () => { + event() + } + }, []) + + if (!clockSettings) { + return null + } + + const background = !['analog', 'flip'].includes(meta?.variant || '') + + return ( + + + + ) +} + +interface ClockContentProps { + size: WidgetSize + timezone: FetchedTimezone + isOptimalMode: boolean + meta?: { + variant?: string + } +} + +function ClockContent({ size, timezone, isOptimalMode, meta }: ClockContentProps) { + const [time, setTime] = useState( + () => + new Date( + new Date().toLocaleString('en-US', { + timeZone: timezone?.value || undefined, + }) + ) + ) + + const isAnalog = meta?.variant === 'analog' + const showSeconds = isAnalog || !isOptimalMode + + useEffect(() => { + function updateTime() { + setTime( + new Date( + new Date().toLocaleString('en-US', { + timeZone: timezone?.value || undefined, + }) + ) + ) + } + + updateTime() + const interval = showSeconds ? 1000 : 30_000 + const timer = setInterval(updateTime, interval) + + return () => clearInterval(timer) + }, [timezone?.value, showSeconds]) + + const rawHours = time.getHours().toString().padStart(2, '0') + const rawMinutes = time.getMinutes().toString().padStart(2, '0') + + const timezoneLabel = useMemo(() => { + return getTimeZoneLabel(timezone.label) + }, [timezone]) + + if (meta?.variant === 'analog') { + return + } + + if (meta?.variant === 'flip') { + return + } + + if ( + meta?.variant === 'digital-vertical' || + meta?.variant === 'vertical' || + (size.w === 1 && size.h === 1) + ) { + return + } + + return ( + + ) +} diff --git a/src/layouts/widgets/clock/variants/clock-1x1.tsx b/src/layouts/widgets/clock/variants/clock-1x1.tsx new file mode 100644 index 000000000..00a8bde77 --- /dev/null +++ b/src/layouts/widgets/clock/variants/clock-1x1.tsx @@ -0,0 +1,26 @@ +interface Clock1x1Props { + time: Date + hours: string + minutes: string +} + +export function Clock1x1({ hours, minutes }: Clock1x1Props) { + return ( +
+
+ + {hours} + + + : + + + {minutes} + +
+
+ ) +} diff --git a/src/layouts/widgets/clock/variants/clock-2x1.tsx b/src/layouts/widgets/clock/variants/clock-2x1.tsx new file mode 100644 index 000000000..277fc9e2a --- /dev/null +++ b/src/layouts/widgets/clock/variants/clock-2x1.tsx @@ -0,0 +1,38 @@ +interface Clock2x1Props { + time: Date + timezoneLabel: string + hours: string + minutes: string +} + +export function Clock2x1({ time, timezoneLabel, hours, minutes }: Clock2x1Props) { + return ( +
+
+
+
+ + {hours} : {minutes} + +
+
+
+ +
+
+ {timezoneLabel} +
+ + {time.toLocaleDateString('fa-IR', { + weekday: 'short', + month: 'short', + day: 'numeric', + })} + +
+
+ ) +} diff --git a/src/layouts/widgets/clock/variants/clock-analog.tsx b/src/layouts/widgets/clock/variants/clock-analog.tsx new file mode 100644 index 000000000..e036fc10e --- /dev/null +++ b/src/layouts/widgets/clock/variants/clock-analog.tsx @@ -0,0 +1,148 @@ +import { useEffect, useId, useMemo, useState } from 'react' +import { useGeneralSetting } from '@/context/general-setting.context' +import { getCurrentDate } from '@/layouts/widgets/calendar/utils/date-events' + +interface ClockAnalogProps { + size?: number + time?: Date +} + +/* Apple Watch style palette (matches the reference design) */ +const FACE_COLOR = '#FDFDFB' +const BEZEL_COLOR = '#1A1A1A' +const HAND_COLOR = '#1A1A1A' +const NUMERAL_COLOR = '#1A1A1A' +const SECOND_HAND_COLOR = '#F7A600' + +export function ClockAnalog({ size = 76, time: propTime }: ClockAnalogProps) { + const { selected_timezone: timezone } = useGeneralSetting() + const [internalTime, setInternalTime] = useState(() => + getCurrentDate(timezone.value).toDate() + ) + + useEffect(() => { + if (propTime) return + const timer = setInterval(() => { + setInternalTime(new Date()) + }, 1000) + return () => clearInterval(timer) + }, [propTime]) + + const time = propTime || internalTime + + /* Unique id per instance so multiple clocks don't share one SVG filter */ + const filterId = useId().replace(/[^a-zA-Z0-9-]/g, '') + + const hours = time.getHours() % 12 + const minutes = time.getMinutes() + const seconds = time.getSeconds() + + const hourAngle = hours * 30 + minutes * 0.5 + const minuteAngle = minutes * 6 + const secondAngle = seconds * 6 + + const numerals = useMemo(() => { + return Array.from({ length: 12 }, (_, i) => { + const numeral = i === 0 ? 12 : i + const angle = i * 30 + const radius = 37 + + return { + numeral, + x: 50 + radius * Math.cos(((angle - 90) * Math.PI) / 180), + y: 50 + radius * Math.sin(((angle - 90) * Math.PI) / 180), + } + }) + }, []) + + return ( +
+
+ + + + + + + + {/* Bezel + face */} + + + + {/* Hour numerals */} + + {numerals.map(({ numeral, x, y }) => ( + + {numeral} + + ))} + + + {/* Hour + minute hands */} + + {/* Hour hand */} + + + {/* Minute hand */} + + + + {/* Second hand */} + + + + + {/* Center pin */} + + +
+
+ ) +} diff --git a/src/layouts/widgets/clock/variants/clock-flip.tsx b/src/layouts/widgets/clock/variants/clock-flip.tsx new file mode 100644 index 000000000..39598c14a --- /dev/null +++ b/src/layouts/widgets/clock/variants/clock-flip.tsx @@ -0,0 +1,252 @@ +import { memo, useEffect, useRef, useState } from 'react' +import type { CSSProperties } from 'react' +import { useGeneralSetting } from '@/context/general-setting.context' +import { getCurrentDate } from '@/layouts/widgets/calendar/utils/date-events' + +const FLIP_DURATION = 400 + +/** Half-card height; scales with the widget cell via container query units. */ +const flipSizeVars = { + '--flip-h': 'min(16cqw, 40cqh)', +} as CSSProperties + +interface FlipUnitProps { + value: string +} + +const FlipUnit = memo(function FlipUnit({ value }: FlipUnitProps) { + const [current, setCurrent] = useState(value) + const [previous, setPrevious] = useState(value) + const [flipping, setFlipping] = useState<'top' | 'bottom' | null>(null) + const timeoutsRef = useRef[]>([]) + + useEffect(() => { + if (value === current) return + + timeoutsRef.current.forEach(clearTimeout) + timeoutsRef.current = [] + + setPrevious(current) + setFlipping('top') + + const t1 = setTimeout(() => { + setCurrent(value) + setFlipping('bottom') + }, FLIP_DURATION / 2) + + const t2 = setTimeout(() => { + setFlipping(null) + }, FLIP_DURATION) + + timeoutsRef.current = [t1, t2] + + return () => timeoutsRef.current.forEach(clearTimeout) + }, [value]) + + return ( +
+
+ + {current} + +
+
+ + {flipping === 'bottom' ? previous : current} + +
+ + {flipping === 'top' && ( +
+
+ + {previous} + +
+
+
+ )} + {flipping === 'bottom' && ( +
+
+ + {value} + +
+
+
+ )} +
+
+
+
+ ) +}) + +export function ClockFlip() { + const { selected_timezone: timezone } = useGeneralSetting() + const [now, setNow] = useState(() => getCurrentDate(timezone.value).toDate()) + + useEffect(() => { + const updateTime = () => { + const next = getCurrentDate(timezone.value).toDate() + setNow((prev) => + prev.getHours() === next.getHours() && + prev.getMinutes() === next.getMinutes() + ? prev + : next + ) + } + + updateTime() + + let timer: ReturnType + const scheduleNextMinuteTick = () => { + // Tick shortly after each minute boundary so the flip only runs when + // the displayed value actually changes (a 1s interval re-rendered the + // whole widget 60x per minute for the same visual output). + const delay = 60_000 - (Date.now() % 60_000) + 100 + timer = setTimeout(() => { + updateTime() + scheduleNextMinuteTick() + }, delay) + } + scheduleNextMinuteTick() + + return () => clearTimeout(timer) + }, [timezone?.value]) + + const hours = now.getHours().toString().padStart(2, '0') + const minutes = now.getMinutes().toString().padStart(2, '0') + + return ( +
+
+ +
+ + +
+ +
+
+ ) +} diff --git a/src/layouts/widgets/combo-widget/combo-setting.tsx b/src/layouts/widgets/combo-widget/combo-setting.tsx new file mode 100644 index 000000000..36076b5f0 --- /dev/null +++ b/src/layouts/widgets/combo-widget/combo-setting.tsx @@ -0,0 +1,68 @@ +import { useEffect, useState } from 'react' +import { getFromStorage, setToStorage } from '@/common/storage' +import { TabNavigation } from '@/components/ui' +import { Icon } from '@/icons' +import { WidgetSettingWrapper } from '@/layouts/widgets-settings/widget-settings-wrapper' +import { RssFeedSetting } from '../news/rss-feed-setting' +import { WigiArzSetting } from '../wigi-arz/wigi-arz-setting' + +type ComboSettingTab = 'currency' | 'news' + +interface ComboSettingProps { + instanceId?: string + size?: { w: number; h: number } +} + +export function ComboSetting({ instanceId, size }: ComboSettingProps) { + const [activeTab, setActiveTab] = useState('currency') + + useEffect(() => { + async function load() { + const savedTab = await getFromStorage('comboTabs') + if (savedTab === 'news' || savedTab === 'currency') { + setActiveTab(savedTab) + } + } + + load() + }, []) + + const handleTabChange = (tab: ComboSettingTab) => { + setActiveTab(tab) + setToStorage('comboTabs', tab) + } + + return ( + +
+ + tabMode="simple" + activeTab={activeTab} + onTabClick={handleTabChange} + tabs={[ + { + id: 'currency', + label: 'تنظیمات ارزها', + icon: , + }, + { + id: 'news', + label: 'تنظیمات اخبار', + icon: , + }, + ]} + size="medium" + className="w-full" + /> + +
+ {activeTab === 'currency' ? ( + + ) : ( + + )} +
+
+
+ ) +} diff --git a/src/layouts/widgets/combo-widget/combo-widget.layout.tsx b/src/layouts/widgets/combo-widget/combo-widget.widget.tsx similarity index 66% rename from src/layouts/widgets/combo-widget/combo-widget.layout.tsx rename to src/layouts/widgets/combo-widget/combo-widget.widget.tsx index dc81354c6..48f770a77 100644 --- a/src/layouts/widgets/combo-widget/combo-widget.layout.tsx +++ b/src/layouts/widgets/combo-widget/combo-widget.widget.tsx @@ -1,27 +1,16 @@ import { useEffect, useState } from 'react' import Analytics from '@/analytics' import { getFromStorage, setToStorage } from '@/common/storage' -import { callEvent } from '@/common/utils/call-event' -import { WidgetTabKeys } from '@/layouts/widgets-settings/constant/tab-keys' -import { NewsLayout } from '../news/news.layout' +import { NewsLayout } from '../news/news.widget' import { WidgetContainer } from '../widget-container' -import { WigiArzLayout } from '../wigi-arz/wigi_arz.layout' -import { Button, TabNavigation } from '@/components/ui' -import { Icon } from '@/src/icons' +import { WigiArzLayout } from '../wigi-arz/wigi-arz.widget' +import { TabNavigation } from '@/components/ui' +import { Icon } from '@/icons' export type ComboTabType = 'news' | 'currency' export function ComboWidget() { const [activeTab, setActiveTab] = useState(null) - const handleSettingsClick = () => { - if (activeTab === 'currency') { - callEvent('openWidgetsSettings', { tab: WidgetTabKeys.wigiArz }) - } else { - callEvent('openWidgetsSettings', { tab: WidgetTabKeys.news_settings }) - } - - Analytics.event(`combo_${activeTab}_settings_opened`) - } const onTabClick = (tab: ComboTabType) => { if (tab === activeTab) return @@ -78,15 +67,6 @@ export function ComboWidget() { )}
- - ) } diff --git a/src/layouts/widgets/google-calendar/components/google-calendar-auth.tsx b/src/layouts/widgets/google-calendar/components/google-calendar-auth.tsx new file mode 100644 index 000000000..fc92ea9d9 --- /dev/null +++ b/src/layouts/widgets/google-calendar/components/google-calendar-auth.tsx @@ -0,0 +1,109 @@ +import type React from 'react' +import { callEvent } from '@/common/utils/call-event' +import { Button } from '@/components/ui' +import { Icon } from '@/icons' +import type { WidgetSize } from '../../layout-engine/types' + +interface GoogleCalendarAuthProps { + isAuthenticated: boolean + size?: WidgetSize +} + +export const GoogleCalendarAuth: React.FC = ({ + isAuthenticated, + size = { w: 2, h: 3 }, +}) => { + const handleAction = () => { + if (isAuthenticated) { + callEvent('openSettings', 'platforms') + } else { + callEvent('openProfile') + } + } + + const buttonText = isAuthenticated ? 'اتصال به تقویم' : 'ورود به حساب' + + if (size.w === 1 && size.h === 1) { + return ( +
+
+ +
+ +
+ + تقویم گوگل + + + {isAuthenticated ? 'نیاز به اتصال' : 'ورود به حساب'} + +
+ + +
+ ) + } + + if (size.w === 2 && size.h === 1) { + return ( +
+
+
+ +
+
+ + تقویم گوگل + + + {isAuthenticated + ? 'برای مشاهده برنامه‌ها، تقویم رو متصل کن' + : 'برای مشاهده رویدادها، اول وارد حسابت شو'} + +
+
+ + +
+ ) + } + + return ( +
+
+ +
+

تقویم گوگل

+

+ {isAuthenticated + ? 'برای مشاهده جلسات و برنامه‌هات، تقویم گوگل رو متصل کن' + : 'برای دسترسی به تقویم گوگل، اول وارد حسابت شو'} +

+ +
+ ) +} diff --git a/src/layouts/widgets/google-calendar/components/google-calendar-empty.tsx b/src/layouts/widgets/google-calendar/components/google-calendar-empty.tsx new file mode 100644 index 000000000..236406733 --- /dev/null +++ b/src/layouts/widgets/google-calendar/components/google-calendar-empty.tsx @@ -0,0 +1,17 @@ +import type React from 'react' +import { Icon } from '@/icons' + +interface GoogleCalendarEmptyProps { + message?: string +} + +export const GoogleCalendarEmpty: React.FC = ({ + message = 'برای این روز برنامه‌ای نداری', +}) => { + return ( +
+ +

{message}

+
+ ) +} diff --git a/src/layouts/widgets/google-calendar/components/google-calendar-event-card.tsx b/src/layouts/widgets/google-calendar/components/google-calendar-event-card.tsx new file mode 100644 index 000000000..e81820de1 --- /dev/null +++ b/src/layouts/widgets/google-calendar/components/google-calendar-event-card.tsx @@ -0,0 +1,71 @@ +import type React from 'react' +import { Icon } from '@/icons' +import type { ClassifiedCalendarEvent } from '../types' + +interface GoogleCalendarEventCardProps { + classified: ClassifiedCalendarEvent + onEventClick: (event: any) => void +} + +export const GoogleCalendarEventCard: React.FC = ({ + classified, + onEventClick, +}) => { + const { event, isPast, isAllDay, startTimeStr, endTimeStr, durationLabel } = + classified + const hasAction = !!(event.hangoutLink || event.location) + + return ( +
hasAction && onEventClick(event)} + className={`flex items-center gap-2.5 p-2 rounded-xl bg-base-200/30 hover:bg-base-200/70 border border-base-content/5 transition-all ${ + hasAction ? 'cursor-pointer active:scale-[0.99]' : '' + } ${isPast ? 'opacity-40' : ''}`} + > +
+ {isAllDay ? ( + همه‌روز + ) : ( + <> + + {startTimeStr} + + + {endTimeStr} + + + )} +
+ + {/* Info */} +
+

+ {event.summary || 'بدون عنوان'} +

+
+ {durationLabel} + {event.location && ( + + 📍 {event.location} + + )} +
+
+ + {/* Action button */} + {event.hangoutLink && !isPast && ( + + )} +
+ ) +} diff --git a/src/layouts/widgets/google-calendar/components/google-calendar-now-card.tsx b/src/layouts/widgets/google-calendar/components/google-calendar-now-card.tsx new file mode 100644 index 000000000..e67cc2a49 --- /dev/null +++ b/src/layouts/widgets/google-calendar/components/google-calendar-now-card.tsx @@ -0,0 +1,80 @@ +import type React from 'react' +import { Icon } from '@/icons' +import type { ClassifiedCalendarEvent } from '../types' + +interface GoogleCalendarNowCardProps { + classified: ClassifiedCalendarEvent + onEventClick: (event: any) => void +} + +export const GoogleCalendarNowCard: React.FC = ({ + classified, + onEventClick, +}) => { + const { + event, + startTimeStr, + endTimeStr, + durationLabel, + minsRemaining, + elapsedPercent, + } = classified + const hasAction = !!(event.hangoutLink || event.location) + + return ( +
hasAction && onEventClick(event)} + className={`relative overflow-hidden flex flex-col gap-1.5 p-2.5 rounded-2xl bg-primary/10 border border-primary/20 transition-all ${ + hasAction ? 'cursor-pointer hover:bg-primary/15' : '' + }`} + > +
+
+ + + + + + در حال برگزاری + + + · {minsRemaining} دقیقه مانده + +
+ + {event.hangoutLink && ( + + )} +
+ +

+ {event.summary || 'بدون عنوان'} +

+ +
+ + {startTimeStr} - {endTimeStr} + + {durationLabel} +
+ + {/* Live progress indicator bar */} +
+
+
+
+ ) +} diff --git a/src/layouts/widgets/google-calendar/components/google-calendar-week-strip.tsx b/src/layouts/widgets/google-calendar/components/google-calendar-week-strip.tsx new file mode 100644 index 000000000..c118759f9 --- /dev/null +++ b/src/layouts/widgets/google-calendar/components/google-calendar-week-strip.tsx @@ -0,0 +1,78 @@ +import type React from 'react' +import type { WidgetifyDate } from '@/layouts/widgets/calendar/utils/date-events' +import { toIsoDateKey } from '../types' + +interface GoogleCalendarWeekStripProps { + weekDays: WidgetifyDate[] + selectedDay: WidgetifyDate + onSelectDay: (day: WidgetifyDate) => void + isToday: (day: WidgetifyDate) => boolean + eventsByDate: Map +} + +const PERSIAN_DAY_LETTERS = ['ش', 'ی', 'د', 'س', 'چ', 'پ', 'ج'] + +export const GoogleCalendarWeekStrip: React.FC = ({ + weekDays, + selectedDay, + onSelectDay, + isToday, + eventsByDate, +}) => { + return ( +
+ {weekDays.map((day, idx) => { + const dayIsoKey = toIsoDateKey(day) + const isDaySelected = + day.jDate() === selectedDay.jDate() && + day.jMonth() === selectedDay.jMonth() && + day.jYear() === selectedDay.jYear() + const isDayToday = isToday(day) + const hasEvents = (eventsByDate.get(dayIsoKey)?.length ?? 0) > 0 + + return ( + + ) + })} +
+ ) +} diff --git a/src/layouts/widgets/google-calendar/google-calendar.widget.tsx b/src/layouts/widgets/google-calendar/google-calendar.widget.tsx new file mode 100644 index 000000000..8564010f2 --- /dev/null +++ b/src/layouts/widgets/google-calendar/google-calendar.widget.tsx @@ -0,0 +1,193 @@ +import type React from 'react' +import { useState, useEffect, useMemo } from 'react' +import Analytics from '@/analytics' +import { + useGetGoogleCalendarEvents, + type GoogleCalendarEvent, +} from '@/services/hooks/date/get-google-calendar-events.hook' +import { useDate } from '@/context/date.context' +import { useAuth } from '@/context/auth.context' +import { WidgetContainer } from '../widget-container' +import type { WidgetSize } from '../layout-engine/types' +import type { WidgetifyDate } from '../calendar/utils/date-events' +import { + classifyEvent, + getWeekDays, + toIsoDateKey, + type ClassifiedCalendarEvent, +} from './types' +import { GoogleCalendarAuth } from './components/google-calendar-auth' +import { GoogleCalendarSchedule } from './variants/google-calendar-schedule' +import { GoogleCalendarTimeline } from './variants/google-calendar-timeline' +import { GoogleCalendarAgenda } from './variants/google-calendar-agenda' +import { GoogleCalendar1x1 } from './variants/google-calendar-1x1' +import { GoogleCalendar2x1 } from './variants/google-calendar-2x1' + +interface GoogleCalendarWidgetProps { + size?: WidgetSize + meta?: { + variant?: + | 'schedule' + | 'timeline' + | 'agenda' + | 'compact-1x1' + | 'compact-2x1' + | string + } +} + +export function GoogleCalendarWidget({ + size = { w: 2, h: 3 }, + meta, +}: GoogleCalendarWidgetProps) { + const { user, isAuthenticated } = useAuth() + const { currentDate, today, isToday } = useDate() + const [currentTime, setCurrentTime] = useState(new Date()) + const [selectedDay, setSelectedDay] = useState(currentDate) + + const isCalendarConnected = user?.connections?.includes('google') || false + + useEffect(() => { + const timer = setInterval(() => setCurrentTime(new Date()), 30000) + return () => clearInterval(timer) + }, []) + + const weekDays = useMemo(() => getWeekDays(selectedDay), [selectedDay]) + + const weekStartIso = useMemo(() => { + return `${weekDays[0].clone().locale('en').format('YYYY-MM-DD')}T00:00:00+03:30` + }, [weekDays]) + + const weekEndIso = useMemo(() => { + return `${weekDays[6].clone().locale('en').format('YYYY-MM-DD')}T23:59:59+03:30` + }, [weekDays]) + + const { data: rawEvents, isLoading } = useGetGoogleCalendarEvents( + isCalendarConnected, + weekStartIso, + weekEndIso + ) + + const eventsByDate = useMemo(() => { + const map = new Map() + if (!rawEvents) return map + + for (const ev of rawEvents) { + const dateKey = (ev.start?.dateTime || ev.start?.date || '').slice(0, 10) + if (!dateKey) continue + const list = map.get(dateKey) || [] + list.push(ev) + map.set(dateKey, list) + } + return map + }, [rawEvents]) + + const selectedDayKey = toIsoDateKey(selectedDay) + const dayEvents = useMemo(() => { + return eventsByDate.get(selectedDayKey) || [] + }, [eventsByDate, selectedDayKey]) + + const classifiedEvents = useMemo(() => { + const isDayToday = isToday(selectedDay) + const isDayPast = selectedDay.toDate() < today.startOf('day').toDate() + + return [...dayEvents] + .sort( + (a, b) => + new Date(a.start?.dateTime || a.start?.date || 0).getTime() - + new Date(b.start?.dateTime || b.start?.date || 0).getTime() + ) + .map((ev) => classifyEvent(ev, currentTime, isDayToday, isDayPast)) + }, [dayEvents, isToday, selectedDay, currentTime, today]) + + const handleEventClick = (event: GoogleCalendarEvent) => { + Analytics.event('google_calendar_event_click') + const link = + event.hangoutLink || + (event.location + ? `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent( + event.location + )}` + : event.htmlLink || null) + if (link) window.open(link, '_blank') + } + + if (!isCalendarConnected) { + return ( + + + + ) + } + + if (size.w === 1 && size.h === 1) { + return ( + + + + ) + } + + if (size.w === 2 && size.h === 1) { + return ( + + + + ) + } + + const variant = meta?.variant || 'schedule' + + return ( + + {variant === 'timeline' && ( + + )} + + {variant === 'agenda' && ( + + )} + + {variant !== 'timeline' && variant !== 'agenda' && ( + + )} + + ) +} \ No newline at end of file diff --git a/src/layouts/widgets/google-calendar/types.ts b/src/layouts/widgets/google-calendar/types.ts new file mode 100644 index 000000000..9cc37f8c1 --- /dev/null +++ b/src/layouts/widgets/google-calendar/types.ts @@ -0,0 +1,90 @@ +import type { GoogleCalendarEvent } from '@/services/hooks/date/get-google-calendar-events.hook' +import type { WidgetifyDate } from '@/layouts/widgets/calendar/utils/date-events' + +export interface ClassifiedCalendarEvent { + event: GoogleCalendarEvent + isNow: boolean + isPast: boolean + start: Date + end: Date + startTimeStr: string + endTimeStr: string + durationLabel: string + minsRemaining: number + elapsedPercent: number + isAllDay: boolean +} + +export function formatPersianTime(d: Date): string { + return d.toLocaleTimeString('fa-IR', { + hour: '2-digit', + minute: '2-digit', + }) +} + +export function getDurationLabel(start: Date, end: Date): string { + const diffMins = Math.max(1, Math.round((end.getTime() - start.getTime()) / 60000)) + if (diffMins >= 60) { + const hours = Math.floor(diffMins / 60) + const remMins = diffMins % 60 + return remMins > 0 ? `${hours} ساعت و ${remMins} دقیقه` : `${hours} ساعت` + } + return `${diffMins} دقیقه` +} + +export function classifyEvent( + event: GoogleCalendarEvent, + currentTime: Date, + isDayToday: boolean, + isDayPast: boolean +): ClassifiedCalendarEvent { + const start = new Date(event.start?.dateTime || event.start?.date || '') + const end = new Date(event.end?.dateTime || event.end?.date || '') + const isAllDay = !event.start?.dateTime && !!event.start?.date + + const isNow = + !isAllDay && + isDayToday && + currentTime.getTime() >= start.getTime() && + currentTime.getTime() <= end.getTime() + + let isPast = isDayPast + if (!isDayPast && isDayToday && !isAllDay) { + isPast = end.getTime() < currentTime.getTime() + } + + const totalDuration = end.getTime() - start.getTime() + const elapsed = currentTime.getTime() - start.getTime() + const elapsedPercent = + totalDuration > 0 + ? Math.min(100, Math.max(0, (elapsed / totalDuration) * 100)) + : 0 + const minsRemaining = Math.max( + 0, + Math.ceil((end.getTime() - currentTime.getTime()) / 60000) + ) + + return { + event, + isNow, + isPast, + start, + end, + startTimeStr: formatPersianTime(start), + endTimeStr: formatPersianTime(end), + durationLabel: isAllDay ? 'تمام روز' : getDurationLabel(start, end), + minsRemaining, + elapsedPercent, + isAllDay, + } +} + +export function getWeekDays(referenceDate: WidgetifyDate): WidgetifyDate[] { + const dayOfWeek = (referenceDate.day() + 1) % 7 + const startOfWeek = referenceDate.clone().subtract(dayOfWeek, 'days') + return Array.from({ length: 7 }, (_, i) => startOfWeek.clone().add(i, 'days')) +} + +export function toIsoDateKey(date: WidgetifyDate): string { + return date.clone().locale('en').format('YYYY-MM-DD') +} diff --git a/src/layouts/widgets/google-calendar/variants/google-calendar-1x1.tsx b/src/layouts/widgets/google-calendar/variants/google-calendar-1x1.tsx new file mode 100644 index 000000000..5fdf5eeda --- /dev/null +++ b/src/layouts/widgets/google-calendar/variants/google-calendar-1x1.tsx @@ -0,0 +1,143 @@ +import type React from 'react' +import { Icon } from '@/icons' +import type { WidgetifyDate } from '../../calendar/utils/date-events' +import type { ClassifiedCalendarEvent } from '../types' + +interface GoogleCalendar1x1Props { + today: WidgetifyDate + classifiedEvents: ClassifiedCalendarEvent[] + isLoading: boolean + onEventClick: (event: any) => void +} + +export const GoogleCalendar1x1: React.FC = ({ + today, + classifiedEvents, + isLoading, + onEventClick, +}) => { + const activeNow = classifiedEvents.find((e) => e.isNow) + const nextUpcoming = classifiedEvents.find((e) => !e.isPast && !e.isNow) + const targetEvent = activeNow || nextUpcoming + + if (isLoading) { + return ( +
+
+
+
+
+
+
+
+
+
+
+ ) + } + + if (!targetEvent) { + return ( +
+ {/* Top bar */} +
+
+ + تقویم +
+ + {today.format('jD jMMMM')} + +
+ + {/* Center State */} +
+ + + بدون برنامه + + امروز آزادتری +
+ + {/* Bottom day name */} +
+ + {today.format('dddd')} + +
+
+ ) + } + + const { event, isNow, startTimeStr, minsRemaining } = targetEvent + const hasAction = !!(event.hangoutLink || event.location) + + return ( +
hasAction && onEventClick(event)} + className={`flex flex-col justify-between h-full w-full p-2.5 select-none transition-all ${ + hasAction ? 'cursor-pointer active:scale-[0.98]' : '' + } ${isNow ? 'bg-primary/5' : ''}`} + > + {/* Top Bar */} +
+
+ {isNow ? ( + + + + + ) : ( + + )} + + {isNow ? 'در حال جلسه' : 'جلسه بعدی'} + +
+ + + {startTimeStr} + +
+ + {/* Center Title */} +
+

+ {event.summary || 'رویداد تقویم'} +

+ + {isNow + ? `${minsRemaining} دقیقه مانده` + : `امروز (${classifiedEvents.length} برنامه)`} + +
+ + {/* Bottom Action / Info */} +
+ + {today.format('dddd')} + + + {event.hangoutLink ? ( + + ) : ( + مشاهده + )} +
+
+ ) +} diff --git a/src/layouts/widgets/google-calendar/variants/google-calendar-2x1.tsx b/src/layouts/widgets/google-calendar/variants/google-calendar-2x1.tsx new file mode 100644 index 000000000..cfcbec55c --- /dev/null +++ b/src/layouts/widgets/google-calendar/variants/google-calendar-2x1.tsx @@ -0,0 +1,153 @@ +import type React from 'react' +import { Icon } from '@/icons' +import type { WidgetifyDate } from '../../calendar/utils/date-events' +import type { ClassifiedCalendarEvent } from '../types' + +interface GoogleCalendar2x1Props { + today: WidgetifyDate + classifiedEvents: ClassifiedCalendarEvent[] + isLoading: boolean + onEventClick: (event: any) => void +} + +export const GoogleCalendar2x1: React.FC = ({ + today, + classifiedEvents, + isLoading, + onEventClick, +}) => { + const activeNow = classifiedEvents.find((e) => e.isNow) + const nextUpcoming = classifiedEvents.find((e) => !e.isPast && !e.isNow) + const targetEvent = activeNow || nextUpcoming + + if (isLoading) { + return ( +
+
+
+
+
+
+
+
+
+
+
+ ) + } + + return ( +
+
+
+ + + {today.format('dddd')} + +
+ + {today.format('jD jMMMM')} + + + {classifiedEvents.length > 0 + ? `${classifiedEvents.length} برنامه` + : 'بدون برنامه'} + +
+ +
+ +
+ {targetEvent ? ( + (() => { + const { + event, + isNow, + startTimeStr, + endTimeStr, + durationLabel, + minsRemaining, + } = targetEvent + const hasAction = !!(event.hangoutLink || event.location) + + return ( +
hasAction && onEventClick(event)} + className={`flex flex-col gap-1 p-1.5 rounded-xl transition-all ${ + hasAction + ? 'cursor-pointer hover:bg-base-200/40 active:scale-[0.99]' + : '' + } ${isNow ? 'bg-primary/10 border border-primary/20' : ''}`} + > +
+
+ {isNow ? ( + <> + + + + + + در حال جلسه + + + ) : ( + + {startTimeStr} - {endTimeStr} + + )} +
+ + {event.hangoutLink && ( + + )} +
+ +

+ {event.summary || 'رویداد تقویم'} +

+ +
+ + {isNow + ? `${minsRemaining} دقیقه مانده` + : durationLabel} + + {event.location && ( + + 📍 {event.location} + + )} +
+
+ ) + })() + ) : ( +
+ +
+ + برنامه‌ای نداری + + وقت استراحت و کارهای شخصی +
+
+ )} +
+
+ ) +} diff --git a/src/layouts/widgets/google-calendar/variants/google-calendar-agenda.tsx b/src/layouts/widgets/google-calendar/variants/google-calendar-agenda.tsx new file mode 100644 index 000000000..96eb49a13 --- /dev/null +++ b/src/layouts/widgets/google-calendar/variants/google-calendar-agenda.tsx @@ -0,0 +1,237 @@ +import type React from 'react' +import { useMemo } from 'react' +import jalaliMoment from 'jalali-moment' +import { Icon } from '@/icons' +import type { WidgetifyDate } from '../../calendar/utils/date-events' +import type { GoogleCalendarEvent } from '@/services/hooks/date/get-google-calendar-events.hook' +import { classifyEvent, type ClassifiedCalendarEvent } from '../types' +import { GoogleCalendarEmpty } from '../components/google-calendar-empty' +import { cn } from '@/common/utils/cn' + +interface GoogleCalendarAgendaProps { + rawEvents: GoogleCalendarEvent[] | undefined + isLoading: boolean + today: WidgetifyDate + currentTime: Date + onEventClick: (event: any) => void +} + +export const GoogleCalendarAgenda: React.FC = ({ + rawEvents, + isLoading, + today, + currentTime, + onEventClick, +}) => { + const groupedEvents = useMemo(() => { + if (!rawEvents || rawEvents.length === 0) return [] + + const sorted = [...rawEvents].sort( + (a, b) => + new Date(a.start?.dateTime || a.start?.date || 0).getTime() - + new Date(b.start?.dateTime || b.start?.date || 0).getTime() + ) + + const map = new Map() + const todayIso = today.clone().locale('en').format('YYYY-MM-DD') + + for (const ev of sorted) { + const dateStr = (ev.start?.dateTime || ev.start?.date || '').slice(0, 10) + if (!dateStr) continue + + const isDayToday = dateStr === todayIso + const isDayPast = dateStr < todayIso + + const classified = classifyEvent(ev, currentTime, isDayToday, isDayPast) + + // Exclude past events in upcoming agenda view + if (classified.isPast) continue + + const list = map.get(dateStr) || [] + list.push(classified) + map.set(dateStr, list) + } + + return Array.from(map.entries()) + .filter(([_, items]) => items.length > 0) + .map(([dateStr, items]) => { + const isTodayDay = dateStr === todayIso + const jDate = jalaliMoment(dateStr, 'YYYY-MM-DD') + const isTomorrow = jDate.isSame(today.clone().add(1, 'day'), 'day') + + let dayLabel = jDate.locale('fa').format('dddd jD jMMMM') + if (isTodayDay) { + dayLabel = `امروز (${today.format('jD jMMMM')})` + } else if (isTomorrow) { + dayLabel = `فردا (${jDate.format('jD jMMMM')})` + } + + return { + dateStr, + dayLabel, + isToday: isTodayDay, + items, + } + }) + }, [rawEvents, today, currentTime]) + + return ( +
+
+
+ + + برنامه‌های پیش‌رو + +
+ {today.format('jD jMMMM')} +
+ +
+ {isLoading && ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+
+
+ ))} +
+ )} + + {!isLoading && groupedEvents.length === 0 && ( + + )} + + {!isLoading && + groupedEvents.map( + ({ dateStr, dayLabel, isToday: isGroupToday, items }) => { + return ( +
+
+ + {dayLabel} + +
+
+ +
+ {items.map((item) => { + const { + event, + isNow, + isAllDay, + startTimeStr, + endTimeStr, + durationLabel, + } = item + const hasAction = !!( + event.hangoutLink || event.location + ) + + if (isAllDay) { + return ( +
+ hasAction && + onEventClick(event) + } + className={`flex items-center gap-2 p-2 rounded-xl bg-primary/10 border border-primary/20 text-primary transition-all ${ + hasAction + ? 'cursor-pointer hover:bg-primary/15' + : '' + }`} + > + + + {event.summary || + 'رویداد همه‌روز'} + + + تمام روز + +
+ ) + } + + return ( +
+ hasAction && onEventClick(event) + } + className={cn( + 'flex items-center gap-2 p-2 rounded-xl bg-base-200/30 hover:bg-base-200/60 border border-base-content/5 transition-all', + hasAction + ? 'cursor-pointer active:scale-[0.99]' + : '', + isNow + ? 'ring-1 ring-primary/40 bg-primary/5' + : '' + )} + > +
+
+
+ + {event.summary || + 'بدون عنوان'} + + {isNow && ( + + در حال برگزاری + + )} +
+
+ + {startTimeStr} -{' '} + {endTimeStr} + + · + {durationLabel} +
+
+ + {event.hangoutLink && ( + + )} +
+ ) + })} +
+
+ ) + } + )} +
+
+ ) +} diff --git a/src/layouts/widgets/google-calendar/variants/google-calendar-schedule.tsx b/src/layouts/widgets/google-calendar/variants/google-calendar-schedule.tsx new file mode 100644 index 000000000..52b13d576 --- /dev/null +++ b/src/layouts/widgets/google-calendar/variants/google-calendar-schedule.tsx @@ -0,0 +1,165 @@ +import type React from 'react' +import Analytics from '@/analytics' +import { Icon } from '@/icons' +import type { WidgetifyDate } from '../../calendar/utils/date-events' +import type { ClassifiedCalendarEvent } from '../types' +import { GoogleCalendarWeekStrip } from '../components/google-calendar-week-strip' +import { GoogleCalendarNowCard } from '../components/google-calendar-now-card' +import { GoogleCalendarEventCard } from '../components/google-calendar-event-card' +import { GoogleCalendarEmpty } from '../components/google-calendar-empty' + +interface GoogleCalendarScheduleProps { + selectedDay: WidgetifyDate + setSelectedDay: (day: WidgetifyDate) => void + weekDays: WidgetifyDate[] + eventsByDate: Map + classifiedEvents: ClassifiedCalendarEvent[] + isLoading: boolean + isToday: (day: WidgetifyDate) => boolean + today: WidgetifyDate + onEventClick: (event: any) => void +} + +export const GoogleCalendarSchedule: React.FC = ({ + selectedDay, + setSelectedDay, + weekDays, + eventsByDate, + classifiedEvents, + isLoading, + isToday, + today, + onEventClick, +}) => { + const isCurrentWeek = weekDays.some((d) => isToday(d)) + const isSelectedToday = isToday(selectedDay) + + const handlePrevWeek = () => { + setSelectedDay(selectedDay.clone().subtract(7, 'days')) + Analytics.event('google_calendar_prev_week') + } + + const handleNextWeek = () => { + setSelectedDay(selectedDay.clone().add(7, 'days')) + Analytics.event('google_calendar_next_week') + } + + const handleResetToday = () => { + setSelectedDay(today.clone()) + Analytics.event('google_calendar_reset_today') + } + + return ( +
+ {/* Top Header */} +
+
+ + + {selectedDay.format('jMMMM jYYYY')} + +
+ +
+ {(!isSelectedToday || !isCurrentWeek) && ( + + )} + + +
+
+ + {/* 7-Day Mini Week Strip */} + + + {/* Day Sub-header */} +
+ + {isSelectedToday + ? `امروز، ${selectedDay.format('dddd')}` + : selectedDay.format('dddd jD jMMMM')} + + + {classifiedEvents.length > 0 + ? `${classifiedEvents.length} برنامه` + : 'بدون برنامه'} + +
+ + {/* Events Stream */} +
+ {isLoading && ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+
+
+
+
+
+ ))} +
+ )} + + {!isLoading && classifiedEvents.length === 0 && ( + + )} + + {!isLoading && + classifiedEvents.map((classified) => { + if (classified.isNow) { + return ( + + ) + } + + return ( + + ) + })} +
+
+ ) +} diff --git a/src/layouts/widgets/google-calendar/variants/google-calendar-timeline.tsx b/src/layouts/widgets/google-calendar/variants/google-calendar-timeline.tsx new file mode 100644 index 000000000..af4f9e614 --- /dev/null +++ b/src/layouts/widgets/google-calendar/variants/google-calendar-timeline.tsx @@ -0,0 +1,209 @@ +import type React from 'react' +import Analytics from '@/analytics' +import { Icon } from '@/icons' +import type { WidgetifyDate } from '../../calendar/utils/date-events' +import type { ClassifiedCalendarEvent } from '../types' +import { GoogleCalendarEmpty } from '../components/google-calendar-empty' + +interface GoogleCalendarTimelineProps { + selectedDay: WidgetifyDate + setSelectedDay: React.Dispatch> + classifiedEvents: ClassifiedCalendarEvent[] + isLoading: boolean + isToday: (day: WidgetifyDate) => boolean + today: WidgetifyDate + onEventClick: (event: any) => void +} + +export const GoogleCalendarTimeline: React.FC = ({ + selectedDay, + setSelectedDay, + classifiedEvents, + isLoading, + isToday, + today, + onEventClick, +}) => { + const isSelectedToday = isToday(selectedDay) + + const handlePrevDay = () => { + setSelectedDay((prev) => prev.clone().subtract(1, 'day')) + Analytics.event('google_calendar_prev_day') + } + + const handleNextDay = () => { + setSelectedDay((prev) => prev.clone().add(1, 'day')) + Analytics.event('google_calendar_next_day') + } + + const handleResetToday = () => { + setSelectedDay(today.clone()) + Analytics.event('google_calendar_reset_today') + } + + return ( +
+ {/* Header */} +
+
+ تایم‌لاین +
+ +
+ {!isSelectedToday && ( + + )} + + + + {isSelectedToday + ? `امروز (${selectedDay.format('jD jMMMM')})` + : selectedDay.format('dddd jD jMMMM')} + + + +
+
+ + {/* Timeline Stream */} +
+ {isLoading && ( +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+
+
+
+ ))} +
+ )} + + {!isLoading && classifiedEvents.length === 0 && ( + + )} + + {!isLoading && + classifiedEvents.map((classified, idx) => { + const { + event, + isNow, + isPast, + startTimeStr, + endTimeStr, + durationLabel, + } = classified + const hasAction = !!(event.hangoutLink || event.location) + + return ( +
+ {/* Left time label */} +
+ + {startTimeStr} + + + {endTimeStr} + +
+ + {/* Timeline node */} +
+
+
+
+ {idx < classifiedEvents.length - 1 && ( +
+ )} +
+ + {/* Content Card */} +
hasAction && onEventClick(event)} + className={`flex-1 flex flex-col gap-1 p-2.5 rounded-2xl border transition-all ${ + isNow + ? 'bg-primary/10 border-primary/30 shadow-xs' + : 'bg-base-200/30 hover:bg-base-200/60 border-base-content/5' + } ${hasAction ? 'cursor-pointer active:scale-[0.99]' : ''} ${ + isPast ? 'opacity-40' : '' + }`} + > +
+

+ {event.summary || 'بدون عنوان'} +

+ {isNow && ( + + الان + + )} +
+ +
+ + {durationLabel} + + {event.hangoutLink && ( + + )} +
+
+
+ ) + })} +
+
+ ) +} diff --git a/src/layouts/widgets/grid-geometry.ts b/src/layouts/widgets/grid-geometry.ts new file mode 100644 index 000000000..584de9fbb --- /dev/null +++ b/src/layouts/widgets/grid-geometry.ts @@ -0,0 +1,68 @@ +import type { StoredWidget, WidgetPosition, WidgetSize } from './layout-engine/types' + +export interface PixelRect { + left: number + top: number + width: number + height: number +} + +export function getWidgetPixelRect( + position: WidgetPosition, + size: WidgetSize, + cellWidth: number, + cellHeight: number, + gap: number +): PixelRect { + const left = position.col * (cellWidth + gap) + const top = position.row * (cellHeight + gap) + const width = size.w * cellWidth + (size.w - 1) * gap + const height = size.h * cellHeight + (size.h - 1) * gap + + return { left, top, width, height } +} + +export function getCanvasRowCount(layout: StoredWidget[]): number { + if (layout.length === 0) return 0 + return Math.max(...layout.map((w) => w.position.row + w.size.h), 0) +} + +export function getCanvasHeight( + layout: StoredWidget[], + cellHeight: number, + gap: number +): number { + const rows = getCanvasRowCount(layout) + if (rows === 0) return 0 + return rows * cellHeight + Math.max(0, rows - 1) * gap +} + +export function pixelToGrid( + pixelX: number, + pixelY: number, + cellWidth: number, + cellHeight: number, + gap: number +): WidgetPosition { + const unitW = cellWidth + gap + const unitH = cellHeight + gap + const col = unitW > 0 ? Math.round(pixelX / unitW) : 0 + const row = unitH > 0 ? Math.round(pixelY / unitH) : 0 + return { + col: Math.max(0, col), + row: Math.max(0, row), + } +} + +export function gridToPixel( + col: number, + row: number, + cellWidth: number, + cellHeight: number, + gap: number +): { left: number; top: number } { + return { + left: col * (cellWidth + gap), + top: row * (cellHeight + gap), + } +} diff --git a/src/layouts/widgets/habit/components/habit-calendar-heatmap.tsx b/src/layouts/widgets/habit/components/habit-detail-modal/habit-calendar-heatmap.tsx similarity index 98% rename from src/layouts/widgets/habit/components/habit-calendar-heatmap.tsx rename to src/layouts/widgets/habit/components/habit-detail-modal/habit-calendar-heatmap.tsx index bdbfc5e11..ffd1bc2f3 100644 --- a/src/layouts/widgets/habit/components/habit-calendar-heatmap.tsx +++ b/src/layouts/widgets/habit/components/habit-detail-modal/habit-calendar-heatmap.tsx @@ -5,10 +5,10 @@ import { HabitComparison, type Habit } from '@/services/hooks/habit/habit.interf import { safeAwait } from '@/services/api' import { useLogHabitProgress } from '@/services/hooks/habit/log-habit-progress.hook' import { autoFormatErrorToast, showToast } from '@/common/toast' -import { HABIT_UNIT_STEP } from '@/common/constant/habit-options' +import { HABIT_UNIT_STEP } from '@/common/constants/habit-options' import { useQueryClient } from '@tanstack/react-query' import { IconLoading } from '@/components/ui' -import { Icon } from '@/src/icons' +import { Icon } from '@/icons' interface HabitCalendarProps { habit: Habit diff --git a/src/layouts/widgets/habit/components/habit-detail-modal/habit-contribution-chart.tsx b/src/layouts/widgets/habit/components/habit-detail-modal/habit-contribution-chart.tsx new file mode 100644 index 000000000..a608523b1 --- /dev/null +++ b/src/layouts/widgets/habit/components/habit-detail-modal/habit-contribution-chart.tsx @@ -0,0 +1,345 @@ +import { useMemo, useState } from 'react' +import jalaliMoment from 'jalali-moment' +import moment from 'moment' +import { HabitComparison, type Habit } from '@/services/hooks/habit/habit.interface' +import { useLogHabitProgress } from '@/services/hooks/habit/log-habit-progress.hook' +import { safeAwait } from '@/services/api' +import { autoFormatErrorToast, showToast } from '@/common/toast' +import { HABIT_UNIT_STEP } from '@/common/constants/habit-options' +import { useQueryClient } from '@tanstack/react-query' +import { getHabitUnitLabel } from '../../utils/habit-goal' +import { cn } from '@/common/utils/cn' + +interface HabitContributionChartProps { + habit: Habit + color: string +} + +interface DayCell { + gregorianDate: string + jalaliDate: jalaliMoment.Moment + value: number + isDone: boolean + level: number + isFuture: boolean + isToday: boolean +} + +interface WeekColumn { + weekNumber: number + days: DayCell[] + monthLabel?: string +} + +const DISPLAY_WEEKDAYS = ['ش', 'ی', 'د', 'س', 'چ', 'پ', 'ج'] +const NUM_WEEKS = 26 + +export function HabitContributionChart({ habit, color }: HabitContributionChartProps) { + const queryClient = useQueryClient() + const { mutateAsync: logProgress, isPending: isUpdating } = useLogHabitProgress() + const [hoveredDay, setHoveredDay] = useState(null) + + const today = useMemo(() => jalaliMoment().locale('fa').startOf('day'), []) + + const getHabitData = (gregorianDate: string) => { + const [year, month] = gregorianDate.split('-') + const monthKey = `${year}-${month}` + const monthData = habit.calendarData?.[monthKey] || {} + return monthData[gregorianDate] || { value: 0, isDone: false } + } + + const { weeks, stats } = useMemo(() => { + const start = today + .clone() + .subtract(NUM_WEEKS - 1, 'weeks') + .startOf('week') + const current = start.clone() + const weeksList: WeekColumn[] = [] + + let currentStreak = 0 + let longestStreak = 0 + let tempStreak = 0 + let totalCompleted = 0 + let totalTrackedDays = 0 + + const allDaysChronological: DayCell[] = [] + let lastMonthName = '' + + for (let w = 0; w < NUM_WEEKS; w++) { + const weekDays: DayCell[] = [] + let weekMonthLabel = '' + + for (let d = 0; d < 7; d++) { + const dayDate = current.clone() + const gregorianDate = moment(dayDate.toDate()).format('YYYY-MM-DD') + const habitData = getHabitData(gregorianDate) + const isDayToday = dayDate.isSame(today, 'day') + const isFuture = dayDate.isAfter(today, 'day') + + const value = habitData.value + const isDone = + habitData.isDone || (habit.target > 0 && value >= habit.target) + + let level = 0 + if (value > 0) { + if (habit.target > 0) { + const ratio = value / habit.target + if (ratio >= 1.5) level = 4 + else if (ratio >= 1) level = 3 + else if (ratio >= 0.5) level = 2 + else level = 1 + } else { + level = 3 + } + } + + const cell: DayCell = { + gregorianDate, + jalaliDate: dayDate, + value, + isDone, + level, + isFuture, + isToday: isDayToday, + } + + weekDays.push(cell) + + if (!isFuture) { + allDaysChronological.push(cell) + totalTrackedDays++ + if (isDone) totalCompleted++ + } + + const monthName = dayDate.format('jMMMM') + if (d === 0 && monthName !== lastMonthName) { + weekMonthLabel = monthName + lastMonthName = monthName + } + + current.add(1, 'day') + } + + weeksList.push({ + weekNumber: w, + days: weekDays, + monthLabel: weekMonthLabel, + }) + } + + for (const day of allDaysChronological) { + if (day.isDone) { + tempStreak++ + if (tempStreak > longestStreak) longestStreak = tempStreak + } else { + tempStreak = 0 + } + } + + const lastIdx = allDaysChronological.length - 1 + if (lastIdx >= 0) { + let checkIdx = lastIdx + if ( + !allDaysChronological[checkIdx].isDone && + checkIdx > 0 && + allDaysChronological[checkIdx - 1].isDone + ) { + checkIdx-- + } + while (checkIdx >= 0 && allDaysChronological[checkIdx].isDone) { + currentStreak++ + checkIdx-- + } + } + + const completionRate = + totalTrackedDays > 0 + ? Math.round((totalCompleted / totalTrackedDays) * 100) + : 0 + + return { + weeks: weeksList, + stats: { + currentStreak, + longestStreak, + totalCompleted, + completionRate, + }, + } + }, [habit, color, today]) + + const handleDayClick = async (cell: DayCell) => { + if (cell.isFuture || isUpdating) return + + let step = HABIT_UNIT_STEP[habit.unit] || 1 + const currentVal = cell.value + + if ( + habit.comparison === HabitComparison.EXACT && + currentVal + step > habit.target + ) { + step = 0 + } + + if ( + habit.comparison === HabitComparison.AT_MOST && + currentVal + step > habit.target + ) { + showToast(`مقدار فعلی به حداکثر هدف شما (${habit.target}) رسیده`, 'error') + return + } + + const [error] = await safeAwait( + logProgress({ + id: habit.id, + input: { date: cell.gregorianDate, amount: step }, + }) + ) + + if (error) { + autoFormatErrorToast(error) + return + } + + queryClient.invalidateQueries({ queryKey: ['get-habit-detail', habit.id] }) + } + + const getCellColor = (level: number) => { + switch (level) { + case 1: + return `${color}33` + case 2: + return `${color}66` + case 3: + return `${color}aa` + case 4: + return color + default: + return undefined + } + } + + const unitLabel = getHabitUnitLabel(habit) + + return ( +
+
+
+
+
+ {weeks.map((week) => ( +
+ {week.monthLabel || ''} +
+ ))} +
+ +
+
+ {DISPLAY_WEEKDAYS.map((dayName, idx) => ( +
+ {dayName} +
+ ))} +
+ +
+ {weeks.map((week) => ( +
+ {week.days.map((day) => { + const cellBg = getCellColor(day.level) + return ( +
+ ))} +
+
+
+
+ +
+
+ {hoveredDay ? ( + <> + + {hoveredDay.jalaliDate.format('dddd، jD jMMMM')} + + : + + {hoveredDay.value > 0 + ? `${hoveredDay.value} ${unitLabel}` + : 'بدون ثبت'} + + {hoveredDay.isDone && ( + + (انجام شد) + + )} + + ) : ( + برای ثبت، روی روزها کلیک کن + )} +
+ +
+ کمتر +
+
+
+
+
+ بیشتر +
+
+
+
+ ) +} diff --git a/src/layouts/widgets/habit/components/habit-detail-modal/habit-stats-cards.tsx b/src/layouts/widgets/habit/components/habit-detail-modal/habit-stats-cards.tsx new file mode 100644 index 000000000..8d0d321f4 --- /dev/null +++ b/src/layouts/widgets/habit/components/habit-detail-modal/habit-stats-cards.tsx @@ -0,0 +1,136 @@ +import React, { useMemo } from 'react' +import type { Habit } from '@/services/hooks/habit/habit.interface' +import jalaliMoment from 'jalali-moment' +import moment from 'moment' + +interface HabitStatsCardsProps { + habit: Habit +} + +const NUM_WEEKS = 26 + +export const HabitStatsCards: React.FC = React.memo(({ habit }) => { + const today = useMemo(() => jalaliMoment().locale('fa').startOf('day'), []) + + const stats = useMemo(() => { + const start = today + .clone() + .subtract(NUM_WEEKS - 1, 'weeks') + .startOf('week') + const current = start.clone() + + let currentStreak = 0 + let longestStreak = 0 + let tempStreak = 0 + let totalCompleted = 0 + let totalTrackedDays = 0 + + const allDaysChronological: { isDone: boolean }[] = [] + + for (let w = 0; w < NUM_WEEKS; w++) { + for (let d = 0; d < 7; d++) { + const dayDate = current.clone() + const gregorianDate = moment(dayDate.toDate()).format('YYYY-MM-DD') + const [year, month] = gregorianDate.split('-') + const monthKey = `${year}-${month}` + const monthData = habit.calendarData?.[monthKey] || {} + const habitData = monthData[gregorianDate] || { value: 0, isDone: false } + const isFuture = dayDate.isAfter(today, 'day') + + const value = habitData.value + const isDone = + habitData.isDone || (habit.target > 0 && value >= habit.target) + + if (!isFuture) { + allDaysChronological.push({ isDone }) + totalTrackedDays++ + if (isDone) totalCompleted++ + } + + current.add(1, 'day') + } + } + + for (const day of allDaysChronological) { + if (day.isDone) { + tempStreak++ + if (tempStreak > longestStreak) longestStreak = tempStreak + } else { + tempStreak = 0 + } + } + + const lastIdx = allDaysChronological.length - 1 + if (lastIdx >= 0) { + let checkIdx = lastIdx + if ( + !allDaysChronological[checkIdx].isDone && + checkIdx > 0 && + allDaysChronological[checkIdx - 1].isDone + ) { + checkIdx-- + } + while (checkIdx >= 0 && allDaysChronological[checkIdx].isDone) { + currentStreak++ + checkIdx-- + } + } + + const completionRate = + totalTrackedDays > 0 + ? Math.round((totalCompleted / totalTrackedDays) * 100) + : 0 + + return { + currentStreak, + longestStreak, + totalCompleted, + completionRate, + } + }, [habit, today]) + + return ( +
+
+
استریک فعلی
+
+ + {stats.currentStreak} + + روز +
+
+ +
+
بهترین استریک
+
+ + {stats.longestStreak} + + روز +
+
+ +
+
روزهای موفق
+
+ + {stats.totalCompleted} + + روز +
+
+ +
+
نرخ موفقیت
+
+ + {stats.completionRate}٪ + +
+
+
+ ) +}) + +HabitStatsCards.displayName = 'HabitStatsCards' diff --git a/src/layouts/widgets/habit/components/habit-detail-modal/index.tsx b/src/layouts/widgets/habit/components/habit-detail-modal/index.tsx new file mode 100644 index 000000000..00aa8991d --- /dev/null +++ b/src/layouts/widgets/habit/components/habit-detail-modal/index.tsx @@ -0,0 +1,234 @@ +import { lazy, Suspense, useState } from 'react' +import { Button, Modal } from '@/components/ui' +import { useGetHabitDetail } from '@/services/hooks/habit/get-habit-detail.hook' +import { HabitCalendar } from './habit-calendar-heatmap' +import { HabitContributionChart } from './habit-contribution-chart' +import { HabitStatsCards } from './habit-stats-cards' +import { useAuth } from '@/context/auth.context' +import { formatHabitGoal } from '../../utils/habit-goal' +import { Dropdown } from '@/components/ui' +import type { Habit } from '@/services/hooks/habit/habit.interface' +import { callEvent } from '@/common/utils/call-event' +import { Icon } from '@/icons' +import { cn } from '@/common/utils/cn' + +const HabitShareModal = lazy(() => + import('../habit-share-modal').then((module) => ({ + default: module.HabitShareModal, + })) +) + +interface ModalProps { + isOpen: boolean + habitId: string | null + onClose: () => void + onEdit: (habit: Habit) => void + onArchive: () => void +} + +export function HabitDetailModal({ + isOpen, + habitId, + onClose, + onArchive, + onEdit, +}: ModalProps) { + const { isAuthenticated } = useAuth() + const [activeView, setActiveView] = useState<'contribution' | 'calendar'>( + 'contribution' + ) + const [isShareModalOpen, setIsShareModalOpen] = useState(false) + + const onClickEdit = () => { + if (habit) { + callEvent('closeAllDropdowns') + onEdit(habit) + } + } + + const onClickArchive = () => { + if (habit) { + callEvent('closeAllDropdowns') + onArchive() + } + } + + const { + data: habit, + isLoading, + error, + } = useGetHabitDetail(habitId || '', isOpen && isAuthenticated) + + const color = habit?.color || '#536dfe' + + const title = + isLoading || !habit ? ( +
+
+
+
+
+
+
+ ) : ( +
+
+ {habit.emoji || '🎯'} +
+ +
+
+

{habit.title}

+ + + + } + > +
+ + + +
+
+
+

+ {formatHabitGoal(habit)} +

+
+
+ ) + + return ( + <> + + {isLoading ? ( +
+
+
+ ) : error ? ( +
+

+ خطا در دریافت اطلاعات +

+

+ لطفا چند لحظه دیگر دوباره تلاش کنید +

+
+ ) : !habit ? ( +
+
📭
+

اطلاعات این عادت پیدا نشد

+
+ ) : ( +
+ + +
+
+
+ + +
+
+ + {activeView === 'contribution' ? ( + + ) : ( + + )} +
+
+ )} + +
+ + +
+ + + {habit && isShareModalOpen && ( + + setIsShareModalOpen(false)} + habit={habit} + color={color} + /> + + )} + + ) +} diff --git a/src/layouts/widgets/habit/components/habit-detail.modal.tsx b/src/layouts/widgets/habit/components/habit-detail.modal.tsx deleted file mode 100644 index 373b0cac1..000000000 --- a/src/layouts/widgets/habit/components/habit-detail.modal.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import { Button, Modal } from '@/components/ui' -import { useGetHabitDetail } from '@/services/hooks/habit/get-habit-detail.hook' -import { HabitCalendar } from './habit-calendar-heatmap' -import { useAuth } from '@/context/auth.context' -import { formatHabitGoal } from '../utils' -import { Dropdown } from '@/components/ui' -import type { Habit } from '@/services/hooks/habit/habit.interface' -import { callEvent } from '@/common/utils/call-event' -import { Icon } from '@/src/icons' - -interface ModalProps { - isOpen: boolean - habitId: string | null - onClose: () => void - onEdit: (habit: Habit) => void - onArchive: () => void -} - -export function HabitDetailModal({ - isOpen, - habitId, - onClose, - onArchive, - onEdit, -}: ModalProps) { - const { isAuthenticated } = useAuth() - const onClickEdit = () => { - if (habit) { - callEvent('closeAllDropdowns') - onEdit(habit) - } - } - - const onClickArchive = () => { - if (habit) { - callEvent('closeAllDropdowns') - onArchive() - } - } - - const { - data: habit, - isLoading, - error, - } = useGetHabitDetail(habitId || '', isOpen && isAuthenticated) - - const color = habit?.color || '#536dfe' - - const title = - isLoading || !habit ? ( -
-
-
-
-
-
-
- ) : ( -
-
- {habit.emoji || '🎯'} -
- -
-
-

{habit.title}

- - - - } - > -
- - - -
-
-
-

- {formatHabitGoal(habit)} -

-
-
- ) - - return ( - - {isLoading ? ( -
-
-
- ) : error ? ( -
-

- خطا در دریافت اطلاعات -

-

- لطفا چند لحظه دیگر دوباره تلاش کنید. -

-
- ) : !habit ? ( -
-
📭
-

اطلاعات این عادت پیدا نشد.

-
- ) : ( -
-
- -
-
- )} - - ) -} diff --git a/src/layouts/widgets/habit/components/habit-form-modal/constants.ts b/src/layouts/widgets/habit/components/habit-form-modal/constants.ts new file mode 100644 index 000000000..d97ab9e7c --- /dev/null +++ b/src/layouts/widgets/habit/components/habit-form-modal/constants.ts @@ -0,0 +1,111 @@ +import type { CreateHabitInput } from '@/services/hooks/habit/habit.interface' +import { + HabitComparison, + HabitFrequency, + HabitUnit, +} from '@/services/hooks/habit/habit.interface' + +export interface HabitPresetItem { + id: string + label: string + emoji: string + color: string + values: CreateHabitInput +} + +export const HABIT_QUICK_PRESETS: HabitPresetItem[] = [ + { + id: 'water', + label: '۸ لیوان آب', + emoji: '💧', + color: '#3b82f6', + values: { + title: 'نوشیدن آب', + emoji: '💧', + color: '#3b82f6', + comparison: HabitComparison.AT_LEAST, + unit: HabitUnit.GLASSES, + target: 8, + frequency: HabitFrequency.DAILY, + frequencyCount: 1, + }, + }, + { + id: 'walk', + label: '۳ کیلومتر پیاده‌روی', + emoji: '🏃', + color: '#f97316', + values: { + title: 'پیاده‌روی', + emoji: '🏃', + color: '#f97316', + comparison: HabitComparison.AT_LEAST, + unit: HabitUnit.CUSTOM, + customUnit: 'کیلومتر', + target: 3, + frequency: HabitFrequency.DAILY, + frequencyCount: 1, + }, + }, + { + id: 'book', + label: '۲۰ صفحه کتاب', + emoji: '📖', + color: '#06b6d4', + values: { + title: 'مطالعه کتاب', + emoji: '📖', + color: '#06b6d4', + comparison: HabitComparison.AT_LEAST, + unit: HabitUnit.PAGES, + target: 20, + frequency: HabitFrequency.DAILY, + frequencyCount: 1, + }, + }, + { + id: 'meditation', + label: 'مدیتیشن', + emoji: '🧘', + color: '#8b5cf6', + values: { + title: 'مدیتیشن و تنفس', + emoji: '🧘', + color: '#8b5cf6', + comparison: HabitComparison.AT_LEAST, + unit: HabitUnit.MINUTES, + target: 15, + frequency: HabitFrequency.DAILY, + frequencyCount: 1, + }, + }, +] + +export interface EmojiCategory { + id: string + label: string + emojis: string[] +} + +export const HABIT_EMOJI_CATEGORIES: EmojiCategory[] = [ + { + id: 'health', + label: 'سلامتی', + emojis: ['💧', '🥗', '💊', '😴', '🧘', '🦷', '🚭'], + }, + { + id: 'sport', + label: 'ورزش', + emojis: ['🏃', '🚴', '🏋️', '🏊', '⚽', '🤸', '🧗'], + }, + { + id: 'study', + label: 'مطالعه', + emojis: ['📖', '✍️', '💻', '🎨', '🧠', '🎧', '🎯'], + }, + { + id: 'lifestyle', + label: 'سبک زندگی', + emojis: ['☕', '🍵', '🧹', '🪴', '🍎', '🚶', '✨'], + }, +] diff --git a/src/layouts/widgets/habit/components/habit-form-modal/habit-form-actions.tsx b/src/layouts/widgets/habit/components/habit-form-modal/habit-form-actions.tsx new file mode 100644 index 000000000..a0111209e --- /dev/null +++ b/src/layouts/widgets/habit/components/habit-form-modal/habit-form-actions.tsx @@ -0,0 +1,46 @@ +import React from 'react' +import { Button } from '@/components/ui' + +interface HabitFormActionsProps { + isEdit: boolean + isPending: boolean + onClose: () => void + onSubmit: () => void +} + +export const HabitFormActions: React.FC = React.memo( + ({ isEdit, isPending, onClose, onSubmit }) => { + return ( +
+ + + +
+ ) + } +) + +HabitFormActions.displayName = 'HabitFormActions' diff --git a/src/layouts/widgets/habit/components/habit-form-modal/habit-form-header.tsx b/src/layouts/widgets/habit/components/habit-form-modal/habit-form-header.tsx new file mode 100644 index 000000000..7f852d9d5 --- /dev/null +++ b/src/layouts/widgets/habit/components/habit-form-modal/habit-form-header.tsx @@ -0,0 +1,34 @@ +import React from 'react' + +interface HabitFormHeaderProps { + isEdit: boolean + onClose: () => void +} + +export const HabitFormHeader: React.FC = React.memo( + ({ isEdit, onClose }) => { + return ( +
+
+

+ {isEdit ? 'ویرایش عادت' : 'عادت جدید'} +

+

+ از یک الگو شروع کن یا خودت بساز +

+
+ + +
+ ) + } +) + +HabitFormHeader.displayName = 'HabitFormHeader' diff --git a/src/layouts/widgets/habit/components/habit-form-modal/habit-form-presets.tsx b/src/layouts/widgets/habit/components/habit-form-modal/habit-form-presets.tsx new file mode 100644 index 000000000..7c962eb0a --- /dev/null +++ b/src/layouts/widgets/habit/components/habit-form-modal/habit-form-presets.tsx @@ -0,0 +1,35 @@ +import React from 'react' +import type { HabitPresetItem } from './constants' +import { Chip } from '@/components/ui' + +interface HabitFormPresetsProps { + presets: HabitPresetItem[] + activePresetId: string | null + onSelectPreset: (preset: HabitPresetItem) => void +} + +export const HabitFormPresets: React.FC = React.memo( + ({ presets, activePresetId, onSelectPreset }) => { + return ( +
+ {presets.map((preset) => { + const isSelected = activePresetId === preset.id + + return ( + onSelectPreset(preset)} + className="flex items-center gap-1.5 shrink-0 px-1.5 py-1" + > + {preset.emoji} + {preset.label} + + ) + })} +
+ ) + } +) + +HabitFormPresets.displayName = 'HabitFormPresets' diff --git a/src/layouts/widgets/habit/components/habit-form-modal/habit-goal-frequency-section.tsx b/src/layouts/widgets/habit/components/habit-form-modal/habit-goal-frequency-section.tsx new file mode 100644 index 000000000..4e0f19e03 --- /dev/null +++ b/src/layouts/widgets/habit/components/habit-form-modal/habit-goal-frequency-section.tsx @@ -0,0 +1,286 @@ +import React, { useCallback, useMemo } from 'react' +import { + type HabitComparison, + type HabitFrequency, + HabitUnit, +} from '@/services/hooks/habit/habit.interface' +import { + HABIT_COMPARISON_OPTIONS, + HABIT_FREQUENCY_OPTIONS, + HABIT_UNIT_OPTIONS, +} from '@/common/constants/habit-options' +import { Chip, Dropdown, type DropdownOption, TextInput } from '@/components/ui' +import { Icon } from '@/icons' + +interface HabitGoalFrequencySectionProps { + target: number + unit: HabitUnit + customUnit?: string + frequency: HabitFrequency + comparison: HabitComparison + onChangeTarget: (val: number) => void + onChangeUnit: (val: HabitUnit) => void + onChangeCustomUnit?: (val: string) => void + onChangeFrequency: (val: HabitFrequency) => void + onChangeComparison: (val: HabitComparison) => void +} + +const PRIMARY_UNIT_IDS: HabitUnit[] = [ + HabitUnit.TIMES, + HabitUnit.MINUTES, + HabitUnit.GLASSES, + HabitUnit.PAGES, +] + +const MORE_UNIT_IDS: HabitUnit[] = [HabitUnit.HOURS, HabitUnit.CUSTOM] + +interface StepperCounterProps { + target: number + onDecrease: () => void + onIncrease: () => void +} + +const StepperCounter = React.memo( + ({ target, onDecrease, onIncrease }) => { + return ( +
+ + + {target} + + +
+ ) + } +) +StepperCounter.displayName = 'StepperCounter' + +interface UnitSelectionRowProps { + unit: HabitUnit + onChangeUnit: (val: HabitUnit) => void +} + +const UnitSelectionRow = React.memo(({ unit, onChangeUnit }) => { + const primaryUnits = useMemo( + () => + PRIMARY_UNIT_IDS.map((id) => ({ + id, + label: HABIT_UNIT_OPTIONS.find((opt) => opt.value === id)?.label || '', + })), + [] + ) + + const moreUnits = useMemo( + () => + MORE_UNIT_IDS.map((id) => ({ + id, + label: HABIT_UNIT_OPTIONS.find((opt) => opt.value === id)?.label || '', + })), + [] + ) + + const isMoreUnitSelected = MORE_UNIT_IDS.includes(unit) + const selectedMoreUnit = moreUnits.find((u) => u.id === unit) + + const moreUnitDropdownOptions: DropdownOption[] = useMemo( + () => + moreUnits.map((opt) => ({ + id: opt.id, + label: ( +
+ {opt.label} + {unit === opt.id && ( + + )} +
+ ), + onClick: () => onChangeUnit(opt.id), + })), + [moreUnits, unit, onChangeUnit] + ) + + return ( +
+ {primaryUnits.map((opt) => { + const isSelected = unit === opt.id + + return ( + onChangeUnit(opt.id)} + className="px-2.5 py-1 rounded-full text-xs font-medium shrink-0" + > + {opt.label} + + ) + })} + + {}} + className="flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium shrink-0" + > + + {isMoreUnitSelected ? selectedMoreUnit?.label : 'بیشتر'} + + + + } + options={moreUnitDropdownOptions} + position="bottom-left" + width="8rem" + /> +
+ ) +}) +UnitSelectionRow.displayName = 'UnitSelectionRow' + +interface FrequencyComparisonRowProps { + frequency: HabitFrequency + comparison: HabitComparison + onChangeFrequency: (val: HabitFrequency) => void + onChangeComparison: (val: HabitComparison) => void +} + +const FrequencyComparisonRow = React.memo( + ({ frequency, comparison, onChangeFrequency, onChangeComparison }) => { + const comparisonDropdownOptions: DropdownOption[] = useMemo( + () => + HABIT_COMPARISON_OPTIONS.map((opt) => ({ + id: opt.value, + label: ( +
+ {opt.label} + {comparison === opt.value && ( + + )} +
+ ), + onClick: () => onChangeComparison(opt.value as HabitComparison), + })), + [comparison, onChangeComparison] + ) + + const currentComparisonLabel = + HABIT_COMPARISON_OPTIONS.find((c) => c.value === comparison)?.label || 'حداقل' + + return ( +
+
+ تکرار +
+ {HABIT_FREQUENCY_OPTIONS.map((freqOpt) => ( + + onChangeFrequency(freqOpt.value as HabitFrequency) + } + className="px-3 py-1 text-xs font-medium rounded-full" + > + {freqOpt.label} + + ))} +
+
+ + + {currentComparisonLabel} + +
+ } + options={comparisonDropdownOptions} + position="bottom-left" + width="9rem" + /> +
+ ) + } +) +FrequencyComparisonRow.displayName = 'FrequencyComparisonRow' + +export const HabitGoalFrequencySection: React.FC = + React.memo( + ({ + target, + unit, + customUnit = '', + frequency, + comparison, + onChangeTarget, + onChangeUnit, + onChangeCustomUnit, + onChangeFrequency, + onChangeComparison, + }) => { + const handleDecrease = useCallback(() => { + if (target > 1) { + onChangeTarget(target - 1) + } + }, [target, onChangeTarget]) + + const handleIncrease = useCallback(() => { + onChangeTarget(target + 1) + }, [target, onChangeTarget]) + + return ( +
+
+
+ + هدف روزانه + + +
+ + +
+ + {unit === HabitUnit.CUSTOM && onChangeCustomUnit && ( +
+ + نام واحد دلخواه: + + onChangeCustomUnit(val)} + placeholder="مثال: کیلومتر، ست، فنجان" + size="sm" + className="flex-1 text-xs" + /> +
+ )} + + +
+ ) + }, + ) + +HabitGoalFrequencySection.displayName = 'HabitGoalFrequencySection' diff --git a/src/layouts/widgets/habit/components/habit-form-modal/habit-icon-color-picker.tsx b/src/layouts/widgets/habit/components/habit-form-modal/habit-icon-color-picker.tsx new file mode 100644 index 000000000..b97f81ed3 --- /dev/null +++ b/src/layouts/widgets/habit/components/habit-form-modal/habit-icon-color-picker.tsx @@ -0,0 +1,136 @@ +import React, { useCallback, useState } from 'react' +import { HABIT_EMOJI_CATEGORIES } from './constants' +import { HABIT_COLOR_PRESETS } from '@/common/constants/habit-options' +import { Chip, ColorPicker } from '@/components/ui' +import { cn } from '@/common/utils/cn' + +interface HabitIconColorPickerProps { + selectedEmoji: string + selectedColor: string + onSelectEmoji: (emoji: string) => void + onSelectColor: (color: string) => void +} + +interface EmojiItemProps { + emoji: string + isSelected: boolean + onSelect: (emoji: string) => void +} + +const EmojiItem = React.memo(({ emoji, isSelected, onSelect }) => { + const handleClick = useCallback(() => { + onSelect(emoji) + }, [emoji, onSelect]) + + return ( + + ) +}) +EmojiItem.displayName = 'EmojiItem' + +interface ColorSwatchProps { + color: string + isSelected: boolean + onSelect: (color: string) => void +} + +const ColorSwatch = React.memo(({ color, isSelected, onSelect }) => { + const handleClick = useCallback(() => { + onSelect(color) + }, [color, onSelect]) + + return ( + + ) +}) +ColorSwatch.displayName = 'ColorSwatch' + +export const HabitIconColorPicker: React.FC = React.memo( + ({ selectedEmoji, selectedColor, onSelectEmoji, onSelectColor }) => { + const [activeCategory, setActiveCategory] = useState('health') + + const currentCategory = HABIT_EMOJI_CATEGORIES.find( + (c) => c.id === activeCategory + ) + + return ( +
+
+
+ شکلک +
+ {HABIT_EMOJI_CATEGORIES.map((cat) => ( + setActiveCategory(cat.id)} + key={cat.id} + selected={activeCategory === cat.id} + className="py-1! px-1.5! border-none text-[11px]" + > + {cat.label} + + ))} +
+
+ +
+ {currentCategory?.emojis.map((emoji) => ( + + ))} +
+
+ +
+
+ رنگ +
+ +
+
+ +
+ {HABIT_COLOR_PRESETS.map((color) => ( + + ))} +
+
+
+ ) + } +) + +HabitIconColorPicker.displayName = 'HabitIconColorPicker' diff --git a/src/layouts/widgets/habit/components/habit-form-modal/habit-live-preview.tsx b/src/layouts/widgets/habit/components/habit-form-modal/habit-live-preview.tsx new file mode 100644 index 000000000..d59a2dac3 --- /dev/null +++ b/src/layouts/widgets/habit/components/habit-form-modal/habit-live-preview.tsx @@ -0,0 +1,70 @@ +import React from 'react' +import { addOpacityToColor } from '@/common/color' +import { + HABIT_FREQUENCY_OPTIONS, + HABIT_UNIT_OPTIONS, +} from '@/common/constants/habit-options' +import { type HabitFrequency, HabitUnit } from '@/services/hooks/habit/habit.interface' +import { Icon } from '@/icons' + +interface HabitLivePreviewProps { + title: string + emoji: string + color: string + target: number + unit: HabitUnit + customUnit?: string + frequency: HabitFrequency +} + +export const HabitLivePreview: React.FC = React.memo( + ({ title, emoji, color, target, unit, customUnit, frequency }) => { + const defaultUnitLabel = + HABIT_UNIT_OPTIONS.find((opt) => opt.value === unit)?.label || '' + const unitText = unit === HabitUnit.CUSTOM ? customUnit || '' : defaultUnitLabel + const freqText = + HABIT_FREQUENCY_OPTIONS.find((opt) => opt.value === frequency)?.label || '' + const displayTitle = title.trim() || 'عنوان عادت' + + return ( +
+ پیش‌نمایش +
+
+
+ {emoji || '💧'} +
+ +
+ + {displayTitle} + + + {`۰ از ${target} ${unitText} • ${freqText}`} + +
+
+ +
+ +
+
+
+ ) + } +) + +HabitLivePreview.displayName = 'HabitLivePreview' diff --git a/src/layouts/widgets/habit/components/habit-form-modal/index.tsx b/src/layouts/widgets/habit/components/habit-form-modal/index.tsx new file mode 100644 index 000000000..bf883ac13 --- /dev/null +++ b/src/layouts/widgets/habit/components/habit-form-modal/index.tsx @@ -0,0 +1,254 @@ +import { useCallback, useEffect, useState } from 'react' +import Analytics from '@/analytics' +import { HABIT_COLOR_PRESETS, HABIT_EMOJI_PRESETS } from '@/common/constants/habit-options' +import { showToast } from '@/common/toast' +import { Modal, TextInput } from '@/components/ui' +import { + type CreateHabitInput, + type Habit, + HabitComparison, + HabitFrequency, + HabitUnit, +} from '@/services/hooks/habit/habit.interface' +import { useAddHabit } from '@/services/hooks/habit/add-habit.hook' +import { useUpdateHabit } from '@/services/hooks/habit/update-habit.hook' +import type { HabitIcon } from '@/services/hooks/habit/get-habits.hook' +import { addOpacityToColor } from '@/common/color' +import { safeAwait } from '@/services/api' +import { translateError } from '@/common/utils/translate-error' +import { HabitFormActions } from './habit-form-actions' +import { HABIT_QUICK_PRESETS, type HabitPresetItem } from './constants' +import { HabitFormHeader } from './habit-form-header' +import { HabitFormPresets } from './habit-form-presets' +import { HabitGoalFrequencySection } from './habit-goal-frequency-section' +import { HabitIconColorPicker } from './habit-icon-color-picker' +import { HabitLivePreview } from './habit-live-preview' + +export interface HabitFormModalProps { + isOpen: boolean + habit: Habit | null + onClose: () => void + onSaved: () => void + icons: HabitIcon[] + colors: string[] +} + +export const HabitFormModal: React.FC = ({ + isOpen, + onClose, + onSaved, + habit, +}) => { + const isEdit = Boolean(habit) + + const [title, setTitle] = useState('') + const [emoji, setEmoji] = useState(HABIT_EMOJI_PRESETS[0] || '💧') + const [color, setColor] = useState(HABIT_COLOR_PRESETS[0] || '#3b82f6') + const [comparison, setComparison] = useState( + HabitComparison.AT_LEAST + ) + const [unit, setUnit] = useState(HabitUnit.TIMES) + const [customUnit, setCustomUnit] = useState('') + const [target, setTarget] = useState(1) + const [frequency, setFrequency] = useState(HabitFrequency.DAILY) + const [frequencyCount, setFrequencyCount] = useState(1) + const [activePresetId, setActivePresetId] = useState(null) + + const { mutateAsync: addHabit, isPending: isAdding } = useAddHabit() + const { mutateAsync: updateHabit, isPending: isUpdating } = useUpdateHabit() + const isPending = isAdding || isUpdating + + useEffect(() => { + if (!isOpen) return + + if (habit) { + setTitle(habit.title) + setEmoji(habit.emoji || HABIT_EMOJI_PRESETS[0]) + setColor(habit.color || HABIT_COLOR_PRESETS[0]) + setComparison(habit.comparison || HabitComparison.AT_LEAST) + setUnit(habit.unit || HabitUnit.TIMES) + setCustomUnit(habit.customUnit || '') + setTarget(habit.target || 1) + setFrequency(habit.frequency || HabitFrequency.DAILY) + setFrequencyCount(habit.frequencyCount || 1) + setActivePresetId(null) + } else { + setTitle('') + setEmoji(HABIT_EMOJI_PRESETS[0] || '💧') + setColor(HABIT_COLOR_PRESETS[0] || '#3b82f6') + setComparison(HabitComparison.AT_LEAST) + setUnit(HabitUnit.TIMES) + setCustomUnit('') + setTarget(1) + setFrequency(HabitFrequency.DAILY) + setFrequencyCount(1) + setActivePresetId(null) + } + }, [habit, isOpen]) + + const handleSelectPreset = useCallback((preset: HabitPresetItem) => { + setActivePresetId(preset.id) + setTitle(preset.values.title) + setEmoji(preset.values.emoji || '💧') + setColor(preset.values.color || '#3b82f6') + setComparison(preset.values.comparison || HabitComparison.AT_LEAST) + setUnit(preset.values.unit || HabitUnit.GLASSES) + setCustomUnit(preset.values.customUnit || '') + setTarget(preset.values.target || 1) + setFrequency(preset.values.frequency || HabitFrequency.DAILY) + setFrequencyCount(preset.values.frequencyCount || 1) + }, []) + + const handleEmojiChange = useCallback((newEmoji: string) => { + setEmoji(newEmoji) + setActivePresetId(null) + }, []) + + const handleColorChange = useCallback((newColor: string) => { + setColor(newColor) + setActivePresetId(null) + }, []) + + const handleChangeUnit = useCallback((newUnit: HabitUnit) => { + setUnit(newUnit) + }, []) + + const handleChangeCustomUnit = useCallback((newCustomUnit: string) => { + setCustomUnit(newCustomUnit) + }, []) + + const handleChangeFrequency = useCallback((newFreq: HabitFrequency) => { + setFrequency(newFreq) + }, []) + + const handleChangeComparison = useCallback((newComp: HabitComparison) => { + setComparison(newComp) + }, []) + + const handleSubmit = async () => { + if (!title.trim()) { + showToast('عنوان عادت را وارد کنید.', 'error') + return + } + + if (unit === HabitUnit.CUSTOM && !customUnit.trim()) { + showToast('واحد دلخواه را وارد کنید.', 'error') + return + } + + if (target <= 0) { + showToast('هدف باید بزرگتر از صفر باشد', 'error') + return + } + + const payload: CreateHabitInput = { + title: title.trim(), + emoji, + color, + comparison, + unit, + customUnit: unit === HabitUnit.CUSTOM ? customUnit.trim() : undefined, + target, + frequency, + frequencyCount: frequency === HabitFrequency.DAILY ? 1 : frequencyCount || 1, + } + + const [error] = await safeAwait( + isEdit ? updateHabit({ id: habit!.id, input: payload }) : addHabit(payload) + ) + + if (error) { + showToast(translateError(error) as string, 'error') + return + } + + showToast(isEdit ? 'عادت ویرایش شد.' : 'عادت جدید اضافه شد.', 'success') + Analytics.event(isEdit ? 'habit_updated' : 'habit_created') + onSaved() + } + + return ( + +
+ + + {!isEdit && ( + + )} + +
+ +
+
+ {emoji || '💧'} +
+ + { + setTitle(val) + setActivePresetId(null) + }} + placeholder="عنوان عادت (مثلا: نوشیدن آب)" + className="flex-1 text-sm font-medium bg-transparent border-none shadow-none text-content placeholder:text-muted/60 focus:outline-none focus:ring-0" + /> +
+
+ + + + + + + + +
+
+ ) +} diff --git a/src/layouts/widgets/habit/components/habit-form.modal.tsx b/src/layouts/widgets/habit/components/habit-form.modal.tsx deleted file mode 100644 index 00b1c58b6..000000000 --- a/src/layouts/widgets/habit/components/habit-form.modal.tsx +++ /dev/null @@ -1,381 +0,0 @@ -import { useEffect, useState } from 'react' -import Analytics from '@/analytics' -import { - HABIT_COLOR_PRESETS, - HABIT_COMPARISON_OPTIONS, - HABIT_EMOJI_PRESETS, - HABIT_FREQUENCY_OPTIONS, - HABIT_UNIT_OPTIONS, -} from '@/common/constant/habit-options' -import { showToast } from '@/common/toast' -import { Button, Modal } from '@/components/ui' -import { SelectBox } from '@/components/ui' -import { TextInput } from '@/components/text-input' -import { safeAwait } from '@/services/api' -import { useAddHabit } from '@/services/hooks/habit/add-habit.hook' -import { - type CreateHabitInput, - type Habit, - HabitComparison, - HabitFrequency, - HabitUnit, -} from '@/services/hooks/habit/habit.interface' -import { useUpdateHabit } from '@/services/hooks/habit/update-habit.hook' -import { translateError } from '@/common/utils/translate-error' -import { Tooltip } from '@/components/ui' -import type { HabitIcon } from '@/services/hooks/habit/get-habits.hook' -import { Motion as motion, Presence } from '@/common/motion' - -import { Icon } from '@/src/icons' - -interface HabitFormModalProps { - isOpen: boolean - habit: Habit | null - onClose: () => void - onSaved: () => void - icons: HabitIcon[] - colors: string[] -} - -const defaultForm: CreateHabitInput = { - title: '', - color: HABIT_COLOR_PRESETS[0], - comparison: HabitComparison.AT_LEAST, - unit: HabitUnit.TIMES, - customUnit: '', - target: 1, - frequency: HabitFrequency.DAILY, - frequencyCount: 1, -} - -export function HabitFormModal({ - isOpen, - habit, - onClose, - onSaved, - icons, - colors, -}: HabitFormModalProps) { - const isEdit = !!habit - const [showAdvanced, setShowAdvanced] = useState(false) - const [form, setForm] = useState(defaultForm) - const { mutateAsync: addHabit, isPending: isAdding } = useAddHabit() - const { mutateAsync: updateHabit, isPending: isUpdating } = useUpdateHabit() - const isPending = isAdding || isUpdating - - useEffect(() => { - if (!isOpen) { - if (showAdvanced) setShowAdvanced(false) - - return - } - - if (habit) { - setForm({ - title: habit.title, - emoji: habit.emoji || HABIT_EMOJI_PRESETS[0], - color: habit.color || HABIT_COLOR_PRESETS[0], - comparison: habit.comparison, - unit: habit.unit, - customUnit: habit.customUnit || '', - target: habit.target, - frequency: habit.frequency, - frequencyCount: habit.frequencyCount, - }) - } else { - setForm(defaultForm) - } - }, [habit, isOpen]) - - const updateField = ( - key: K, - value: CreateHabitInput[K] - ) => { - setForm((prev) => ({ ...prev, [key]: value })) - } - - const handleSubmit = async () => { - if (isPending) return - - if (!form.title.trim()) { - showToast('عنوان عادت را وارد کنید.', 'error') - return - } - - if (form.unit === HabitUnit.CUSTOM && !form.customUnit?.trim()) { - showToast('واحد دلخواه را وارد کنید.', 'error') - return - } - - const payload: CreateHabitInput = { - ...form, - customUnit: form.unit === HabitUnit.CUSTOM ? form.customUnit : undefined, - frequencyCount: - form.frequency === HabitFrequency.DAILY ? 1 : form.frequencyCount || 1, - } - - const [error] = await safeAwait( - isEdit ? updateHabit({ id: habit!.id, input: payload }) : addHabit(payload) - ) - - if (error) { - showToast(translateError(error) as string, 'error') - return - } - - showToast(isEdit ? 'عادت ویرایش شد.' : 'عادت جدید اضافه شد.', 'success') - Analytics.event(isEdit ? 'habit_updated' : 'habit_created') - onSaved() - } - - return ( - -
-
- - updateField('title', value)} - placeholder="مثلا نوشیدن آب" - direction="rtl" - /> -
- -
-
- -
- {icons.map((emoji) => ( - - ))} -
-
-
- -
- {colors.map((color) => ( -
-
-
- -
- - - - {showAdvanced && ( - -
-
-
-
- - - - -
- - updateField( - 'unit', - value as HabitUnit - ) - } - className="w-full! h-8!" - /> -
-
-
- - - - -
- - updateField( - 'comparison', - value as HabitComparison - ) - } - className="w-full! h-8!" - /> -
-
- - {form.unit === HabitUnit.CUSTOM && ( -
- - updateField('customUnit', value) - } - placeholder="نام واحد (مثلا: کیلومتر)" - direction="rtl" - className="h-8!" - /> -
- )} - -
-
-
- - - - -
- - updateField( - 'target', - Number(value) || 0 - ) - } - /> -
-
-
- - - - -
- - updateField( - 'frequency', - value as HabitFrequency - ) - } - className="w-full! h-8!" - /> -
-
- - {form.frequency !== HabitFrequency.DAILY && ( -
-
- - - - -
- - updateField( - 'frequencyCount', - Number(value) || 1 - ) - } - className="h-8!" - /> -
- )} -
-
- )} -
-
- - -
-
- ) -} diff --git a/src/layouts/widgets/habit/components/habit-share-modal.tsx b/src/layouts/widgets/habit/components/habit-share-modal.tsx new file mode 100644 index 000000000..7d91e3ff9 --- /dev/null +++ b/src/layouts/widgets/habit/components/habit-share-modal.tsx @@ -0,0 +1,86 @@ +import { useEffect, useRef, useState } from 'react' +import { Button, Modal } from '@/components/ui' +import { Icon } from '@/icons' +import type { Habit } from '@/services/hooks/habit/habit.interface' +import { copyCanvasToClipboard, downloadCanvasAsImage } from '@/common/utils/canvas' +import { renderHabitShareCanvas } from '../utils/render-habit-share-canvas' + +interface HabitShareModalProps { + isOpen: boolean + onClose: () => void + habit: Habit + color: string +} + +export function HabitShareModal({ isOpen, onClose, habit, color }: HabitShareModalProps) { + const canvasRef = useRef(null) + const [isGenerating, setIsGenerating] = useState(false) + + useEffect(() => { + if (!isOpen || !habit) return + renderHabitShareCanvas(canvasRef.current, { habit, color }) + }, [isOpen, habit, color]) + + const handleCopyImage = async () => { + setIsGenerating(true) + await copyCanvasToClipboard(canvasRef.current) + setIsGenerating(false) + } + + const handleDownloadImage = () => { + downloadCanvasAsImage(canvasRef.current, `عادت-${habit.title || 'habit'}`) + } + + return ( + + + + اشتراک‌گذاری پیشرفت + +
+ } + > +
+
+ +
+ +
+ + +
+ + + +
+
+
+ + ) +} diff --git a/src/layouts/widgets/habit/components/item/button.progress-ring.tsx b/src/layouts/widgets/habit/components/item/button-progress-ring.tsx similarity index 100% rename from src/layouts/widgets/habit/components/item/button.progress-ring.tsx rename to src/layouts/widgets/habit/components/item/button-progress-ring.tsx diff --git a/src/layouts/widgets/habit/components/item/button.simple-progress-ring.tsx b/src/layouts/widgets/habit/components/item/button-simple-progress-ring.tsx similarity index 100% rename from src/layouts/widgets/habit/components/item/button.simple-progress-ring.tsx rename to src/layouts/widgets/habit/components/item/button-simple-progress-ring.tsx diff --git a/src/layouts/widgets/habit/components/item/habit-item.skeleton.tsx b/src/layouts/widgets/habit/components/item/habit-item-skeleton.tsx similarity index 100% rename from src/layouts/widgets/habit/components/item/habit-item.skeleton.tsx rename to src/layouts/widgets/habit/components/item/habit-item-skeleton.tsx diff --git a/src/layouts/widgets/habit/components/item/habit.item.tsx b/src/layouts/widgets/habit/components/item/habit-item.tsx similarity index 93% rename from src/layouts/widgets/habit/components/item/habit.item.tsx rename to src/layouts/widgets/habit/components/item/habit-item.tsx index 84e22feb2..d748edcf5 100644 --- a/src/layouts/widgets/habit/components/item/habit.item.tsx +++ b/src/layouts/widgets/habit/components/item/habit-item.tsx @@ -1,17 +1,17 @@ import Analytics from '@/analytics' import { getContrastingTextColor } from '@/common/color' -import { HABIT_UNIT_STEP } from '@/common/constant/habit-options' +import { HABIT_UNIT_STEP } from '@/common/constants/habit-options' import { playAlarm } from '@/common/play-alarm' import { showToast } from '@/common/toast' -import type { WidgetifyDate } from '@/layouts/widgets/calendar/utils' +import type { WidgetifyDate } from '@/layouts/widgets/calendar/utils/date-events' import { safeAwait } from '@/services/api' import { HabitComparison, type Habit } from '@/services/hooks/habit/habit.interface' import { useLogHabitProgress } from '@/services/hooks/habit/log-habit-progress.hook' import { translateError } from '@/common/utils/translate-error' -import { formatHabitGoal } from '../../utils' -import { SegmentedProgressRing } from './button.progress-ring' -import { SimpleProgressRing } from './button.simple-progress-ring' -import { Icon } from '@/src/icons' +import { formatHabitGoal } from '../../utils/habit-goal' +import { SegmentedProgressRing } from './button-progress-ring' +import { SimpleProgressRing } from './button-simple-progress-ring' +import { Icon } from '@/icons' import { IconLoading } from '@/components/ui' interface HabitItemProps { diff --git a/src/layouts/widgets/habit/habit.widget.tsx b/src/layouts/widgets/habit/habit.widget.tsx new file mode 100644 index 000000000..d61b37fa3 --- /dev/null +++ b/src/layouts/widgets/habit/habit.widget.tsx @@ -0,0 +1,319 @@ +import { useState } from 'react' +import Analytics from '@/analytics' +import { autoFormatErrorToast, showToast } from '@/common/toast' +import { Button, ConfirmationModal } from '@/components/ui' +import { Tooltip } from '@/components/ui' +import { useAuth } from '@/context/auth.context' +import { useGeneralSetting } from '@/context/general-setting.context' +import { getCurrentDate } from '@/layouts/widgets/calendar/utils/date-events' +import { safeAwait } from '@/services/api' +import { useArchiveHabit } from '@/services/hooks/habit/archive-habit.hook' +import { useGetHabits } from '@/services/hooks/habit/get-habits.hook' +import type { Habit } from '@/services/hooks/habit/habit.interface' +import { WidgetContainer } from '../widget-container' +import { HabitDetailModal } from './components/habit-detail-modal' +import { HabitFormModal } from './components/habit-form-modal' +import { HabitItem } from './components/item/habit-item' +import { callEvent } from '@/common/utils/call-event' +import { HabitItemSkeleton } from './components/item/habit-item-skeleton' +import { Icon } from '@/icons' +import { HabitEmpty } from './components/habit-empty' +import { HabitCompactWide } from './variants/habit-2x1' +import type { WidgetSize } from '../layout-engine/types' + +export function HabitsContent() { + const { isAuthenticated } = useAuth() + const { selected_timezone: timezone, blurMode } = useGeneralSetting() + const today = getCurrentDate(timezone.value) + + const [editingHabit, setEditingHabit] = useState(null) + const [detailHabitId, setDetailHabitId] = useState(null) + const [showForm, setShowForm] = useState(false) + const [archiveConfirm, setArchiveConfirm] = useState(null) + + const { data, isLoading, refetch, isRefetching } = useGetHabits(isAuthenticated) + const { mutateAsync: archiveHabit, isPending: isArchiving } = useArchiveHabit() + + const handleCloseForm = () => { + setShowForm(false) + setEditingHabit(null) + } + + const handleAddHabit = () => { + if (!isAuthenticated) { + callEvent('openProfile') + return + } + setEditingHabit(null) + setShowForm(true) + Analytics.event('habit_form_opened') + } + + const handleEditHabit = (habit: Habit) => { + if (!isAuthenticated) { + callEvent('openProfile') + return + } + + setEditingHabit(habit) + setShowForm(true) + Analytics.event('habit_edit_opened') + } + + const handleConfirmArchive = async () => { + if (!archiveConfirm || isArchiving) return + + const [error] = await safeAwait(archiveHabit(archiveConfirm)) + + if (error) { + autoFormatErrorToast(error) + return + } + + setArchiveConfirm(null) + setDetailHabitId(null) + showToast('عادت حذف شد.', 'success') + Analytics.event('habit_archived') + refetch() + } + + const onRefresh = () => { + if (!isAuthenticated) { + callEvent('openProfile') + return + } + + refetch() + Analytics.event('habit_refetch') + } + + const onCloseDetailModal = () => { + setDetailHabitId(null) + refetch() + Analytics.event('habit_close_detail_model') + } + + const isWaiting = isLoading || isRefetching + const isEmpty = (!isLoading && data?.items?.length === 0) || !isAuthenticated + return ( +
+
+
+
+ + + +
+ +
+ + + +
+
+
+ +
+
+ {isLoading ? ( +
+ {[...Array(3)].map((_, i) => ( + + ))} +
+ ) : isEmpty ? ( + + ) : ( +
+ {data?.items?.map((habit) => ( + refetch()} + onViewDetails={() => { + setDetailHabitId(habit.id) + Analytics.event('habit_open_detail_model') + }} + /> + ))} +
+ )} +
+
+ + { + handleCloseForm() + refetch() + }} + icons={data?.icons || []} + colors={data?.colors || []} + /> + + {detailHabitId && ( + onCloseDetailModal()} + onEdit={(habit) => handleEditHabit(habit)} + onArchive={() => setArchiveConfirm(detailHabitId)} + /> + )} + + setArchiveConfirm(null)} + onConfirm={handleConfirmArchive} + variant="danger" + title="بایگانی عادت؟" + message={`از بایگانی کردن این عادت اطمینان داری؟`} + confirmText="بله، بایگانی کن" + cancelText="انصراف" + isLoading={isArchiving} + /> +
+ ) +} + +interface HabitsLayoutProps { + size?: WidgetSize +} + +export function HabitsLayout({ size = { w: 2, h: 2 } }: HabitsLayoutProps = {}) { + const { isAuthenticated } = useAuth() + const { selected_timezone: timezone } = useGeneralSetting() + const { data, isLoading, refetch } = useGetHabits(isAuthenticated) + const { mutateAsync: archiveHabit, isPending: isArchiving } = useArchiveHabit() + + const [showForm, setShowForm] = useState(false) + const [editingHabit, setEditingHabit] = useState(null) + const [detailHabitId, setDetailHabitId] = useState(null) + const [archiveConfirm, setArchiveConfirm] = useState(null) + + const handleAddHabit = () => { + if (!isAuthenticated) { + callEvent('openProfile') + return + } + setEditingHabit(null) + setShowForm(true) + Analytics.event('habit_form_opened') + } + + const handleEditHabit = (habit: Habit) => { + if (!isAuthenticated) { + callEvent('openProfile') + return + } + setEditingHabit(habit) + setShowForm(true) + Analytics.event('habit_edit_opened') + } + + const handleCloseForm = () => { + setShowForm(false) + setEditingHabit(null) + } + + const handleConfirmArchive = async () => { + if (!archiveConfirm || isArchiving) return + + const [error] = await safeAwait(archiveHabit(archiveConfirm)) + + if (error) { + autoFormatErrorToast(error) + return + } + + setArchiveConfirm(null) + setDetailHabitId(null) + showToast('عادت حذف شد', 'success') + Analytics.event('habit_archived') + refetch() + } + + const handleCloseDetailModal = () => { + setDetailHabitId(null) + refetch() + Analytics.event('habit_close_detail_model') + } + + if (size.w === 2 && size.h === 1) { + return ( + + refetch()} + onAddHabit={handleAddHabit} + onViewDetails={(id) => { + setDetailHabitId(id) + Analytics.event('habit_open_detail_model') + }} + /> + { + handleCloseForm() + refetch() + }} + icons={data?.icons || []} + colors={data?.colors || []} + /> + {detailHabitId && ( + setArchiveConfirm(detailHabitId)} + /> + )} + setArchiveConfirm(null)} + onConfirm={handleConfirmArchive} + variant="danger" + title="بایگانی عادت؟" + message="از بایگانی کردن این عادت اطمینان داری؟" + confirmText="بله، بایگانی کن" + cancelText="انصراف" + isLoading={isArchiving} + /> + + ) + } + + return ( + + + + ) +} diff --git a/src/layouts/widgets/habit/habits.layout.tsx b/src/layouts/widgets/habit/habits.layout.tsx deleted file mode 100644 index 9df848f23..000000000 --- a/src/layouts/widgets/habit/habits.layout.tsx +++ /dev/null @@ -1,208 +0,0 @@ -import { useState } from 'react' -import Analytics from '@/analytics' -import { autoFormatErrorToast, showToast } from '@/common/toast' -import { Button, ConfirmationModal } from '@/components/ui' -import { Tooltip } from '@/components/ui' -import { useAuth } from '@/context/auth.context' -import { useDate } from '@/context/date.context' -import { useGeneralSetting } from '@/context/general-setting.context' -import { safeAwait } from '@/services/api' -import { useArchiveHabit } from '@/services/hooks/habit/archive-habit.hook' -import { useGetHabits } from '@/services/hooks/habit/get-habits.hook' -import type { Habit } from '@/services/hooks/habit/habit.interface' -import { WidgetContainer } from '../widget-container' -import { HabitDetailModal } from './components/habit-detail.modal' -import { HabitFormModal } from './components/habit-form.modal' -import { HabitItem } from './components/item/habit.item' -import { callEvent } from '@/common/utils/call-event' -import { HabitItemSkeleton } from './components/item/habit-item.skeleton' -import { Icon } from '@/src/icons' -import { HabitEmpty } from './components/habit-empty' - -export function HabitsContent() { - const { isAuthenticated } = useAuth() - const { today } = useDate() - const { blurMode } = useGeneralSetting() - - const [editingHabit, setEditingHabit] = useState(null) - const [detailHabitId, setDetailHabitId] = useState(null) - const [showForm, setShowForm] = useState(false) - const [archiveConfirm, setArchiveConfirm] = useState(null) - - const { data, isLoading, refetch, isRefetching } = useGetHabits(isAuthenticated) - const { mutateAsync: archiveHabit, isPending: isArchiving } = useArchiveHabit() - - const handleCloseForm = () => { - setShowForm(false) - setEditingHabit(null) - } - - const handleAddHabit = () => { - if (!isAuthenticated) { - callEvent('openProfile') - return - } - setEditingHabit(null) - setShowForm(true) - Analytics.event('habit_form_opened') - } - - const handleEditHabit = (habit: Habit) => { - if (!isAuthenticated) { - callEvent('openProfile') - return - } - - setEditingHabit(habit) - setShowForm(true) - Analytics.event('habit_edit_opened') - } - - const handleConfirmArchive = async () => { - if (!archiveConfirm || isArchiving) return - - const [error] = await safeAwait(archiveHabit(archiveConfirm)) - - if (error) { - autoFormatErrorToast(error) - return - } - - setArchiveConfirm(null) - setDetailHabitId(null) - showToast('عادت حذف شد.', 'success') - Analytics.event('habit_archived') - refetch() - } - - const onRefresh = () => { - if (!isAuthenticated) { - callEvent('openProfile') - return - } - - refetch() - Analytics.event('habit_refetch') - } - - const onCloseDetailModal = () => { - setDetailHabitId(null) - refetch() - Analytics.event('habit_close_detail_model') - } - - const isWaiting = isLoading || isRefetching - const isEmpty = (!isLoading && data?.items?.length === 0) || !isAuthenticated - return ( -
-
-
-
-

عادت‌ها

-
- -
- - - -
-
-
- -
-
- {isLoading ? ( -
- {[...Array(3)].map((_, i) => ( - - ))} -
- ) : isEmpty ? ( - - ) : ( -
- {data?.items?.map((habit) => ( - setDetailHabitId(habit.id)} - /> - ))} -
- )} -
-
- - - - { - handleCloseForm() - refetch() - }} - icons={data?.icons || []} - colors={data?.colors || []} - /> - - {!!detailHabitId && ( - onCloseDetailModal()} - onEdit={(habit) => handleEditHabit(habit)} - onArchive={() => setArchiveConfirm(detailHabitId)} - /> - )} - - setArchiveConfirm(null)} - onConfirm={handleConfirmArchive} - variant="danger" - title="بایگانی عادت؟" - message={`از بایگانی کردن این عادت اطمینان داری؟`} - confirmText="بله، بایگانی کن" - cancelText="انصراف" - isLoading={isArchiving} - /> -
- ) -} - -export function HabitsLayout() { - return ( - - - - ) -} diff --git a/src/layouts/widgets/habit/utils.ts b/src/layouts/widgets/habit/utils/habit-goal.ts similarity index 100% rename from src/layouts/widgets/habit/utils.ts rename to src/layouts/widgets/habit/utils/habit-goal.ts diff --git a/src/layouts/widgets/habit/utils/habit.utils.ts b/src/layouts/widgets/habit/utils/habit.utils.ts deleted file mode 100644 index 4d89d5ae6..000000000 --- a/src/layouts/widgets/habit/utils/habit.utils.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { - HabitFrequency, - HabitUnit, - type Habit, -} from '@/services/hooks/habit/habit.interface' - -const unitLabels: Record = { - [HabitUnit.TIMES]: 'دفعه', - [HabitUnit.MINUTES]: 'دقیقه', - [HabitUnit.HOURS]: 'ساعت', - [HabitUnit.PAGES]: 'صفحه', - [HabitUnit.GLASSES]: 'لیوان', - [HabitUnit.CUSTOM]: '', -} - -const comparisonLabels: Record = { - AT_LEAST: 'حداقل', - AT_MOST: 'حداکثر', - EXACT: 'دقیقا', -} - -const frequencyLabels: Record = { - [HabitFrequency.DAILY]: 'روزانه', - [HabitFrequency.WEEKLY]: 'هفته', - [HabitFrequency.MONTHLY]: 'ماه', -} - -export function getHabitUnitLabel(habit: Habit): string { - if (habit.unit === HabitUnit.CUSTOM) { - return habit.customUnit || '' - } - return unitLabels[habit.unit] || '' -} - -export function formatHabitGoal(habit: Habit): string { - const unitLabel = getHabitUnitLabel(habit) - const base = - `${comparisonLabels[habit.comparison]} ${habit.target} ${unitLabel}`.trim() - - if (habit.frequency === HabitFrequency.DAILY) { - return `${base} در روز` - } - - return `${base} · ${habit.progressThisPeriod.done} از ${habit.progressThisPeriod.required} بار در ${frequencyLabels[habit.frequency]}` -} diff --git a/src/layouts/widgets/habit/utils/render-habit-share-canvas.ts b/src/layouts/widgets/habit/utils/render-habit-share-canvas.ts new file mode 100644 index 000000000..293f9d501 --- /dev/null +++ b/src/layouts/widgets/habit/utils/render-habit-share-canvas.ts @@ -0,0 +1,362 @@ +import jalaliMoment from 'jalali-moment' +import moment from 'moment' +import type { Habit } from '@/services/hooks/habit/habit.interface' +import { + drawRoundedRect, + fitText, + rgba, +} from '@/common/utils/canvas' +import { formatHabitGoal } from './habit-goal' + +interface RenderHabitShareCanvasOptions { + habit: Habit + color: string +} + +export function renderHabitShareCanvas( + canvas: HTMLCanvasElement | null, + options: RenderHabitShareCanvasOptions +) { + if (!canvas) return + + const { habit, color } = options + const ctx = canvas.getContext('2d') + if (!ctx) return + + const dpr = 2 + const width = 800 + const height = 520 + + canvas.width = width * dpr + canvas.height = height * dpr + canvas.style.width = `${width}px` + canvas.style.height = `${height}px` + + ctx.setTransform(dpr, 0, 0, dpr, 0, 0) + ctx.clearRect(0, 0, width, height) + + const accent = color || '#536dfe' + const background = '#0f1014' + const primary = '#f7f7f8' + const secondary = '#a7a7b0' + const muted = '#62636d' + + ctx.fillStyle = background + ctx.fillRect(0, 0, width, height) + + ctx.fillStyle = rgba(accent, 0.045) + ctx.beginPath() + ctx.arc(118, 86, 170, 0, Math.PI * 2) + ctx.fill() + + ctx.fillStyle = rgba(accent, 0.025) + ctx.beginPath() + ctx.arc(700, 385, 210, 0, Math.PI * 2) + ctx.fill() + + ctx.strokeStyle = '#24252b' + ctx.lineWidth = 1 + drawRoundedRect(ctx, 0.5, 0.5, width - 1, height - 1, 30) + ctx.stroke() + + const today = jalaliMoment().locale('fa').startOf('day') + const numWeeks = 26 + const start = today + .clone() + .subtract(numWeeks - 1, 'weeks') + .startOf('week') + + const current = start.clone() + + let currentStreak = 0 + let longestStreak = 0 + let tempStreak = 0 + let totalCompleted = 0 + + const allDaysChronological: { + gregorianDate: string + isDone: boolean + level: number + jalaliDate: jalaliMoment.Moment + }[] = [] + + const weeksGrid: { + days: { + level: number + isFuture: boolean + }[] + }[] = [] + + const monthMarkers: { + name: string + weekIndex: number + }[] = [] + + let currentMonth = '' + + for (let w = 0; w < numWeeks; w++) { + const daysInWeek: { + level: number + isFuture: boolean + }[] = [] + + for (let d = 0; d < 7; d++) { + const dayDate = current.clone() + const gregorianDate = moment(dayDate.toDate()).format('YYYY-MM-DD') + const [year, month] = gregorianDate.split('-') + const monthData = habit.calendarData?.[`${year}-${month}`] || {} + const dayData = monthData[gregorianDate] || { + value: 0, + isDone: false, + } + + const isFuture = dayDate.isAfter(today, 'day') + const value = dayData.value + const isDone = + dayData.isDone || (habit.target > 0 && value >= habit.target) + + let level = 0 + if (value > 0) { + if (habit.target > 0) { + const ratio = value / habit.target + if (ratio >= 1.5) { + level = 4 + } else if (ratio >= 1) { + level = 3 + } else if (ratio >= 0.5) { + level = 2 + } else { + level = 1 + } + } else { + level = 3 + } + } + + daysInWeek.push({ + level, + isFuture, + }) + + if (!isFuture) { + allDaysChronological.push({ + gregorianDate, + isDone, + level, + jalaliDate: dayDate, + }) + + if (isDone) { + totalCompleted++ + } + } + + const monthName = dayDate.format('jMMMM') + if (monthName !== currentMonth) { + currentMonth = monthName + monthMarkers.push({ + name: monthName, + weekIndex: w, + }) + } + + current.add(1, 'day') + } + + weeksGrid.push({ + days: daysInWeek, + }) + } + + for (const day of allDaysChronological) { + if (day.isDone) { + tempStreak++ + if (tempStreak > longestStreak) { + longestStreak = tempStreak + } + } else { + tempStreak = 0 + } + } + + const lastIdx = allDaysChronological.length - 1 + if (lastIdx >= 0) { + let checkIdx = lastIdx + if ( + !allDaysChronological[checkIdx].isDone && + checkIdx > 0 && + allDaysChronological[checkIdx - 1].isDone + ) { + checkIdx-- + } + + while (checkIdx >= 0 && allDaysChronological[checkIdx].isDone) { + currentStreak++ + checkIdx-- + } + } + + ctx.textAlign = 'left' + ctx.textBaseline = 'middle' + ctx.font = '600 11px Vazir, "Segoe UI", sans-serif' + ctx.fillStyle = muted + ctx.fillText('افزونه نیوتب مرورگر ویجتیفای', 36, 35) + + ctx.fillStyle = accent + ctx.beginPath() + ctx.arc(175, 35, 3, 0, Math.PI * 2) + ctx.fill() + + ctx.textAlign = 'right' + ctx.textBaseline = 'alphabetic' + ctx.font = '800 96px Vazir, "Segoe UI", sans-serif' + ctx.fillStyle = primary + ctx.fillText(String(currentStreak), width - 36, 108) + + const numberWidth = ctx.measureText(String(currentStreak)).width + ctx.fillStyle = accent + drawRoundedRect(ctx, width - 36 - numberWidth, 119, numberWidth, 4, 2) + ctx.fill() + + ctx.font = '500 13px Vazir, "Segoe UI", sans-serif' + ctx.fillStyle = secondary + ctx.fillText('روز متوالی', width - 36, 145) + + if (currentStreak > 0) { + ctx.font = + '28px "Segoe UI Emoji", "Apple Color Emoji", "Noto Color Emoji", sans-serif' + ctx.fillText('🔥', width - 110, 104) + } + + const identityCenterX = 190 + const identityTop = 87 + + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.font = + '78px "Segoe UI Emoji", "Apple Color Emoji", "Noto Color Emoji", sans-serif' + ctx.fillText(habit.emoji || '🎯', identityCenterX, identityTop + 22) + + ctx.fillStyle = accent + drawRoundedRect(ctx, identityCenterX - 20, identityTop + 72, 40, 4, 2) + ctx.fill() + + ctx.textBaseline = 'top' + ctx.font = '700 25px Vazir, "Segoe UI", sans-serif' + ctx.fillStyle = primary + const title = fitText(ctx, habit.title || 'عادت من', 270) + ctx.fillText(title, identityCenterX, identityTop + 88) + + ctx.font = '400 12px Vazir, "Segoe UI", sans-serif' + ctx.fillStyle = secondary + const goal = fitText(ctx, formatHabitGoal(habit), 270) + ctx.fillText(goal, identityCenterX, identityTop + 124) + + const chartX = 36 + const chartY = 225 + const chartWidth = width - 72 + + ctx.textAlign = 'right' + ctx.textBaseline = 'top' + ctx.font = '600 12px Vazir, "Segoe UI", sans-serif' + ctx.fillStyle = secondary + ctx.fillText('فعالیت ۶ ماه اخیر', chartX + chartWidth, chartY) + + ctx.font = '400 10px Vazir, "Segoe UI", sans-serif' + ctx.fillStyle = muted + ctx.fillText(`${totalCompleted} روز موفق`, chartX + chartWidth - 105, chartY + 2) + + const cellSize = 17 + const gap = 4 + const gridTop = chartY + 38 + const gridRight = chartX + chartWidth - 27 + const dayLabels = ['ش', 'ی', 'د', 'س', 'چ', 'پ', 'ج'] + + ctx.font = '500 9px Arad, Vazir, sans-serif' + ctx.fillStyle = muted + ctx.textBaseline = 'middle' + + for (const marker of monthMarkers) { + const colX = gridRight - marker.weekIndex * (cellSize + gap) - cellSize + let textX = colX + cellSize / 2 + let align: CanvasTextAlign = 'center' + + if (marker.weekIndex === 0) { + align = 'right' + textX = colX + cellSize + } + + if (marker.weekIndex >= numWeeks - 2) { + align = 'left' + textX = colX + } + + ctx.textAlign = align + ctx.fillText(marker.name, textX, chartY + 29) + } + + ctx.font = '500 9px Arad, Vazir, sans-serif' + ctx.textAlign = 'center' + ctx.fillStyle = muted + + for (let d = 0; d < 7; d++) { + const y = gridTop + d * (cellSize + gap) + cellSize / 2 + ctx.fillText(dayLabels[d], gridRight + 18, y) + } + + weeksGrid.forEach((week, w) => { + const colX = gridRight - w * (cellSize + gap) - cellSize + + week.days.forEach((day, d) => { + if (day.isFuture) { + return + } + + const cellY = gridTop + d * (cellSize + gap) + let bg = '#24252a' + + if (day.level === 1) { + bg = rgba(accent, 0.22) + } else if (day.level === 2) { + bg = rgba(accent, 0.43) + } else if (day.level === 3) { + bg = rgba(accent, 0.7) + } else if (day.level === 4) { + bg = accent + } + + ctx.fillStyle = bg + drawRoundedRect(ctx, colX, cellY, cellSize, cellSize, 4) + ctx.fill() + }) + }) + + const bottomY = 454 + + ctx.textAlign = 'left' + ctx.textBaseline = 'middle' + ctx.font = '400 10px Vazir, "Segoe UI", sans-serif' + ctx.fillStyle = muted + ctx.fillText(`بهترین رکورد ${longestStreak} روز`, 36, bottomY) + + ctx.textAlign = 'right' + ctx.fillText('یک قدم کوچک، هر روز', width - 36, bottomY) + + const legendY = 454 + let legendX = width / 2 - 38 + const legendColors = [ + '#24252a', + rgba(accent, 0.22), + rgba(accent, 0.43), + rgba(accent, 0.7), + accent, + ] + + for (let i = 0; i < legendColors.length; i++) { + ctx.fillStyle = legendColors[i] + drawRoundedRect(ctx, legendX, legendY - 5, 11, 11, 3) + ctx.fill() + legendX += 15 + } +} + diff --git a/src/layouts/widgets/habit/variants/habit-2x1.tsx b/src/layouts/widgets/habit/variants/habit-2x1.tsx new file mode 100644 index 000000000..92e28bb14 --- /dev/null +++ b/src/layouts/widgets/habit/variants/habit-2x1.tsx @@ -0,0 +1,237 @@ +import { useEffect, useState } from 'react' +import Analytics from '@/analytics' +import { getContrastingTextColor } from '@/common/color' +import { HABIT_UNIT_STEP } from '@/common/constants/habit-options' +import { playAlarm } from '@/common/play-alarm' +import { showToast } from '@/common/toast' +import { cn } from '@/common/utils/cn' +import { translateError } from '@/common/utils/translate-error' +import { IconLoading } from '@/components/ui' +import type { WidgetifyDate } from '@/layouts/widgets/calendar/utils/date-events' +import { safeAwait } from '@/services/api' +import { HabitComparison, type Habit } from '@/services/hooks/habit/habit.interface' +import { useLogHabitProgress } from '@/services/hooks/habit/log-habit-progress.hook' +import { Icon } from '@/icons' +import { SegmentedProgressRing } from '../components/item/button-progress-ring' +import { SimpleProgressRing } from '../components/item/button-simple-progress-ring' + +interface HabitCompactWideProps { + habits: Habit[] + isLoading: boolean + today: WidgetifyDate + onChanged: () => void + onAddHabit?: () => void + onViewDetails?: (habitId: string) => void +} + +export function HabitCompactWide({ + habits, + isLoading, + today, + onChanged, + onAddHabit, + onViewDetails, +}: HabitCompactWideProps) { + const { mutateAsync: logProgress, isPending } = useLogHabitProgress() + const [selectedId, setSelectedId] = useState(habits[0]?.id ?? null) + + useEffect(() => { + if (habits.length === 0) { + setSelectedId(null) + return + } + if (!habits.some((h) => h.id === selectedId)) { + setSelectedId(habits[0].id) + } + }, [habits, selectedId]) + + if (isLoading) { + return ( +
+
+
+
+
+
+
+
+
+ {[...Array(4)].map((_, i) => ( +
+ ))} +
+
+ ) + } + + const selectedHabit = habits.find((h) => h.id === selectedId) || null + + if (habits.length === 0 || !selectedHabit) { + return ( +
+
+ 🌱 +
+
+

عادت‌های خوب

+

+ افزودن عادت + +

+
+
+ ) + } + + const color = selectedHabit.color || '#536dfe' + const target = selectedHabit.target || 1 + const value = selectedHabit.today.value + const isSimpleHabit = target === 1 + const isDone = selectedHabit.today.isDone || value >= target + + const handleQuickLog = async (e: React.MouseEvent) => { + e.stopPropagation() + if (isPending) return + + const date = today.clone().doAsGregorian().format('YYYY-MM-DD') + let step = HABIT_UNIT_STEP[selectedHabit.unit] || 1 + if (selectedHabit.comparison === HabitComparison.EXACT && value + step > target) { + step = 0 + } + if ( + selectedHabit.comparison === HabitComparison.AT_MOST && + value + step > target + ) { + showToast(`مقدار فعلی به حداکثر هدف (${target}) رسیده است`, 'error') + return + } + + const [error] = await safeAwait( + logProgress({ id: selectedHabit.id, input: { date, amount: step } }) + ) + if (error) { + showToast(translateError(error) as string, 'error') + return + } + playAlarm('info') + Analytics.event('habit_quick_log_wide') + onChanged() + } + + return ( +
+
+ + +
onViewDetails?.(selectedHabit.id)} + className="flex-1 min-w-0 cursor-pointer group/title" + title="مشاهده جزئیات عادت" + > +

+ {selectedHabit.title} +

+

+ {value} از {target} امروز +

+
+ + +
+ + {habits.length > 1 && ( +
+ {habits.map((habit) => { + const habitTarget = habit.target || 1 + const habitProgress = Math.min(habit.today.value / habitTarget, 1) + const habitColor = habit.color || '#536dfe' + const habitDone = + habit.today.isDone || habit.today.value >= habitTarget + const isSelected = habit.id === selectedId + + return ( + + ) + })} +
+ )} +
+ ) +} diff --git a/src/layouts/widgets/instance-id.ts b/src/layouts/widgets/instance-id.ts new file mode 100644 index 000000000..7f1cc5f17 --- /dev/null +++ b/src/layouts/widgets/instance-id.ts @@ -0,0 +1,97 @@ +import type { StoredWidget } from './layout-engine/types' + +const SERVER_INSTANCE_ID_PATTERN = /^[0-9a-fA-F]{24}$/ + +export interface SyncedWidgetIdentity { + instanceId: string + widgetKey: string +} + +export function isServerInstanceId(value: unknown): value is string { + return typeof value === 'string' && SERVER_INSTANCE_ID_PATTERN.test(value) +} + +export function createLocalInstanceId(widgetKey: string): string { + return `${widgetKey}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}` +} + +export function dedupeInstanceIds(layout: StoredWidget[]): StoredWidget[] { + const seen = new Set() + let changed = false + + const result = layout.map((widget) => { + if (widget.instanceId && !seen.has(widget.instanceId)) { + seen.add(widget.instanceId) + return widget + } + + let nextId = createLocalInstanceId(widget.id) + while (seen.has(nextId)) { + nextId = createLocalInstanceId(widget.id) + } + seen.add(nextId) + changed = true + + return { ...widget, instanceId: nextId, widgetId: nextId } + }) + + return changed ? result : layout +} + +export function buildInstanceIdMap( + layout: StoredWidget[], + synced: SyncedWidgetIdentity[] +): Map { + const idMap = new Map() + const claimed = new Set() + + for (const widget of layout) { + if (isServerInstanceId(widget.instanceId)) { + claimed.add(widget.instanceId) + } + } + + for (const entry of synced) { + if (!entry?.instanceId) continue + const owner = layout.find((w) => w.instanceId === entry.instanceId) + if (owner) { + claimed.add(entry.instanceId) + } + } + + layout.forEach((widget, index) => { + if (isServerInstanceId(widget.instanceId)) return + + const candidates: SyncedWidgetIdentity[] = [] + const byIndex = synced[index] + if (byIndex?.instanceId && byIndex.widgetKey === widget.id) { + candidates.push(byIndex) + } + for (const entry of synced) { + if (entry?.instanceId && entry.widgetKey === widget.id) { + candidates.push(entry) + } + } + + const matching = candidates.find((entry) => !claimed.has(entry.instanceId)) + if (!matching) return + + claimed.add(matching.instanceId) + idMap.set(widget.instanceId, matching.instanceId) + }) + + return idMap +} + +export function applyInstanceIdMap( + layout: StoredWidget[], + idMap: Map +): StoredWidget[] { + if (idMap.size === 0) return layout + + return layout.map((widget) => { + const nextId = idMap.get(widget.instanceId) + if (!nextId) return widget + return { ...widget, instanceId: nextId, widgetId: nextId } + }) +} diff --git a/src/layouts/widgets/layout-engine/__tests__/layout-engine.test.ts b/src/layouts/widgets/layout-engine/__tests__/layout-engine.test.ts new file mode 100644 index 000000000..aab012945 --- /dev/null +++ b/src/layouts/widgets/layout-engine/__tests__/layout-engine.test.ts @@ -0,0 +1,198 @@ +import { describe, expect, it } from 'bun:test' +import { + WidgetKeys, + compactLayout, + DEFAULT_WIDGET_LAYOUT, + findAvailableSlot, + getCollisions, + rectanglesOverlap, + resolveLayoutChange, + validateLayout, +} from '../index' +import type { StoredWidget } from '../types' + +describe('Layout Engine Tests', () => { + it('validates default layout without errors', () => { + const isValid = validateLayout(DEFAULT_WIDGET_LAYOUT, 8) + expect(isValid).toBe(true) + }) + + it('detects rectangle overlap correctly', () => { + const a: StoredWidget = { + id: WidgetKeys.search, + instanceId: 'a', + position: { col: 0, row: 0 }, + size: { w: 4, h: 2 }, + } + const b: StoredWidget = { + id: WidgetKeys.bookmarks, + instanceId: 'b', + position: { col: 2, row: 1 }, + size: { w: 4, h: 2 }, + } + const c: StoredWidget = { + id: WidgetKeys.clock, + instanceId: 'c', + position: { col: 4, row: 0 }, + size: { w: 2, h: 1 }, + } + + expect(rectanglesOverlap(a, b)).toBe(true) + expect(rectanglesOverlap(a, c)).toBe(false) + expect(getCollisions(a, [a, b, c])).toEqual([b]) + }) + + it('pushes conflicting widget when resizing larger', () => { + const initial: StoredWidget[] = [ + { + id: WidgetKeys.weather, + instanceId: 'weather', + position: { col: 0, row: 0 }, + size: { w: 2, h: 2 }, + }, + { + id: WidgetKeys.calendar, + instanceId: 'calendar', + position: { col: 2, row: 0 }, + size: { w: 2, h: 2 }, + }, + ] + + const result = resolveLayoutChange({ + layout: initial, + operation: 'resize', + instanceId: 'weather', + targetSize: { w: 4, h: 2 }, + cols: 8, + }) + + expect(result).not.toBeNull() + expect(validateLayout(result!, 8)).toBe(true) + + const weather = result!.find((w) => w.instanceId === 'weather')! + const calendar = result!.find((w) => w.instanceId === 'calendar')! + + expect(weather.size).toEqual({ w: 4, h: 2 }) + expect(weather.position).toEqual({ col: 0, row: 0 }) + expect(rectanglesOverlap(weather, calendar)).toBe(false) + }) + + it('pushes recursively when moving a widget into occupied cells', () => { + const initial: StoredWidget[] = [ + { + id: WidgetKeys.search, + instanceId: 'search', + position: { col: 0, row: 0 }, + size: { w: 4, h: 1 }, + }, + { + id: WidgetKeys.bookmarks, + instanceId: 'bookmarks', + position: { col: 0, row: 1 }, + size: { w: 4, h: 2 }, + }, + { + id: WidgetKeys.clock, + instanceId: 'clock', + position: { col: 0, row: 3 }, + size: { w: 2, h: 2 }, + }, + ] + + const result = resolveLayoutChange({ + layout: initial, + operation: 'move', + instanceId: 'clock', + targetPosition: { col: 0, row: 0 }, + cols: 8, + }) + + expect(result).not.toBeNull() + expect(validateLayout(result!, 8)).toBe(true) + + const clock = result!.find((w) => w.instanceId === 'clock')! + expect(clock.position).toEqual({ col: 0, row: 0 }) + }) + + it('compacts layout removing vertical gaps', () => { + const withGaps: StoredWidget[] = [ + { + id: WidgetKeys.search, + instanceId: 'search', + position: { col: 0, row: 0 }, + size: { w: 4, h: 1 }, + }, + { + id: WidgetKeys.bookmarks, + instanceId: 'bookmarks', + position: { col: 0, row: 5 }, + size: { w: 4, h: 2 }, + }, + ] + + const compacted = compactLayout(withGaps, 8) + const bookmarks = compacted.find((w) => w.instanceId === 'bookmarks')! + expect(bookmarks.position.row).toBe(1) + }) + + it('finds available slots deterministically', () => { + const layout: StoredWidget[] = [ + { + id: WidgetKeys.search, + instanceId: 'search', + position: { col: 0, row: 0 }, + size: { w: 8, h: 1 }, + }, + ] + + const slot = findAvailableSlot(layout, { w: 2, h: 2 }, 8) + expect(slot).toEqual({ col: 0, row: 1 }) + }) + + it('contains 11 widgets in the default layout matching the Advanced layout', () => { + expect(DEFAULT_WIDGET_LAYOUT.length).toBe(11) + const widgetIds = DEFAULT_WIDGET_LAYOUT.map((w) => w.id) + expect(widgetIds).toContain(WidgetKeys.photo) + expect(widgetIds).toContain(WidgetKeys.pet) + expect(widgetIds).toContain(WidgetKeys.search) + expect(widgetIds).toContain(WidgetKeys.bookmarks) + expect(widgetIds).toContain(WidgetKeys.clock) + expect(widgetIds).toContain(WidgetKeys.moodTracker) + expect(widgetIds).toContain(WidgetKeys.weather) + expect(widgetIds).toContain(WidgetKeys.calendar) + expect(widgetIds).toContain(WidgetKeys.yadKar) + expect(widgetIds).toContain(WidgetKeys.tools) + expect(widgetIds).toContain(WidgetKeys.comboWidget) + }) + + it('validates Simple mode migration layout without overlaps', () => { + const simpleLayout: StoredWidget[] = [ + { + id: WidgetKeys.search, + instanceId: 'search-default', + position: { col: 2, row: 0 }, + size: { w: 4, h: 1 }, + }, + { + id: WidgetKeys.clock, + instanceId: 'clock-default', + position: { col: 0, row: 3 }, + size: { w: 2, h: 2 }, + }, + { + id: WidgetKeys.bookmarks, + instanceId: 'bookmarks-default', + position: { col: 2, row: 5 }, + size: { w: 4, h: 2 }, + }, + { + id: WidgetKeys.tools, + instanceId: 'tools-default', + position: { col: 6, row: 3 }, + size: { w: 2, h: 4 }, + meta: { variant: 'simplify' }, + }, + ] + expect(validateLayout(simpleLayout, 8)).toBe(true) + }) +}) diff --git a/src/layouts/widgets/layout-engine/__tests__/push-down.test.ts b/src/layouts/widgets/layout-engine/__tests__/push-down.test.ts new file mode 100644 index 000000000..cf4a13640 --- /dev/null +++ b/src/layouts/widgets/layout-engine/__tests__/push-down.test.ts @@ -0,0 +1,276 @@ +import { describe, expect, it } from 'bun:test' +import { + compactLayout, + reconcileIdentity, + resolveLayoutChange, + validateLayout, + WidgetKeys, +} from '../index' +import { pushDownward } from '../push' +import type { StoredWidget } from '../types' + +function widget( + instanceId: string, + col: number, + row: number, + w: number, + h: number, + id: WidgetKeys = WidgetKeys.clock +): StoredWidget { + return { id, instanceId, position: { col, row }, size: { w, h } } +} + +function buildPackedLayout(count: number, cols = 8): StoredWidget[] { + const layout: StoredWidget[] = [] + const perRow = Math.floor(cols / 2) + for (let i = 0; i < count; i++) { + const col = (i % perRow) * 2 + const row = Math.floor(i / perRow) * 3 + layout.push(widget(`w-${i}`, col, row, 2, 3)) + } + return layout +} + +function freezeRepro(): StoredWidget[] { + const layout = buildPackedLayout(20) + layout.push(widget('search', 0, 30, 4, 1, WidgetKeys.search)) + return layout +} + +describe('push-down collision engine', () => { + it('cascades downward through a chain of blockers', () => { + const initial = [ + widget('a', 0, 0, 2, 2), + widget('b', 0, 2, 2, 2), + widget('c', 0, 4, 2, 2), + ] + + const result = resolveLayoutChange({ + layout: initial, + operation: 'move', + instanceId: 'a', + targetPosition: { col: 0, row: 2 }, + cols: 8, + }) + + expect(result).not.toBeNull() + expect(validateLayout(result!, 8)).toBe(true) + expect(result!.find((w) => w.instanceId === 'a')!.position).toEqual({ + col: 0, + row: 2, + }) + expect(result!.find((w) => w.instanceId === 'b')!.position).toEqual({ + col: 0, + row: 4, + }) + expect(result!.find((w) => w.instanceId === 'c')!.position).toEqual({ + col: 0, + row: 6, + }) + }) + + it('never moves the pinned widget and only ever pushes downward', () => { + const initial = [widget('pinned', 2, 2, 2, 2), widget('other', 2, 2, 2, 2)] + const { layout } = pushDownward(initial, new Set(['pinned'])) + + expect(layout.find((w) => w.instanceId === 'pinned')!.position).toEqual({ + col: 2, + row: 2, + }) + const other = layout.find((w) => w.instanceId === 'other')! + expect(other.position.col).toBe(2) + expect(other.position.row).toBe(4) + }) + + it('preserves deliberate vertical gaps', () => { + const initial = [widget('a', 0, 0, 2, 1), widget('b', 0, 8, 2, 1)] + + const result = resolveLayoutChange({ + layout: initial, + operation: 'move', + instanceId: 'a', + targetPosition: { col: 4, row: 0 }, + cols: 8, + }) + + expect(result!.find((w) => w.instanceId === 'b')!.position).toEqual({ + col: 0, + row: 8, + }) + }) + + it('never returns null for any in-bounds move', () => { + const initial = buildPackedLayout(30) + + for (let col = 0; col <= 6; col++) { + for (let row = 0; row < 20; row++) { + const result = resolveLayoutChange({ + layout: initial, + operation: 'move', + instanceId: 'w-7', + targetPosition: { col, row }, + cols: 8, + }) + expect(result).not.toBeNull() + expect(validateLayout(result!, 8)).toBe(true) + } + } + }) + + it('places the dragged widget exactly on target', () => { + const initial = freezeRepro() + + for (let col = 0; col <= 4; col++) { + for (let row = 0; row < 15; row++) { + const result = resolveLayoutChange({ + layout: initial, + operation: 'move', + instanceId: 'search', + targetPosition: { col, row }, + cols: 8, + }) + expect(result!.find((w) => w.instanceId === 'search')!.position).toEqual({ + col, + row, + }) + } + } + }) + + it('keeps a worst-case drag sweep inside the frame budget', () => { + const initial = freezeRepro() + + let worst = 0 + const startedAt = performance.now() + + for (let col = 0; col <= 4; col++) { + for (let row = 0; row < 15; row++) { + const callStart = performance.now() + const result = resolveLayoutChange({ + layout: initial, + operation: 'move', + instanceId: 'search', + targetPosition: { col, row }, + cols: 8, + }) + worst = Math.max(worst, performance.now() - callStart) + expect(result).not.toBeNull() + } + } + + expect(worst).toBeLessThan(16) + expect(performance.now() - startedAt).toBeLessThan(500) + }) + + it('resolves the reported search-widget freeze instantly', () => { + const initial = freezeRepro() + + const startedAt = performance.now() + const result = resolveLayoutChange({ + layout: initial, + operation: 'move', + instanceId: 'search', + targetPosition: { col: 4, row: 0 }, + cols: 8, + }) + const elapsed = performance.now() - startedAt + + expect(result).not.toBeNull() + expect(validateLayout(result!, 8)).toBe(true) + expect(elapsed).toBeLessThan(16) + }) + + it('is deterministic regardless of input ordering', () => { + const initial = buildPackedLayout(12) + const shuffled = [...initial].reverse() + + const move = (layout: StoredWidget[]) => + resolveLayoutChange({ + layout, + operation: 'move', + instanceId: 'w-5', + targetPosition: { col: 0, row: 0 }, + cols: 8, + })! + + const first = move(initial) + const second = move(initial) + const fromShuffled = move(shuffled) + + expect(first).toEqual(second) + + for (const w of first) { + const match = fromShuffled.find((x) => x.instanceId === w.instanceId)! + expect(match.position).toEqual(w.position) + } + }) + + it('does not drift when previewing repeatedly from a stable base', () => { + const base = buildPackedLayout(16) + + const preview = (col: number, row: number) => + resolveLayoutChange({ + layout: base, + operation: 'move', + instanceId: 'w-3', + targetPosition: { col, row }, + cols: 8, + })! + + const first = preview(5, 3) + preview(0, 0) + const third = preview(5, 3) + + expect(third).toEqual(first) + }) + + it('preserves object identity for untouched widgets', () => { + const initial = [ + widget('a', 0, 0, 2, 1), + widget('b', 0, 1, 2, 1), + widget('far', 6, 10, 2, 1), + ] + + const result = resolveLayoutChange({ + layout: initial, + operation: 'move', + instanceId: 'a', + targetPosition: { col: 0, row: 1 }, + cols: 8, + })! + + expect(result.find((w) => w.instanceId === 'far')).toBe(initial[2]) + expect(result.find((w) => w.instanceId === 'b')).not.toBe(initial[1]) + }) + + it('returns the previous array when nothing changed', () => { + const prev = buildPackedLayout(6) + const next = prev.map((w) => ({ + ...w, + position: { ...w.position }, + size: { ...w.size }, + })) + + expect(reconcileIdentity(prev, next)).toBe(prev) + }) + + it('honours onlyIds in compactLayout', () => { + const layout = [widget('pull-me', 0, 5, 2, 1), widget('leave-me', 4, 5, 2, 1)] + + const compacted = compactLayout(layout, 8, { onlyIds: new Set(['pull-me']) }) + + expect(compacted.find((w) => w.instanceId === 'pull-me')!.position.row).toBe(0) + expect(compacted.find((w) => w.instanceId === 'leave-me')!.position.row).toBe(5) + }) + + it('returns overlapping pinned widgets unchanged', () => { + const layout = [widget('p1', 0, 0, 2, 2), widget('p2', 1, 1, 2, 2)] + const { layout: result, displacedIds } = pushDownward( + layout, + new Set(['p1', 'p2']) + ) + + expect(result).toBe(layout) + expect(displacedIds.size).toBe(0) + }) +}) diff --git a/src/layouts/widgets/layout-engine/collision.ts b/src/layouts/widgets/layout-engine/collision.ts new file mode 100644 index 000000000..3c1febdf9 --- /dev/null +++ b/src/layouts/widgets/layout-engine/collision.ts @@ -0,0 +1,44 @@ +import type { StoredWidget, WidgetPosition, WidgetSize } from './types' + +export function doRectanglesOverlap( + posA: WidgetPosition, + sizeA: WidgetSize, + posB: WidgetPosition, + sizeB: WidgetSize +): boolean { + const overlapsH = + posA.col < posB.col + sizeB.w && posA.col + sizeA.w > posB.col + const overlapsV = + posA.row < posB.row + sizeB.h && posA.row + sizeA.h > posB.row + + return overlapsH && overlapsV +} + +export function rectanglesOverlap( + a: StoredWidget, + b: StoredWidget +): boolean { + return doRectanglesOverlap(a.position, a.size, b.position, b.size) +} + +export function getCollisions( + widget: StoredWidget, + layout: StoredWidget[] +): StoredWidget[] { + return layout.filter( + (other) => + other.instanceId !== widget.instanceId && + rectanglesOverlap(widget, other) + ) +} + +export function hasAnyCollision(layout: StoredWidget[]): boolean { + for (let i = 0; i < layout.length; i++) { + for (let j = i + 1; j < layout.length; j++) { + if (rectanglesOverlap(layout[i], layout[j])) { + return true + } + } + } + return false +} diff --git a/src/layouts/widgets/layout-engine/compact.ts b/src/layouts/widgets/layout-engine/compact.ts new file mode 100644 index 000000000..27f1f0ac9 --- /dev/null +++ b/src/layouts/widgets/layout-engine/compact.ts @@ -0,0 +1,102 @@ +import { doRectanglesOverlap } from './collision' +import type { StoredWidget } from './types' + +export interface CompactOptions { + fixedIds?: Set + onlyIds?: Set +} + +export function compactLayout( + layout: StoredWidget[], + cols: number, + fixedIdsOrOptions?: Set | CompactOptions +): StoredWidget[] { + const options: CompactOptions = + fixedIdsOrOptions instanceof Set + ? { fixedIds: fixedIdsOrOptions } + : (fixedIdsOrOptions ?? {}) + + const fixedIds = options.fixedIds ?? new Set() + const onlyIds = options.onlyIds + + const isFrozen = (instanceId: string) => + fixedIds.has(instanceId) || (onlyIds !== undefined && !onlyIds.has(instanceId)) + + const sorted = [...layout].sort((a, b) => { + if (a.position.row !== b.position.row) { + return a.position.row - b.position.row + } + return a.position.col - b.position.col + }) + + const result: StoredWidget[] = [] + const resolved = new Map() + let changed = false + + for (const widget of sorted) { + if (isFrozen(widget.instanceId)) { + result.push(widget) + resolved.set(widget.instanceId, widget) + continue + } + + let bestRow = widget.position.row + + for (let r = widget.position.row - 1; r >= 0; r--) { + let collides = false + + for (const placed of result) { + if ( + doRectanglesOverlap( + { col: widget.position.col, row: r }, + widget.size, + placed.position, + placed.size + ) + ) { + collides = true + break + } + } + + if (!collides) { + for (const other of layout) { + if ( + isFrozen(other.instanceId) && + other.instanceId !== widget.instanceId && + doRectanglesOverlap( + { col: widget.position.col, row: r }, + widget.size, + other.position, + other.size + ) + ) { + collides = true + break + } + } + } + + if (!collides) { + bestRow = r + } else { + break + } + } + + let next = widget + if (bestRow !== widget.position.row) { + next = { ...widget, position: { col: widget.position.col, row: bestRow } } + changed = true + } + + result.push(next) + resolved.set(widget.instanceId, next) + } + + if (!changed) { + return layout + } + + return layout.map((w) => resolved.get(w.instanceId) ?? w) +} diff --git a/src/layouts/widgets/layout-engine/constants.ts b/src/layouts/widgets/layout-engine/constants.ts new file mode 100644 index 000000000..b898bcc53 --- /dev/null +++ b/src/layouts/widgets/layout-engine/constants.ts @@ -0,0 +1,107 @@ +import { type GridBreakpoint, type StoredWidget, WidgetKeys } from './types' + +export const DEFAULT_COLS = 8 +export const DEFAULT_CELL_HEIGHT = 96 +export const DEFAULT_GAP = 8 +export const MIN_CELL_WIDTH = 72 + +export const GRID_BREAKPOINTS: GridBreakpoint[] = [ + { + name: 'lg', + minContainerWidth: 900, + cols: 8, + cellHeight: 96, + gap: 8, + }, + { + name: 'md', + minContainerWidth: 640, + cols: 6, + cellHeight: 88, + gap: 8, + }, + { + name: 'sm', + minContainerWidth: 420, + cols: 4, + cellHeight: 80, + gap: 6, + }, + { + name: 'xs', + minContainerWidth: 0, + cols: 2, + cellHeight: 72, + gap: 6, + }, +] + +export const DEFAULT_WIDGET_LAYOUT: StoredWidget[] = [ + { + id: WidgetKeys.clock, + instanceId: 'clock-default', + position: { col: 0, row: 0 }, + size: { w: 2, h: 1 }, + meta: { variant: 'digital' }, + }, + { + id: WidgetKeys.moodTracker, + instanceId: 'moodtracker-default', + position: { col: 0, row: 1 }, + size: { w: 2, h: 1 }, + }, + { + id: WidgetKeys.weather, + instanceId: 'weather-default', + position: { col: 0, row: 2 }, + size: { w: 2, h: 1 }, + }, + { + id: WidgetKeys.search, + instanceId: 'search-default', + position: { col: 2, row: 0 }, + size: { w: 4, h: 1 }, + }, + { + id: WidgetKeys.bookmarks, + instanceId: 'bookmarks-default', + position: { col: 2, row: 1 }, + size: { w: 4, h: 2 }, + }, + { + id: WidgetKeys.photo, + instanceId: 'photo-default', + position: { col: 6, row: 0 }, + size: { w: 2, h: 2 }, + }, + { + id: WidgetKeys.pet, + instanceId: 'pet-default', + position: { col: 6, row: 2 }, + size: { w: 2, h: 1 }, + }, + { + id: WidgetKeys.comboWidget, + instanceId: 'combo-widget-default', + position: { col: 0, row: 3 }, + size: { w: 2, h: 3 }, + }, + { + id: WidgetKeys.yadKar, + instanceId: 'yadkar-default', + position: { col: 2, row: 3 }, + size: { w: 2, h: 3 }, + }, + { + id: WidgetKeys.tools, + instanceId: 'tools-default', + position: { col: 4, row: 3 }, + size: { w: 2, h: 3 }, + }, + { + id: WidgetKeys.calendar, + instanceId: 'calendar-default', + position: { col: 6, row: 3 }, + size: { w: 2, h: 3 }, + }, +] diff --git a/src/layouts/widgets/layout-engine/identity.ts b/src/layouts/widgets/layout-engine/identity.ts new file mode 100644 index 000000000..6037c91a4 --- /dev/null +++ b/src/layouts/widgets/layout-engine/identity.ts @@ -0,0 +1,42 @@ +import type { StoredWidget } from './types' + +export function isSameWidgetState(a: StoredWidget, b: StoredWidget): boolean { + return ( + a.instanceId === b.instanceId && + a.id === b.id && + a.position.col === b.position.col && + a.position.row === b.position.row && + a.size.w === b.size.w && + a.size.h === b.size.h && + a.meta === b.meta && + a.disabled === b.disabled && + a.widgetId === b.widgetId + ) +} + +export function reconcileIdentity( + prev: StoredWidget[], + next: StoredWidget[] +): StoredWidget[] { + if (prev === next) return prev + + const previousById = new Map() + for (const widget of prev) { + previousById.set(widget.instanceId, widget) + } + + let reusedAll = prev.length === next.length + const merged = next.map((widget, index) => { + const previous = previousById.get(widget.instanceId) + if (previous && isSameWidgetState(previous, widget)) { + if (prev[index] !== previous) { + reusedAll = false + } + return previous + } + reusedAll = false + return widget + }) + + return reusedAll ? prev : merged +} diff --git a/src/layouts/widgets/layout-engine/index.ts b/src/layouts/widgets/layout-engine/index.ts new file mode 100644 index 000000000..34523f7a7 --- /dev/null +++ b/src/layouts/widgets/layout-engine/index.ts @@ -0,0 +1,10 @@ +export * from './types' +export * from './constants' +export * from './collision' +export * from './validation' +export * from './identity' +export * from './push' +export * from './compact' +export * from './placement' +export * from './layout-engine' +export * from '../grid-geometry' diff --git a/src/layouts/widgets/layout-engine/layout-engine.ts b/src/layouts/widgets/layout-engine/layout-engine.ts new file mode 100644 index 000000000..a90af7e38 --- /dev/null +++ b/src/layouts/widgets/layout-engine/layout-engine.ts @@ -0,0 +1,232 @@ +import { compactLayout } from './compact' +import { reconcileIdentity } from './identity' +import { findAvailableSlot, getBestAllowedSizeForColumns } from './placement' +import { resolveCollisions } from './push' +import type { + LayoutEngineOptions, + StoredWidget, + WidgetPosition, + WidgetSize, +} from './types' +import { validateLayout } from './validation' + +function patchWidget( + layout: StoredWidget[], + instanceId: string, + patch: { position?: WidgetPosition; size?: WidgetSize } +): StoredWidget[] | null { + const index = layout.findIndex((w) => w.instanceId === instanceId) + if (index === -1) return null + + const current = layout[index] + const next: StoredWidget = { + ...current, + position: patch.position ?? current.position, + size: patch.size ?? current.size, + } + + const updated = layout.slice() + updated[index] = next + return updated +} + +function assertValidInDev( + layout: StoredWidget[], + cols: number, + registry: LayoutEngineOptions['registry'], + operation: string +): void { + if (!import.meta.env.DEV) return + if (!validateLayout(layout, cols, registry)) { + console.error( + `[layout-engine] "${operation}" produced an invalid layout`, + layout + ) + } +} + +export function resolveLayoutChange( + options: LayoutEngineOptions +): StoredWidget[] | null { + const { + layout, + operation, + instanceId, + targetPosition, + targetSize, + newWidget, + cols, + allowedSizes, + registry, + } = options + + switch (operation) { + case 'move': { + if (!instanceId || !targetPosition) return null + + const current = layout.find((w) => w.instanceId === instanceId) + if (!current) return null + + const clampedCol = Math.max( + 0, + Math.min(cols - current.size.w, targetPosition.col) + ) + const clampedRow = Math.max(0, targetPosition.row) + + const patched = patchWidget(layout, instanceId, { + position: { col: clampedCol, row: clampedRow }, + }) + if (!patched) return null + + const resolved = resolveCollisions(patched, new Set([instanceId]), cols) + assertValidInDev(resolved, cols, registry, 'move') + + return reconcileIdentity(layout, resolved) + } + + case 'resize': { + if (!instanceId || !targetSize) return null + if (targetSize.w > cols || targetSize.w <= 0 || targetSize.h <= 0) { + return null + } + + const current = layout.find((w) => w.instanceId === instanceId) + if (!current) return null + + const nextSize: WidgetSize = { ...targetSize } + const nextPosition: WidgetPosition = + current.position.col + nextSize.w > cols + ? { col: Math.max(0, cols - nextSize.w), row: current.position.row } + : current.position + + const patched = patchWidget(layout, instanceId, { + position: nextPosition, + size: nextSize, + }) + if (!patched) return null + + const resolved = resolveCollisions(patched, new Set([instanceId]), cols) + assertValidInDev(resolved, cols, registry, 'resize') + + return reconcileIdentity(layout, resolved) + } + + case 'add': { + if (!newWidget) return null + if (newWidget.size.w > cols) return null + + const toAdd: StoredWidget = { + ...newWidget, + position: { ...newWidget.position }, + size: { ...newWidget.size }, + } + + if ( + targetPosition && + targetPosition.col + toAdd.size.w <= cols && + targetPosition.col >= 0 && + targetPosition.row >= 0 + ) { + toAdd.position = { ...targetPosition } + const resolved = resolveCollisions( + [...layout, toAdd], + new Set([toAdd.instanceId]), + cols + ) + if (validateLayout(resolved, cols, registry)) { + return resolved + } + } + + toAdd.position = findAvailableSlot(layout, toAdd.size, cols) + const appended = [...layout, toAdd] + + if (!validateLayout(appended, cols, registry)) { + return null + } + + return appended + } + + case 'duplicate': { + if (!instanceId) return null + const original = layout.find((w) => w.instanceId === instanceId) + if (!original) return null + + const duplicated: StoredWidget = newWidget + ? { + ...newWidget, + size: { ...newWidget.size }, + position: findAvailableSlot(layout, newWidget.size, cols), + } + : { + id: original.id, + instanceId: `${original.id}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`, + size: { ...original.size }, + position: findAvailableSlot(layout, original.size, cols), + } + + if (layout.some((w) => w.instanceId === duplicated.instanceId)) { + return null + } + + const appended = [...layout, duplicated] + + if (!validateLayout(appended, cols, registry)) { + return null + } + + return appended + } + + case 'remove': { + if (!instanceId) return null + const filtered = layout.filter((w) => w.instanceId !== instanceId) + if (!validateLayout(filtered, cols, registry)) { + return null + } + return filtered + } + + case 'responsive-reflow': { + const sorted = [...layout].sort((a, b) => { + if (a.position.row !== b.position.row) { + return a.position.row - b.position.row + } + return a.position.col - b.position.col + }) + + const reflowed: StoredWidget[] = [] + + for (const widget of sorted) { + const validSizes = allowedSizes || [widget.size] + const adaptedSize: WidgetSize = getBestAllowedSizeForColumns( + validSizes, + widget.size, + cols + ) + + const slot = findAvailableSlot(reflowed, adaptedSize, cols) + const isSame = + adaptedSize.w === widget.size.w && + adaptedSize.h === widget.size.h && + slot.col === widget.position.col && + slot.row === widget.position.row + + reflowed.push( + isSame ? widget : { ...widget, size: adaptedSize, position: slot } + ) + } + + const compacted = compactLayout(reflowed, cols) + if (!validateLayout(compacted, cols, registry)) { + return null + } + + return reconcileIdentity(layout, compacted) + } + + default: + return null + } +} diff --git a/src/layouts/widgets/layout-engine/placement.ts b/src/layouts/widgets/layout-engine/placement.ts new file mode 100644 index 000000000..f0c6b66d6 --- /dev/null +++ b/src/layouts/widgets/layout-engine/placement.ts @@ -0,0 +1,77 @@ +import { doRectanglesOverlap } from './collision' +import type { StoredWidget, WidgetPosition, WidgetSize } from './types' + +export function findAvailableSlot( + layout: StoredWidget[], + size: WidgetSize, + cols: number +): WidgetPosition { + const maxRow = + layout.length > 0 + ? Math.max(...layout.map((w) => w.position.row + w.size.h)) + 2 + : 2 + + const clampedW = Math.min(size.w, cols) + + for (let row = 0; row <= maxRow + 10; row++) { + for (let col = 0; col <= cols - clampedW; col++) { + let collides = false + + for (const widget of layout) { + if ( + doRectanglesOverlap( + { col, row }, + { w: clampedW, h: size.h }, + widget.position, + widget.size + ) + ) { + collides = true + break + } + } + + if (!collides) { + return { col, row } + } + } + } + + return { col: 0, row: maxRow } +} + +export function getBestAllowedSizeForColumns( + allowedSizes: WidgetSize[], + currentSize: WidgetSize, + cols: number +): WidgetSize { + if (currentSize.w <= cols) { + const isCurrentAllowed = allowedSizes.some( + (s) => s.w === currentSize.w && s.h === currentSize.h + ) + if (isCurrentAllowed) { + return currentSize + } + } + + const fittingSizes = allowedSizes.filter((s) => s.w <= cols) + if (fittingSizes.length === 0) { + return { + w: Math.max(1, Math.min(cols, currentSize.w)), + h: currentSize.h, + } + } + + fittingSizes.sort((a, b) => { + const diffW_A = Math.abs(a.w - currentSize.w) + const diffW_B = Math.abs(b.w - currentSize.w) + if (diffW_A !== diffW_B) { + return diffW_A - diffW_B + } + const diffH_A = Math.abs(a.h - currentSize.h) + const diffH_B = Math.abs(b.h - currentSize.h) + return diffH_A - diffH_B + }) + + return fittingSizes[0] +} diff --git a/src/layouts/widgets/layout-engine/push.ts b/src/layouts/widgets/layout-engine/push.ts new file mode 100644 index 000000000..38cbfb801 --- /dev/null +++ b/src/layouts/widgets/layout-engine/push.ts @@ -0,0 +1,130 @@ +import { doRectanglesOverlap } from './collision' +import { compactLayout } from './compact' +import type { StoredWidget, WidgetPosition, WidgetSize } from './types' + +export interface ResolveCollisionsOptions { + compact?: boolean +} + +export interface PushDownwardResult { + layout: StoredWidget[] + displacedIds: Set +} + +function overlapsAny( + position: WidgetPosition, + size: WidgetSize, + placed: StoredWidget[] +): boolean { + for (const other of placed) { + if (doRectanglesOverlap(position, size, other.position, other.size)) { + return true + } + } + return false +} + +function hasPinnedOverlap(pinned: StoredWidget[]): boolean { + for (let i = 0; i < pinned.length; i++) { + for (let j = i + 1; j < pinned.length; j++) { + if ( + doRectanglesOverlap( + pinned[i].position, + pinned[i].size, + pinned[j].position, + pinned[j].size + ) + ) { + return true + } + } + } + return false +} + +export function pushDownward( + layout: StoredWidget[], + fixedIds: Set +): PushDownwardResult { + const pinned: StoredWidget[] = [] + const movable: StoredWidget[] = [] + + for (const widget of layout) { + if (fixedIds.has(widget.instanceId)) { + pinned.push(widget) + } else { + movable.push(widget) + } + } + + if (hasPinnedOverlap(pinned)) { + if (import.meta.env.DEV) { + console.error( + '[layout-engine] pushDownward received overlapping pinned widgets', + pinned.map((w) => w.instanceId) + ) + } + return { layout, displacedIds: new Set() } + } + + movable.sort((a, b) => { + if (a.position.row !== b.position.row) { + return a.position.row - b.position.row + } + if (a.position.col !== b.position.col) { + return a.position.col - b.position.col + } + return a.instanceId < b.instanceId ? -1 : a.instanceId > b.instanceId ? 1 : 0 + }) + + const placed: StoredWidget[] = [...pinned] + const resolved = new Map() + const displacedIds = new Set() + + for (const widget of pinned) { + resolved.set(widget.instanceId, widget) + } + + for (const widget of movable) { + let row = widget.position.row + + while ( + overlapsAny({ col: widget.position.col, row }, widget.size, placed) + ) { + row++ + } + + let next = widget + if (row !== widget.position.row) { + next = { ...widget, position: { col: widget.position.col, row } } + displacedIds.add(widget.instanceId) + } + + placed.push(next) + resolved.set(widget.instanceId, next) + } + + if (displacedIds.size === 0) { + return { layout, displacedIds } + } + + return { + layout: layout.map((w) => resolved.get(w.instanceId) ?? w), + displacedIds, + } +} + +export function resolveCollisions( + layout: StoredWidget[], + fixedIds: Set, + cols: number, + options?: ResolveCollisionsOptions +): StoredWidget[] { + const { layout: pushed, displacedIds } = pushDownward(layout, fixedIds) + + if (!options?.compact || displacedIds.size === 0) { + return pushed + } + + return compactLayout(pushed, cols, { fixedIds, onlyIds: displacedIds }) +} diff --git a/src/layouts/widgets/layout-engine/types.ts b/src/layouts/widgets/layout-engine/types.ts new file mode 100644 index 000000000..694c343ef --- /dev/null +++ b/src/layouts/widgets/layout-engine/types.ts @@ -0,0 +1,113 @@ +export enum WidgetKeys { + comboWidget = 'comboWidget', + arzLive = 'arzLive', + news = 'news', + calendar = 'calendar', + weather = 'weather', + todos = 'todos', + tools = 'tools', + notes = 'notes', + network = 'network', + yadKar = 'yadKar', + HabitTracker = 'HabitTracker', + search = 'search', + bookmarks = 'bookmarks', + pet = 'pet', + transparentClock = 'transparentClock', + moodTracker = 'moodTracker', + googleCalendar = 'googleCalendar', + photo = 'photo', + clock = 'clock', +} + +export interface WidgetPosition { + col: number + row: number +} + +export interface WidgetSize { + w: number + h: number + isVipOnly?: boolean +} + +export interface StoredWidget { + id: WidgetKeys + instanceId: string + position: WidgetPosition + size: WidgetSize + widgetId?: string | null + meta?: any + disabled?: boolean +} + +export type LayoutOperation = + | 'move' + | 'resize' + | 'add' + | 'duplicate' + | 'remove' + | 'responsive-reflow' + +export interface GridBreakpoint { + name: 'xs' | 'sm' | 'md' | 'lg' + minContainerWidth: number + cols: number + cellHeight: number + gap: number +} + +import type { WidgetTabKeys } from '@/layouts/widgets-settings/tab-keys' + +export type WidgetCategory = 'all' | 'time' | 'productivity' | 'info' | 'lifestyle' + +export interface WidgetVariantOption { + id: string + label: string + size: WidgetSize + isVipOnly?: boolean + meta?: Record +} + +export interface WidgetDefinition { + id: WidgetKeys + label: string + emoji: string + category?: WidgetCategory + isVipOnly?: boolean + allowedSizes: WidgetSize[] + defaultSize: WidgetSize + canResize?: boolean // Allows manual resizing from the context menu even when the widget defines variants. + variants?: WidgetVariantOption[] + settingsTab?: WidgetTabKeys + canDuplicate: boolean + order?: number + canToggle?: boolean + isNew?: boolean + disabled?: boolean + soon?: boolean + popular?: boolean + isBeta?: boolean + node: (instanceId: string, size: WidgetSize, meta?: any) => React.ReactNode +} + +export interface WidgetItem + extends Omit< + WidgetDefinition, + 'allowedSizes' | 'defaultSize' | 'canDuplicate' | 'node' + > { + node: React.ReactNode + order: number +} + +export interface LayoutEngineOptions { + layout: StoredWidget[] + operation: LayoutOperation + instanceId?: string + targetPosition?: WidgetPosition + targetSize?: WidgetSize + newWidget?: StoredWidget + cols: number + allowedSizes?: WidgetSize[] + registry?: Record +} diff --git a/src/layouts/widgets/layout-engine/validation.ts b/src/layouts/widgets/layout-engine/validation.ts new file mode 100644 index 000000000..def8fec72 --- /dev/null +++ b/src/layouts/widgets/layout-engine/validation.ts @@ -0,0 +1,56 @@ +import { hasAnyCollision } from './collision' +import type { StoredWidget, WidgetDefinition } from './types' + +export function isWithinHorizontalBounds( + widget: StoredWidget, + cols: number +): boolean { + return ( + widget.position.col >= 0 && + widget.size.w > 0 && + widget.position.col + widget.size.w <= cols + ) +} + +export function isValidVerticalPosition(widget: StoredWidget): boolean { + return widget.position.row >= 0 && widget.size.h > 0 +} + +export function validateLayout( + layout: StoredWidget[], + cols: number, + registry?: Record +): boolean { + const instanceIds = new Set() + + for (const widget of layout) { + if (!widget.instanceId || instanceIds.has(widget.instanceId)) { + return false + } + instanceIds.add(widget.instanceId) + + if (!isWithinHorizontalBounds(widget, cols)) { + return false + } + + if (!isValidVerticalPosition(widget)) { + return false + } + + if (registry && registry[widget.id]) { + const def = registry[widget.id] + const sizeValid = def.allowedSizes.some( + (s) => s.w === widget.size.w && s.h === widget.size.h + ) + if (!sizeValid) { + return false + } + } + } + + if (hasAnyCollision(layout)) { + return false + } + + return true +} diff --git a/src/layouts/widgets/migration.ts b/src/layouts/widgets/migration.ts new file mode 100644 index 000000000..e03bf5062 --- /dev/null +++ b/src/layouts/widgets/migration.ts @@ -0,0 +1,166 @@ +import { getMultipleFromStorage, getFromStorage, setToStorage } from '@/common/storage' +import { DEFAULT_WIDGET_LAYOUT } from './layout-engine' +import { type StoredWidget, WidgetKeys } from './layout-engine/types' + +const DEFAULT_BOTTOM_WIDGETS: WidgetKeys[] = [ + WidgetKeys.comboWidget, + WidgetKeys.yadKar, + WidgetKeys.tools, + WidgetKeys.calendar, +] + +const topWidgets: StoredWidget[] = [ + { + id: WidgetKeys.clock, + instanceId: 'clock-default', + position: { col: 0, row: 0 }, + size: { w: 2, h: 1 }, + meta: { variant: 'digital' }, + }, + { + id: WidgetKeys.moodTracker, + instanceId: 'moodtracker-default', + position: { col: 0, row: 1 }, + size: { w: 2, h: 1 }, + }, + { + id: WidgetKeys.weather, + instanceId: 'weather-default', + position: { col: 0, row: 2 }, + size: { w: 2, h: 1 }, + }, + { + id: WidgetKeys.search, + instanceId: 'search-default', + position: { col: 2, row: 0 }, + size: { w: 4, h: 1 }, + }, + { + id: WidgetKeys.bookmarks, + instanceId: 'bookmarks-default', + position: { col: 2, row: 1 }, + size: { w: 4, h: 2 }, + }, + { + id: WidgetKeys.photo, + instanceId: 'photo-default', + position: { col: 6, row: 0 }, + size: { w: 2, h: 2 }, + widgetId: 'photo-default', + }, + { + id: WidgetKeys.pet, + instanceId: 'pet-default', + position: { col: 6, row: 2 }, + size: { w: 2, h: 1 }, + widgetId: 'pet-default', + }, +] + +export async function migrateWidgetLayoutIfNeeded(): Promise { + const existingStored = await getFromStorage('storedWidgets') + + if (Array.isArray(existingStored)) { + return existingStored + } + + const { appearance, activeWidgets } = await getMultipleFromStorage([ + 'appearance', + 'activeWidgets', + ]) + + let migratedLayout: StoredWidget[] = [] + + if (appearance?.ui === 'SIMPLE') { + const availableHeight = + typeof window !== 'undefined' ? window.innerHeight - 80 : 600 + const unitH = 96 + 8 + const totalRows = Math.max(6, Math.floor(availableHeight / unitH)) + const bottomStartRow = Math.max(1, totalRows - 3) + + migratedLayout = [ + { + id: WidgetKeys.search, + instanceId: 'search-default', + position: { col: 2, row: 0 }, + size: { w: 4, h: 1 }, + }, + { + id: WidgetKeys.clock, + instanceId: 'clock-default', + position: { col: 0, row: bottomStartRow }, + size: { w: 2, h: 1 }, + meta: { variant: 'digital' }, + }, + { + id: WidgetKeys.moodTracker, + instanceId: 'moodtracker-default', + position: { col: 0, row: bottomStartRow + 1 }, + size: { w: 1, h: 1 }, + }, + { + id: WidgetKeys.calendar, + instanceId: 'calendar-default', + position: { col: 1, row: bottomStartRow + 1 }, + size: { w: 1, h: 1 }, + }, + { + id: WidgetKeys.HabitTracker, + instanceId: 'habittracker-default', + position: { col: 0, row: bottomStartRow + 2 }, + size: { w: 2, h: 1 }, + }, + { + id: WidgetKeys.bookmarks, + instanceId: 'bookmarks-default', + position: { col: 2, row: bottomStartRow + 1 }, + size: { w: 4, h: 2 }, + }, + { + id: WidgetKeys.yadKar, + instanceId: 'yadkar-default', + position: { col: 6, row: bottomStartRow }, + size: { w: 2, h: 3 }, + }, + ] + } else { + let bottomKeys: WidgetKeys[] = DEFAULT_BOTTOM_WIDGETS + + if (Array.isArray(activeWidgets) && activeWidgets.length > 0) { + const validKeys = Object.values(WidgetKeys) + const extracted = [...activeWidgets] + .filter((w) => validKeys.includes(w?.id)) + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)) + .map((w) => w.id as WidgetKeys) + + if (extracted.length > 0) { + bottomKeys = extracted + } + } + + const bottomWidgets: StoredWidget[] = bottomKeys.map((key, index) => ({ + id: key, + instanceId: `${key}-bottom-${index}`, + position: { + col: (index % 4) * 2, + row: 3 + Math.floor(index / 4) * 3, + }, + size: { w: 2, h: 3 }, + })) + + migratedLayout = [...topWidgets, ...bottomWidgets] + } + + const finalLayout = migratedLayout.length > 0 ? migratedLayout : DEFAULT_WIDGET_LAYOUT + + await setToStorage('storedWidgets', finalLayout) + + if (appearance) { + await setToStorage('appearance', { + ...appearance, + ui: 'CUSTOM', + }) + } + + return finalLayout +} diff --git a/src/layouts/widgets/mood-tracker/components/mood-share-modal.tsx b/src/layouts/widgets/mood-tracker/components/mood-share-modal.tsx new file mode 100644 index 000000000..b7a6c4705 --- /dev/null +++ b/src/layouts/widgets/mood-tracker/components/mood-share-modal.tsx @@ -0,0 +1,101 @@ +import { useEffect, useRef, useState } from 'react' +import { Button, Modal } from '@/components/ui' +import { Icon } from '@/icons' +import { useAuth } from '@/context/auth.context' +import { copyCanvasToClipboard, downloadCanvasAsImage } from '@/common/utils/canvas' +import { useGetMoodStats } from '@/services/hooks/mood-log/get-mood-stats.hook' +import { renderMoodShareCanvas } from '../utils/render-mood-share-canvas' + +interface MoodShareModalProps { + isOpen: boolean + onClose: () => void +} + +export function MoodShareModal({ isOpen, onClose }: MoodShareModalProps) { + const { user, isAuthenticated } = useAuth() + const canvasRef = useRef(null) + const [isGenerating, setIsGenerating] = useState(false) + + const { data: statsData, isLoading } = useGetMoodStats( + isOpen && Boolean(isAuthenticated) + ) + + useEffect(() => { + if (!isOpen || !statsData) return + renderMoodShareCanvas(canvasRef.current, statsData, user?.name) + }, [isOpen, statsData, user?.name]) + + const handleCopyImage = async () => { + setIsGenerating(true) + await copyCanvasToClipboard(canvasRef.current) + setIsGenerating(false) + } + + const handleDownloadImage = () => { + const monthName = statsData?.currentJalaliMonthName || 'ماه' + downloadCanvasAsImage(canvasRef.current, `گزارش-حال-${monthName}`) + } + + return ( + + + + اشتراک‌گذاری حال این ماه + +
+ } + > +
+ {isLoading ? ( +
+ + در حال آماده‌سازی تصویر... +
+ ) : ( +
+ +
+ )} + +
+ + +
+ + + +
+
+
+ + ) +} diff --git a/src/layouts/widgets/mood-tracker/mood-tracker.widget.tsx b/src/layouts/widgets/mood-tracker/mood-tracker.widget.tsx new file mode 100644 index 000000000..8de618f0c --- /dev/null +++ b/src/layouts/widgets/mood-tracker/mood-tracker.widget.tsx @@ -0,0 +1,169 @@ +import { useMemo, useRef, useState } from 'react' +import { useGeneralSetting } from '@/context/general-setting.context' +import { getCurrentDate } from '@/layouts/widgets/calendar/utils/date-events' +import { useAuth } from '@/context/auth.context' +import { useGetMoods } from '@/services/hooks/mood-log/get-moods.hook' +import { + type MoodType, + useUpsertMoodLog, +} from '@/services/hooks/mood-log/upsert-mood-log.hook' +import { useQueryClient } from '@tanstack/react-query' +import { safeAwait } from '@/services/api' +import { autoFormatErrorToast, showToast } from '@/common/toast' +import { WidgetContainer } from '../widget-container' +import type { WidgetSize } from '../layout-engine/types' +import { Mood1x1 } from './variants/mood-tracker-1x1' +import { Mood2x1 } from './variants/mood-tracker-2x1' +import { MoodShareModal } from './components/mood-share-modal' +import { PopoverMenu, PopoverMenuItem, PopoverMenuHeader } from '@/components/ui' +import { Icon } from '@/icons' +import Analytics from '@/analytics' +import type { AxiosError } from 'axios' + +interface MoodTrackerWidgetProps { + size?: WidgetSize +} + +export function MoodTrackerWidget({ size = { w: 2, h: 1 } }: MoodTrackerWidgetProps) { + const queryClient = useQueryClient() + const { isAuthenticated } = useAuth() + const { selected_timezone: timezone } = useGeneralSetting() + const today = getCurrentDate(timezone.value) + const { mutateAsync: upsertMoodLog, isPending } = useUpsertMoodLog() + const [optimisticMood, setOptimisticMood] = useState(null) + const [isMenuOpen, setIsMenuOpen] = useState(false) + const [isShareModalOpen, setIsShareModalOpen] = useState(false) + const menuTriggerRef = useRef(null) + + const startStr = useMemo(() => { + return today.clone().subtract(6, 'days').doAsGregorian().format('YYYY-MM-DD') + }, [today]) + + const endStr = useMemo(() => { + return today.clone().doAsGregorian().format('YYYY-MM-DD') + }, [today]) + + const { data: moodsData } = useGetMoods(Boolean(isAuthenticated), startStr, endStr) + + const todayDateStr = useMemo(() => { + return today.clone().doAsGregorian().format('YYYY-MM-DD') + }, [today]) + + const todayMood = useMemo(() => { + if (optimisticMood) { + return { + date: todayDateStr, + mood: optimisticMood as any, + } + } + return moodsData?.moods?.find( + (m) => + m.date === todayDateStr || + m.date?.startsWith(todayDateStr) || + m.date?.split('T')[0] === todayDateStr + ) + }, [optimisticMood, moodsData?.moods, todayDateStr]) + + const handleSelectMood = async (moodValue: MoodType, targetDateStr?: string) => { + if (isPending) return + Analytics.event('mood_widget_clicked') + + const dateToLog = targetDateStr || todayDateStr + + if (dateToLog === todayDateStr) { + setOptimisticMood(moodValue) + } + + const [error, response] = await safeAwait< + AxiosError, + { action: 'added' | 'removed' } + >( + upsertMoodLog({ + mood: moodValue, + date: dateToLog, + }) + ) + + if (error) { + if (dateToLog === todayDateStr) { + setOptimisticMood(null) + } + autoFormatErrorToast(error) + return + } + + if (response?.action === 'removed') { + if (dateToLog === todayDateStr) { + setOptimisticMood(null) + } + showToast('حال روزانه شما حذف شد.', 'info') + } else { + if (dateToLog === todayDateStr) { + setOptimisticMood(moodValue) + } + showToast('حال روزانه شما ثبت شد.', 'success', { + alarmSound: true, + }) + } + + queryClient.invalidateQueries({ queryKey: ['get-moods'] }) + queryClient.invalidateQueries({ queryKey: ['get-calendar-data'] }) + } + + const handleOpenMenu = (e: React.MouseEvent) => { + e.stopPropagation() + setIsMenuOpen((prev) => !prev) + } + + const handleOpenShare = () => { + setIsMenuOpen(false) + setIsShareModalOpen(true) + Analytics.event('mood_share_modal_opened') + } + + return ( + <> + + {size.w === 1 && size.h === 1 ? ( + + ) : ( + + )} + + + setIsMenuOpen(false)} + triggerRef={menuTriggerRef} + width={180} + placement="bottom-end" + > + + حال روزانه + + } + label="اشتراک‌گذاری ماه" + onClick={handleOpenShare} + /> + + + setIsShareModalOpen(false)} + /> + + ) +} diff --git a/src/layouts/widgets/mood-tracker/utils/render-mood-share-canvas.ts b/src/layouts/widgets/mood-tracker/utils/render-mood-share-canvas.ts new file mode 100644 index 000000000..e8cece9fa --- /dev/null +++ b/src/layouts/widgets/mood-tracker/utils/render-mood-share-canvas.ts @@ -0,0 +1,349 @@ +import type { MoodStatsResponse } from '@/services/hooks/mood-log/get-mood-stats.hook' +import { drawRoundedRect, fitText } from '@/common/utils/canvas' + +const W = 1080 +const H = 1350 +const BG_BASE = '#05050a' +const TEXT_LIGHT = '#f8fafc' +const FONT_STACK = 'Vazir, Tahoma, Arial, sans-serif' + +const moodConfig: Record = { + excited: { + label: 'سرحال', + emoji: '😄', + colors: ['#00ff87', '#60efff'], + }, + happy: { + label: 'اوکی', + emoji: '🙂', + colors: ['#00f2fe', '#4facfe'], + }, + normal: { + label: 'خسته', + emoji: '😴', + colors: ['#ffb020', '#e69500'], + }, + tired: { + label: 'خسته', + emoji: '😴', + colors: ['#ffb020', '#e69500'], + }, + sad: { + label: 'ناراحت', + emoji: '😔', + colors: ['#ff4a5a', '#ff1f36'], + }, +} + +function getPersianDate(date: Date): { year: number; month: number; day: number } { + const parts = new Intl.DateTimeFormat('en-US-u-ca-persian', { + timeZone: 'Asia/Tehran', + year: 'numeric', + month: 'numeric', + day: 'numeric', + }).formatToParts(date) + + const pMap = Object.fromEntries(parts.map((p) => [p.type, p.value])) + return { + year: Number.parseInt(pMap.year, 10), + month: Number.parseInt(pMap.month, 10), + day: Number.parseInt(pMap.day, 10), + } +} + +function getPersianMonthName(date: Date): string { + return new Intl.DateTimeFormat('fa-IR-u-ca-persian', { + timeZone: 'Asia/Tehran', + month: 'long', + }).format(date) +} + +function wrapText( + ctx: CanvasRenderingContext2D, + text: string, + maxWidth: number +): string[] { + const words = text.split(' ') + const lines: string[] = [] + let currentLine = words[0] || '' + + for (let i = 1; i < words.length; i++) { + const word = words[i] + const testLine = `${currentLine} ${word}` + const width = ctx.measureText(testLine).width + if (width < maxWidth) { + currentLine = testLine + } else { + lines.push(currentLine) + currentLine = word + } + } + if (currentLine) { + lines.push(currentLine) + } + return lines +} + +function loadImageAsync(src: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image() + img.crossOrigin = 'anonymous' + img.onload = () => resolve(img) + img.onerror = () => reject(new Error(`Failed to load image: ${src}`)) + img.src = src + }) +} + +export async function renderMoodShareCanvas( + canvas: HTMLCanvasElement | null, + data: MoodStatsResponse, + userNameDefault?: string +) { + if (!canvas) return + + const ctx = canvas.getContext('2d') + if (!ctx) return + + canvas.width = W + canvas.height = H + + const logs = data.logs || [] + const counts: Record = { + excited: 0, + happy: 0, + normal: 0, + sad: 0, + } + + for (const log of logs) { + const key = log.mood === 'tired' ? 'normal' : log.mood + if (counts[key] !== undefined) { + counts[key]++ + } + } + + const dominant = Object.entries(counts).sort((a, b) => b[1] - a[1])[0]?.[0] || 'happy' + + const dominantMoodConfig = moodConfig[dominant] || moodConfig.happy + const dominantColor = dominantMoodConfig.colors[0] + + const now = new Date() + const currentPersian = getPersianDate(now) + const monthNumber = data.currentJalaliMonth || currentPersian.month + const monthName = data.currentJalaliMonthName || getPersianMonthName(now) + const daysInMonth = monthNumber <= 6 ? 31 : monthNumber <= 11 ? 30 : 29 + const total = logs.length + const progress = Math.min(total / daysInMonth, 1) + + ctx.fillStyle = BG_BASE + ctx.fillRect(0, 0, W, H) + + ctx.save() + const glow = ctx.createRadialGradient(W / 2, H / 3, 20, W / 2, H / 3, 520) + glow.addColorStop(0, `${dominantColor}24`) + glow.addColorStop(1, 'rgba(0,0,0,0)') + ctx.fillStyle = glow + ctx.fillRect(0, 0, W, H) + ctx.restore() + + ctx.save() + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.font = `bold 280px ${FONT_STACK}` + ctx.fillStyle = 'rgba(255, 255, 255, 0.02)' + ctx.fillText(monthName, W / 2, 400) + ctx.restore() + + const avatarX = W - 185 + const centerX = avatarX + 45 + const centerY = 140 + + ctx.save() + ctx.beginPath() + ctx.arc(centerX, centerY, 46, -Math.PI / 2, -Math.PI / 2 + 2 * Math.PI * progress) + ctx.strokeStyle = `${dominantColor}B3` + ctx.lineWidth = 4 + ctx.shadowColor = dominantColor + ctx.shadowBlur = 6 + ctx.stroke() + ctx.restore() + + if (data.userAvatar) { + try { + const avatarImg = await loadImageAsync(data.userAvatar) + ctx.save() + ctx.beginPath() + ctx.arc(centerX, centerY, 45, 0, Math.PI * 2) + ctx.clip() + ctx.drawImage(avatarImg, avatarX, 95, 90, 90) + ctx.restore() + } catch { + drawDefaultAvatar(ctx, centerX, centerY, dominantColor) + } + } else { + drawDefaultAvatar(ctx, centerX, centerY, dominantColor) + } + + const userName = + (data.userName || userNameDefault || 'کاربر عزیز').trim() || 'کاربر عزیز' + + ctx.save() + ctx.textAlign = 'right' + ctx.fillStyle = TEXT_LIGHT + ctx.font = `bold 38px ${FONT_STACK}` + ctx.fillText(fitText(ctx, userName, 450), avatarX - 25, 135) + ctx.fillStyle = 'rgba(255, 255, 255, 0.55)' + ctx.font = `24px ${FONT_STACK}` + ctx.fillText(`حس و حال ثبت شده در ماه ${monthName}`, avatarX - 25, 175) + ctx.restore() + + ctx.save() + ctx.textAlign = 'center' + ctx.font = '140px sans-serif' + ctx.fillText(dominantMoodConfig.emoji, W / 2, 430) + ctx.restore() + + ctx.save() + ctx.textAlign = 'center' + ctx.font = `bold 54px ${FONT_STACK}` + ctx.fillStyle = TEXT_LIGHT + ctx.fillText(dominantMoodConfig.label, W / 2, 530) + ctx.restore() + + const badge = data.badge || { + label: dominant === 'excited' ? 'روی موج بودم 🌊' : 'آروم بودم، خوب بودم 🌿', + color: dominant === 'excited' ? '#00ff87' : '#00f2fe', + } + + ctx.save() + ctx.strokeStyle = `${badge.color}30` + ctx.lineWidth = 2 + const badgeX = 120 + const badgeY = 115 + const badgeW = 280 + const badgeH = 50 + drawRoundedRect(ctx, badgeX, badgeY, badgeW, badgeH, 25) + ctx.fillStyle = 'rgba(255, 255, 255, 0.04)' + ctx.fill() + ctx.stroke() + ctx.fillStyle = `${badge.color}CC` + ctx.textAlign = 'center' + ctx.font = `20px ${FONT_STACK}` + ctx.fillText(badge.label, badgeX + badgeW / 2, badgeY + 32) + ctx.restore() + + const chartX = 160 + const chartY = 640 + const chartW = W - 320 + + ctx.save() + ctx.textAlign = 'center' + ctx.font = `20px ${FONT_STACK}` + const legendEntries = [ + moodConfig.excited, + moodConfig.happy, + moodConfig.normal, + moodConfig.sad, + ] + const itemW = 150 + const totalW = legendEntries.length * itemW + const startLegendX = (W - totalW) / 2 + + legendEntries.forEach((value, idx) => { + const x = startLegendX + idx * itemW + 75 + ctx.fillStyle = `${value.colors[0]}CC` + ctx.beginPath() + ctx.arc(x - 10, chartY - 38, 6, 0, Math.PI * 2) + ctx.fill() + ctx.fillStyle = 'rgba(255, 255, 255, 0.45)' + ctx.fillText(value.label, x - 50, chartY - 33) + }) + ctx.restore() + + const dayMoodMap = new Map() + for (const log of logs) { + const logDate = log.date instanceof Date ? log.date : new Date(log.date) + const p = getPersianDate(logDate) + if (p.month === monthNumber) { + dayMoodMap.set(p.day, log.mood === 'tired' ? 'normal' : log.mood) + } + } + + const gridCols = 7 + const gridRows = Math.ceil(daysInMonth / gridCols) + const gridGap = 14 + const maxGridHeight = 360 + + const cellByWidth = (chartW - gridGap * (gridCols - 1)) / gridCols + const cellByHeight = (maxGridHeight - gridGap * (gridRows - 1)) / gridRows + const cellSize = Math.min(cellByWidth, cellByHeight) + const gridWidthUsed = gridCols * cellSize + gridGap * (gridCols - 1) + const gridStartX = chartX + (chartW - gridWidthUsed) / 2 + const gridStartY = chartY + + ctx.save() + for (let day = 1; day <= daysInMonth; day++) { + const idx = day - 1 + const col = idx % gridCols + const row = Math.floor(idx / gridCols) + + const x = gridStartX + (gridCols - 1 - col) * (cellSize + gridGap) + const y = gridStartY + row * (cellSize + gridGap) + + const mood = dayMoodMap.get(day) + drawRoundedRect(ctx, x, y, cellSize, cellSize, 18) + + if (mood) { + const color = (moodConfig[mood] || moodConfig.happy).colors[0] + ctx.shadowColor = `${color}80` + ctx.shadowBlur = 12 + ctx.fillStyle = `${color}CC` + ctx.fill() + ctx.shadowBlur = 0 + } else { + ctx.fillStyle = 'rgba(255, 255, 255, 0.05)' + ctx.fill() + } + } + ctx.restore() + + const insightText = + data.insightText && data.insightText.trim().length > 0 + ? data.insightText.trim() + : `${userName} جان این آرامشی که داشتی واقعیه! نه ساختگی نه اجباری، از دل میومد` + + ctx.save() + ctx.textAlign = 'center' + ctx.font = `italic 32px ${FONT_STACK}` + ctx.fillStyle = 'rgba(255, 255, 255, 0.88)' + wrapText(ctx, insightText, W - 260).forEach((line, i) => + ctx.fillText(line, W / 2, 1120 + i * 48) + ) + ctx.restore() + + ctx.save() + ctx.textAlign = 'center' + ctx.fillStyle = 'rgba(255, 255, 255, 0.35)' + ctx.font = `22px ${FONT_STACK}` + ctx.fillText('افزونه مرورگر ویجتیفای - widgetify.ir', W / 2, H - 45) + ctx.restore() +} + +function drawDefaultAvatar( + ctx: CanvasRenderingContext2D, + cx: number, + cy: number, + accentColor: string +) { + ctx.save() + ctx.beginPath() + ctx.arc(cx, cy, 45, 0, Math.PI * 2) + ctx.fillStyle = `${accentColor}30` + ctx.fill() + ctx.fillStyle = '#ffffff' + ctx.font = '28px sans-serif' + ctx.textAlign = 'center' + ctx.textBaseline = 'middle' + ctx.fillText('👤', cx, cy) + ctx.restore() +} diff --git a/src/layouts/widgets/mood-tracker/variants/mood-tracker-1x1.tsx b/src/layouts/widgets/mood-tracker/variants/mood-tracker-1x1.tsx new file mode 100644 index 000000000..657f1cc74 --- /dev/null +++ b/src/layouts/widgets/mood-tracker/variants/mood-tracker-1x1.tsx @@ -0,0 +1,82 @@ +import { moodOptions } from '@/common/constants/moods' +import type { MoodEntry } from '@/services/hooks/mood-log/get-moods.hook' +import type { MoodType } from '@/services/hooks/mood-log/upsert-mood-log.hook' +import { cn } from '@/common/utils/cn' +import { Icon } from '@/icons' + +interface Mood1x1Props { + todayMood?: MoodEntry + onSelectMood: (mood: MoodType) => void + isSaving?: boolean + onOpenMenu?: (e: React.MouseEvent) => void + menuTriggerRef?: React.RefObject +} + +export function Mood1x1({ + todayMood, + onSelectMood, + isSaving, + onOpenMenu, + menuTriggerRef, +}: Mood1x1Props) { + const currentOption = moodOptions.find((m) => m.value === todayMood?.mood) + + return ( +
+
+ + {currentOption ? currentOption.label : 'حس امروزت؟'} + + + {onOpenMenu && ( + + )} +
+ +
+ + {currentOption ? currentOption.emoji : '🤍'} + +
+ +
+ {moodOptions.map((opt) => { + const isSelected = todayMood?.mood === opt.value + + return ( + + ) + })} +
+
+ ) +} diff --git a/src/layouts/widgets/mood-tracker/variants/mood-tracker-2x1.tsx b/src/layouts/widgets/mood-tracker/variants/mood-tracker-2x1.tsx new file mode 100644 index 000000000..bc3b0e734 --- /dev/null +++ b/src/layouts/widgets/mood-tracker/variants/mood-tracker-2x1.tsx @@ -0,0 +1,75 @@ +import { moodOptions } from '@/common/constants/moods' +import type { MoodEntry } from '@/services/hooks/mood-log/get-moods.hook' +import type { MoodType } from '@/services/hooks/mood-log/upsert-mood-log.hook' +import { cn } from '@/common/utils/cn' +import { Icon } from '@/icons' + +interface Mood2x1Props { + todayMood?: MoodEntry + onSelectMood: (mood: MoodType) => void + isSaving?: boolean + onOpenMenu?: (e: React.MouseEvent) => void + menuTriggerRef?: React.RefObject +} + +export function Mood2x1({ + todayMood, + onSelectMood, + isSaving, + onOpenMenu, + menuTriggerRef, +}: Mood2x1Props) { + return ( +
+
+ + امروز چه حسی داری؟ + + + {onOpenMenu && ( + + )} +
+ +
+ {moodOptions.map((opt) => { + const isSelected = todayMood?.mood === opt.value + + return ( + + ) + })} +
+
+ ) +} diff --git a/src/layouts/widgets/network/components/index.ts b/src/layouts/widgets/network/components/index.ts index 76e5f3cc2..8e85e03c8 100644 --- a/src/layouts/widgets/network/components/index.ts +++ b/src/layouts/widgets/network/components/index.ts @@ -1,3 +1,2 @@ export { NetworkIPCard } from './network-ip-card' -export { NetworkLoadingSkeleton } from './network-loading-skeleton' export { NetworkPingCard } from './network-ping-card' diff --git a/src/layouts/widgets/network/components/network-ip-card.tsx b/src/layouts/widgets/network/components/network-ip-card.tsx index 50d994efa..56d035ff9 100644 --- a/src/layouts/widgets/network/components/network-ip-card.tsx +++ b/src/layouts/widgets/network/components/network-ip-card.tsx @@ -19,7 +19,7 @@ export function NetworkIPCard({ ip, blurMode }: NetworkIPCardProps) {
آدرس IP
{ip || '.........'} diff --git a/src/layouts/widgets/network/network.layout.tsx b/src/layouts/widgets/network/network.widget.tsx similarity index 55% rename from src/layouts/widgets/network/network.layout.tsx rename to src/layouts/widgets/network/network.widget.tsx index a4ec135ba..a6904b4d1 100644 --- a/src/layouts/widgets/network/network.layout.tsx +++ b/src/layouts/widgets/network/network.widget.tsx @@ -9,7 +9,10 @@ import { useGeneralSetting } from '@/context/general-setting.context' import { getMainClient, safeAwait } from '@/services/api' import { WidgetContainer } from '../widget-container' import { NetworkIPCard, NetworkPingCard } from './components' -import { Icon } from '@/src/icons' +import { NetworkCompactSquare } from './variants/network-1x1' +import { NetworkCompactRow } from './variants/network-2x1' +import { Icon } from '@/icons' +import type { WidgetSize } from '../layout-engine/types' interface NetworkInfo { status: 'online' | 'offline' @@ -22,11 +25,22 @@ interface NetworkInfo { speed: string } +export enum NetworkLoadingState { + IDLE = 'IDLE', + INITIAL = 'INITIAL', + REFRESHING = 'REFRESHING', +} + interface Prop { inComboWidget: boolean enableBackground: boolean + size?: WidgetSize } -export function NetworkLayout({ enableBackground, inComboWidget }: Prop) { +export function NetworkLayout({ + enableBackground, + inComboWidget, + size = { w: 2, h: 2 }, +}: Prop) { const { blurMode } = useGeneralSetting() const { isAuthenticated } = useAuth() @@ -41,63 +55,87 @@ export function NetworkLayout({ enableBackground, inComboWidget }: Prop) { speed: 'به زودی..', }) - const [isLoading, setIsLoading] = useState(true) + const [loadingState, setLoadingState] = useState( + NetworkLoadingState.INITIAL + ) - const fetchNetworkData = async () => { - setIsLoading(true) - try { - const client = getMainClient() - const response = await client.get('/extension/@me/ip') - const data = response.data - setNetworkInfo((prev) => ({ - ...prev, - ip: data.ip, - country: data.country, - countryIcon: data.countryIcon, - city: data.city, - isp: data.isp, - })) - } catch { - setNetworkInfo((prev) => ({ - ...prev, - ip: 'N/A', - country: null, - countryIcon: null, - city: null, - isp: null, - })) + const fetchNetworkData = async (isRefresh = false) => { + setLoadingState( + isRefresh ? NetworkLoadingState.REFRESHING : NetworkLoadingState.INITIAL + ) + + const client = getMainClient() + + const fetchIp = async () => { + try { + const response = await client.get('/extension/@me/ip') + const data = response.data + return { + ip: data.ip, + country: data.country, + countryIcon: data.countryIcon, + city: data.city, + isp: data.isp, + } + } catch { + return { + ip: 'N/A', + country: null, + countryIcon: null, + city: null, + isp: null, + } + } } - try { - const client = getMainClient() - const start = Date.now() - const [err, _ok] = await safeAwait(client.get('/')) - if (err) { - if (!err.status) { + const fetchPing = async () => { + try { + const start = Date.now() + const [err, _ok] = await safeAwait(client.get('/')) + if (err && !err.status) { throw err } + const end = Date.now() + return end - start + } catch { + return null } - const end = Date.now() - setNetworkInfo((prev) => ({ ...prev, ping: end - start })) - } catch { - setNetworkInfo((prev) => ({ ...prev, ping: null })) } - setIsLoading(false) + + const [ipData, pingResult] = await Promise.all([fetchIp(), fetchPing()]) + + setNetworkInfo((prev) => ({ + ...prev, + ...ipData, + ping: pingResult, + })) + setLoadingState(NetworkLoadingState.IDLE) } useEffect(() => { - window.addEventListener('offline', () => { + const handleOffline = () => { setNetworkInfo((prev) => ({ ...prev, status: 'offline', })) - }) - window.addEventListener('online', () => { + } + const handleOnline = () => { setNetworkInfo((prev) => ({ ...prev, status: 'online', })) - }) + } + + window.addEventListener('offline', handleOffline) + window.addEventListener('online', handleOnline) + + return () => { + window.removeEventListener('offline', handleOffline) + window.removeEventListener('online', handleOnline) + } + }, []) + + useEffect(() => { if (isAuthenticated) { fetchNetworkData() } @@ -105,7 +143,50 @@ export function NetworkLayout({ enableBackground, inComboWidget }: Prop) { function handleRefresh() { Analytics.event('refresh_network_data') - fetchNetworkData() + fetchNetworkData(true) + } + + const isInitialLoading = loadingState === NetworkLoadingState.INITIAL + const isRefreshing = loadingState === NetworkLoadingState.REFRESHING + const isLoading = loadingState !== NetworkLoadingState.IDLE + + if (!inComboWidget) { + if (size.w === 1 && size.h === 1) { + return ( + + + + ) + } + + if (size.w === 2 && size.h === 1) { + return ( + + + + ) + } } return ( @@ -125,7 +206,7 @@ export function NetworkLayout({ enableBackground, inComboWidget }: Prop) { + )} +
+ + {city || 'شبکه'} + + {countryIcon ? ( + flag + ) : ( + + )} +
+
+ +
+ {isRefreshing ? ( +
+ ) : ( +
+ + {ping !== null ? ping : '--'} + + ms +
+ )} +
+ +
+ {isOnline ? ( + + ) : ( + + عدم دسترسی + + )} +
+
+ ) +} diff --git a/src/layouts/widgets/network/variants/network-2x1.tsx b/src/layouts/widgets/network/variants/network-2x1.tsx new file mode 100644 index 000000000..a4231624b --- /dev/null +++ b/src/layouts/widgets/network/variants/network-2x1.tsx @@ -0,0 +1,190 @@ +import { cn } from '@/common/utils/cn' +import { Button } from '@/components/ui' +import { Icon } from '@/icons' +import { showToast } from '@/common/toast' + +interface NetworkCompactRowProps { + status: 'online' | 'offline' + ip: string | null + countryIcon: string | null + city: string | null + isp: string | null + ping: number | null + isLoading: boolean + blurMode: boolean + onRefresh?: () => void +} + +function getPingQuality(ping: number | null) { + if (ping === null) return 'unknown' + if (ping < 80) return 'great' + if (ping < 160) return 'ok' + return 'poor' +} + +function cleanIspName(isp: string | null): string { + if (!isp) return 'نامشخص' + + const name = isp + .replace(/\(.*?\)/g, '') + .replace(/Company|Co\.|LLC|Inc\.|Corp\.|Ltd\.|Joint Stock/gi, '') + .trim() + + return name || isp +} + +export function NetworkCompactRow({ + status, + ip, + countryIcon, + city, + isp, + ping, + isLoading, + blurMode, + onRefresh, +}: NetworkCompactRowProps) { + const isOnline = status === 'online' + const pingQuality = getPingQuality(ping) + + const handleCopyIp = (e: React.MouseEvent) => { + e.stopPropagation() + if (ip && navigator?.clipboard) { + navigator.clipboard.writeText(ip).then(() => { + showToast('آدرس IP کپی شد', 'success') + }) + } + } + + if (isLoading) { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ) + } + + const displayIsp = cleanIspName(isp) + + return ( +
+
+
+ {isOnline ? ( +
+ + {ping !== null ? ping : '--'} + ms +
+ ) : ( +
+ + قطع +
+ )} + + + • + + + +
+ +
+ + {isOnline ? 'متصل' : 'اتصال ندارد'} + + + • + + + {city ? `${city}، ` : ''} + {displayIsp} + +
+
+ +
+
+ {countryIcon ? ( + پرچم کشور + ) : ( +
+ 🌐 +
+ )} + + +
+ + {onRefresh && ( + + )} +
+
+ ) +} diff --git a/src/layouts/widgets/news/components/news-container.tsx b/src/layouts/widgets/news/components/news-container.tsx index a3f545401..3b695bf0b 100644 --- a/src/layouts/widgets/news/components/news-container.tsx +++ b/src/layouts/widgets/news/components/news-container.tsx @@ -1,5 +1,5 @@ import type { RssFeed } from '../rss.interface' -import { RssFeedComponent } from '../rss-feed' +import { RssFeedComponent } from './rss-feed' interface NewsContainerProps { customFeeds: RssFeed[] @@ -18,7 +18,7 @@ export const NewsContainer = ({ customFeeds, useDefaultNews }: NewsContainerProp } return ( -
+
{enabledFeeds.map((feed) => ( ))} diff --git a/src/layouts/widgets/news/components/news-header.tsx b/src/layouts/widgets/news/components/news-header.tsx deleted file mode 100644 index 7b01037cd..000000000 --- a/src/layouts/widgets/news/components/news-header.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { Button } from '@/components/ui' -import { Icon } from '@/src/icons' - -interface NewsHeaderProps { - title: string - onSettingsClick: () => void -} - -export const NewsHeader = ({ title, onSettingsClick }: NewsHeaderProps) => { - return ( -
-
-
-

{title}

-
-
- -
- -
-
- ) -} diff --git a/src/layouts/widgets/news/components/news-item.tsx b/src/layouts/widgets/news/components/news-item.tsx index b60eb8df5..49f7cc409 100644 --- a/src/layouts/widgets/news/components/news-item.tsx +++ b/src/layouts/widgets/news/components/news-item.tsx @@ -1,4 +1,6 @@ +import { useState } from 'react' import moment from 'jalali-moment' +import { Icon } from '@/icons' interface NewsItemProps { title: string @@ -14,19 +16,26 @@ interface NewsItemProps { onClick: (url: string) => void } +const toPersianDigits = (str: string) => { + const persianDigits = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'] + return str.replace(/[0-9]/g, (w) => persianDigits[+w]) +} + export const NewsItem = ({ title, - description, source, publishedAt, link, image_url, onClick, }: NewsItemProps) => { - const formatDate = (dateString: string) => { + const [imageError, setImageError] = useState(false) + + const formatRelativeTime = (dateString: string) => { try { const date = new Date(dateString) - return moment(date).locale('fa').format('HH:mm - jYYYY/jMM/jDD') + if (isNaN(date.getTime())) return dateString + return toPersianDigits(moment(date).locale('fa').fromNow()) } catch { return dateString } @@ -37,38 +46,35 @@ export const NewsItem = ({ if (url && typeof url === 'string') onClick(url) } + const hasImage = Boolean(image_url && !imageError) + return ( -
-
- {image_url && ( - {title} - )} -
-

{title}

+
+ {hasImage ? ( + {title} setImageError(true)} + /> + ) : ( +
+
- {description && ( -

- {description.replace(/\n/g, ' ').replace(/<.*?>/g, '')} -

- )} -
- - {source.name} - - - {formatDate(publishedAt)} - + )} + +
+

+ {title} +

+
+ {source.name} + · + {formatRelativeTime(publishedAt)}
diff --git a/src/layouts/widgets/news/rss-feed.tsx b/src/layouts/widgets/news/components/rss-feed.tsx similarity index 84% rename from src/layouts/widgets/news/rss-feed.tsx rename to src/layouts/widgets/news/components/rss-feed.tsx index 361e5cee3..5c220478c 100644 --- a/src/layouts/widgets/news/rss-feed.tsx +++ b/src/layouts/widgets/news/components/rss-feed.tsx @@ -1,6 +1,6 @@ import Analytics from '@/analytics' -import { useGetRss } from '../../../services/hooks/news/get-news.hook' -import { NewsItem } from './components/news-item' +import { useGetRss } from '../../../../services/hooks/news/get-news.hook' +import { NewsItem } from './news-item' interface Prop { url: string diff --git a/src/layouts/widgets/news/news.layout.tsx b/src/layouts/widgets/news/news.widget.tsx similarity index 52% rename from src/layouts/widgets/news/news.layout.tsx rename to src/layouts/widgets/news/news.widget.tsx index 3dd2f4633..5cdbc47a7 100644 --- a/src/layouts/widgets/news/news.layout.tsx +++ b/src/layouts/widgets/news/news.widget.tsx @@ -1,15 +1,15 @@ import { useEffect, useState } from 'react' import { getFromStorage } from '@/common/storage' -import { callEvent, listenEvent } from '@/common/utils/call-event' -import { WidgetTabKeys } from '@/layouts/widgets-settings/constant/tab-keys' +import { listenEvent } from '@/common/utils/call-event' import { WidgetContainer } from '../widget-container' import { NewsContainer } from './components/news-container' -import { NewsHeader } from './components/news-header' import type { WigiNewsSetting } from './rss.interface' +import type { WidgetSize } from '../layout-engine/types' interface NewsLayoutProps { inComboWidget: boolean enableBackground?: boolean + size?: WidgetSize } export const NewsLayout: React.FC = ({ @@ -47,38 +47,27 @@ export const NewsLayout: React.FC = ({ } }, []) - return ( - <> - {inComboWidget ? ( -
- -
- ) : ( - - - callEvent('openWidgetsSettings', { - tab: WidgetTabKeys.news_settings, - }) - } - /> + if (inComboWidget) { + return ( +
+ +
+ ) + } - -
- )} - + return ( + + + ) } diff --git a/src/layouts/widgets/news/rss-feed-setting.tsx b/src/layouts/widgets/news/rss-feed-setting.tsx index 577bf219f..d9d1a5d1b 100644 --- a/src/layouts/widgets/news/rss-feed-setting.tsx +++ b/src/layouts/widgets/news/rss-feed-setting.tsx @@ -4,11 +4,11 @@ import { getFromStorage, setToStorage } from '@/common/storage' import { callEvent } from '@/common/utils/call-event' import { Button, CheckBoxWithDescription } from '@/components/ui' import { SectionPanel } from '@/components/ui' -import { TextInput } from '@/components/text-input' +import { TextInput } from '@/components/ui' import { ToggleSwitch } from '@/components/ui' import { WidgetSettingWrapper } from '@/layouts/widgets-settings/widget-settings-wrapper' import type { WigiNewsSetting } from './rss.interface' -import { Icon } from '@/src/icons' +import { Icon } from '@/icons' const SUGGESTED_FEEDS = [ { @@ -221,7 +221,7 @@ export const RssFeedSetting = () => { size="md" rounded={'2xl'} onClick={addNewFeed} - variant={'primary'} + color={'primary'} > افزودن فید جدید @@ -348,7 +348,7 @@ const FeedItem = ({ feed, disabled = false, onToggle, onRemove }: FeedItemProps)
+ + +
- + diff --git a/src/layouts/widgets/notes/notes.layout.tsx b/src/layouts/widgets/notes/notes.widget.tsx similarity index 62% rename from src/layouts/widgets/notes/notes.layout.tsx rename to src/layouts/widgets/notes/notes.widget.tsx index 49ad2232e..ea7cbf725 100644 --- a/src/layouts/widgets/notes/notes.layout.tsx +++ b/src/layouts/widgets/notes/notes.widget.tsx @@ -5,6 +5,8 @@ import { NoteEditor } from './components/note-editor' import { NoteItem } from './components/note-item' import { NoteNavigation } from './components/note-navigation' import { NoteEmpty } from './components/note-empty' +import { NoteSticky } from './variants/note-sticky' +import type { WidgetSize } from '../layout-engine/types' function NotesContent() { const { notes, activeNoteId } = useNotes() @@ -41,7 +43,7 @@ function NoteList() { return (
{notes.map((note) => ( @@ -50,18 +52,38 @@ function NoteList() { ) } -export function NotesLayout() { +interface NotesLayoutProps { + size?: WidgetSize + meta?: Record + instanceId?: string +} + +export function NotesLayout({ + size = { w: 2, h: 3 }, + meta, + instanceId, +}: NotesLayoutProps = {}) { + const isSticky = + meta?.variant === 'sticky' || + (!meta?.variant && size.w === 2 && size.h === 2) + return ( -
-
- -
-
+ {isSticky ? ( + + ) : ( +
+
+
+ +
+
-
- -
+
+ +
+
+ )}
) } diff --git a/src/layouts/widgets/notes/variants/note-sticky.tsx b/src/layouts/widgets/notes/variants/note-sticky.tsx new file mode 100644 index 000000000..703e291cb --- /dev/null +++ b/src/layouts/widgets/notes/variants/note-sticky.tsx @@ -0,0 +1,412 @@ +import { useEffect, useMemo, useState } from 'react' +import { + Button, + ConfirmationModal, + IconLoading, + TextInput, + Tooltip, +} from '@/components/ui' +import { useNotes } from '@/context/notes.context' +import { useAuth } from '@/context/auth.context' +import { useGeneralSetting } from '@/context/general-setting.context' +import { useFreeWidgetActions } from '@/context/free-widget/free-widget.context' +import Analytics from '@/analytics' +import { callEvent } from '@/common/utils/call-event' +import { cn } from '@/common/utils/cn' +import { Icon } from '@/icons' +import { PRIORITY_OPTIONS } from '@/common/constants/priority-options' +import moment from 'jalali-moment' + +const STICKY_COLOR_MAP: Record< + string, + { bg: string; border: string; text: string; headerBg: string; divider: string } +> = { + default: { + bg: 'bg-base-200 bg-glass', + border: 'border-base-content/10', + text: 'text-content', + headerBg: 'bg-base-300/40', + divider: 'border-base-content/10', + }, + low: { + bg: 'bg-success', + border: 'border-success-content/20', + text: 'text-success-content', + headerBg: 'bg-black/10', + divider: 'border-success-content/20', + }, + medium: { + bg: 'bg-warning', + border: 'border-warning-content/20', + text: 'text-warning-content', + headerBg: 'bg-black/10', + divider: 'border-warning-content/20', + }, + high: { + bg: 'bg-error', + border: 'border-error-content/20', + text: 'text-error-content', + headerBg: 'bg-black/10', + divider: 'border-error-content/20', + }, +} + +interface NoteStickyProps { + meta?: Record + instanceId?: string +} + +export function NoteSticky({ meta, instanceId }: NoteStickyProps = {}) { + const { isAuthenticated } = useAuth() + const { blurMode } = useGeneralSetting() + const { notes, addNote, updateNote, deleteNote, isSaving, isCreatingNote } = + useNotes() + const { updateWidgetSettings } = useFreeWidgetActions() + + const [currentIndex, setCurrentIndex] = useState(0) + const [localTitle, setLocalTitle] = useState('') + const [localBody, setLocalBody] = useState('') + const [localPriority, setLocalPriority] = useState< + 'low' | 'medium' | 'high' | undefined + >(undefined) + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + + const targetNoteId = meta?.activeNoteId || meta?.noteId + + const currentNote = useMemo(() => { + if (!notes.length) return null + if (targetNoteId) { + const found = notes.find((n) => n.id === targetNoteId) + if (found) return found + } + const validIndex = Math.min(Math.max(0, currentIndex), notes.length - 1) + return notes[validIndex] || notes[0] + }, [notes, targetNoteId, currentIndex]) + + useEffect(() => { + if (currentNote) { + setLocalTitle(currentNote.title || '') + setLocalBody(currentNote.body || '') + setLocalPriority(currentNote.priority) + const foundIndex = notes.findIndex((n) => n.id === currentNote.id) + if (foundIndex !== -1 && foundIndex !== currentIndex) { + setCurrentIndex(foundIndex) + } + const targetResolved = !targetNoteId || currentNote.id === targetNoteId + if ( + instanceId && + currentNote.id && + meta?.activeNoteId !== currentNote.id && + targetResolved + ) { + updateWidgetSettings(instanceId, { + ...meta, + activeNoteId: currentNote.id, + }) + } + } else { + setLocalTitle('') + setLocalBody('') + setLocalPriority(undefined) + } + }, [currentNote?.id]) + + const handleTitleChange = (val: string) => { + setLocalTitle(val) + if (currentNote) { + updateNote(currentNote.id, { + title: val, + body: localBody, + priority: localPriority, + }) + } + } + + const handleBodyChange = (val: string) => { + setLocalBody(val) + if (currentNote) { + updateNote(currentNote.id, { + title: localTitle, + body: val, + priority: localPriority, + }) + } + } + + const handlePriorityChange = (priorityKey?: 'low' | 'medium' | 'high') => { + if (!currentNote) return + const nextPriority = localPriority === priorityKey ? undefined : priorityKey + setLocalPriority(nextPriority) + updateNote(currentNote.id, { + title: localTitle, + body: localBody, + priority: nextPriority, + }) + } + + const handlePrevNote = (e: React.MouseEvent) => { + e.stopPropagation() + if (notes.length <= 1) return + const prev = (currentIndex - 1 + notes.length) % notes.length + setCurrentIndex(prev) + const target = notes[prev] + if (instanceId && target) { + updateWidgetSettings(instanceId, { + ...meta, + activeNoteId: target.id, + }) + } + Analytics.event('note_sticky_prev') + } + + const handleNextNote = (e: React.MouseEvent) => { + e.stopPropagation() + if (notes.length <= 1) return + const next = (currentIndex + 1) % notes.length + setCurrentIndex(next) + const target = notes[next] + if (instanceId && target) { + updateWidgetSettings(instanceId, { + ...meta, + activeNoteId: target.id, + }) + } + Analytics.event('note_sticky_next') + } + + const handleCreateNote = async () => { + if (!isAuthenticated) { + callEvent('open_require_auth_modal') + Analytics.event('note_open_required_auth_modal') + return + } + const created = await addNote({ + title: '', + body: '', + }) + if (created) { + setCurrentIndex(0) + if (instanceId) { + updateWidgetSettings(instanceId, { + ...meta, + activeNoteId: created.id, + }) + } + } + } + + const handleDelete = async () => { + setShowDeleteConfirm(false) + if (currentNote) { + await deleteNote(currentNote.id) + const remaining = notes.filter((n) => n.id !== currentNote.id) + const nextIdx = Math.min(currentIndex, Math.max(0, remaining.length - 1)) + setCurrentIndex(nextIdx) + if (instanceId) { + updateWidgetSettings(instanceId, { + ...meta, + activeNoteId: remaining[nextIdx]?.id, + }) + } + } + } + + const currentPriorityKey = localPriority || 'default' + const colorTheme = STICKY_COLOR_MAP[currentPriorityKey] || STICKY_COLOR_MAP.default + + return ( +
+
+
+ {currentNote ? ( + + ) : ( + + استیک نوت جدید + + )} +
+ +
+ {notes.length > 1 && ( +
+ + +
+ )} + + + + + + {currentNote && ( + + + + )} +
+
+ +
+ {currentNote ? ( +