1
0

feat: creator revenue page overhaul (#4204)

* feat: start on tax compliance

* feat: avarala1099 composable

* fix: shouldShow should be managed on the page itself

* refactor: move show logic to revenue page

* feat: security practices rather than info

* feat: withdraw page lock

* fix: empty modal bug & lint issues

* feat: hide behind feature flag

* Use standard admonition components, make casing consistent

* modal title

* lint

* feat: withdrawal check

* feat: tax cap on withdrawals warning

* feat: start on revenue page overhaul

* feat: segment generation for bar

* feat: tooltips and links

* fix: tooltip border

* feat: finish initial layout, start on withdraw modal

* feat: start on withdrawal limit stage

* feat: shade support for primary colors

* feat: start on withdraw details stage

* fix: convert swatches to hex

* feat: payout method/region dropdown temporarily using multiselect

* feat: fix modal open issues and use teleport dropdowns

* feat: hide transactions section if there are no transactions

* refactor: NavStack surfaces

* feat: new dropdown component

* feat: remove teleport dropdown modal in favour of new combobox component

* fix: lint

* refactor: dashboard sidebar layout

* feat: cleanup

* fix: niche bugs

* fix: ComboBox styling

* feat: first part of qa

* feat: animate flash rather than tooltip

* fix: lint

* feat: qa border gradient

* fix: seg hover flashes

* feat: i18n

* feat: i18n and final QA

* fix: lint

* feat: QA

* fix: lint

* fix: merge conflicts

* fix: intl

* fix: blue hover

* fix: transfers page

* feat: surface variables & gradients

* feat: text vars

* fix: lint

* fix: intl

* feat: stages

* fix: lint

* feat: region selection

* feat: method selection btns

* fix: flex col on transactions

* feat: hook up method selection to ctx

* feat: muralpay kyc stage info

* wip: muralpay integration

* Basic Mural Pay API bindings

* Fix clippy

* use dotenvy in muralpay example

* Refactor payout creation code

* wip: muralpay payout requests

* Mural Pay payouts work

* Fix clippy

* feat: progress

* fix: broken tax form stage logic

* polish: tax form stage and method selection stage layout

* add mural pay fees API

* Work on payout fee API

* Fees API for more payment methods

* Fix CI

* polish: muralpay qa

* refactor: clean up combobox component

* polish: change from critical -> warning admonition in MuralpayDetailsStage

* Temporarily disable Venmo and PayPal methods from frontend

* polish: clean up transaction component & page

* polish: navbar qa, text color-contrast in chips type buttonstyled, mb on rev/index.vue page

* fix: incorrectly using available balance as tax form withdraw limit after tax forms submitted

* wip: counterparties

* Start on counterparties and payment methods API

* polish: combobox component

* polish: fix broken scroll logic using a composable & web:fix

* fix: lint

* polish: various QA fixes

* feat: hook up with backend (wip)

* feat: draft muralpay rails dynamic logic

* polish: modify rails to support backend changes

* Mural Pay multiple methods when fetching

* Don't send supported_countries to frontend

* Mural Pay multiple methods when fetching

* Don't send supported_countries to frontend

* feat: fees & methods endpoint hookup

* chore: remove duplicates fix

* polish: qa changes + figma match

* Add countries to muralpay fiat methods

* Compile fix

* Add exchange rate info to fees endpoint

* Add fees to premium Tremendous options

* polish: i18n and better document type dropdown -> id input labels

* feat: tremendous

* fix: lint & i18n

* feat: reintroduce tin mismatch logic to index.vue

* polish: qa

* fix: i18n

* feat: remove teleport dropdown menu - combobox should be used

* fix: lint

* fix: jsdoc

* feat: checkbox for reward program terms

* Add delivery email field to Tremendous payouts

* Add Tremendous product category to payout methods

* Add bank details API to muralpay

* Fix CI

* Fix CI

* polish: qa changes

* feat: i18n pass

* feat: deduplicate methods endpoint & fix i18n issues

* chore: deduplicate i18n strings into common-messages.ts

* fix: lint

* fix: i18n

* feat: estimates

* polish: more QA

* Remove prepaid visa, compute fees properly for Tremendous methods

* Add more details to Tremendous errors

* feat: withdraw endpoint impl & internals refactor

* Add more details to Tremendous errors

* feat: completion stage

* Add fees to Mural

* feat: transactions page match figma

* fix: i18n

* polish: QA changes

* polish: qa

* Payout history route and bank details

* polish: autofill and requirements checks

* fix: i18n + lint

* fix: fiat rail fees

* polish: move scroll fade stuff into NewModal rather than just CreatorWithdrawModal

* feat: simplify action btn logic & tax form error

* fix: tax -> Tax form

* Re-add legacy PayPal/Venmo options for US

* feat: mobile responsiveness fixes for modal

* fix: responsiveness issues

* feat: navstack responsiveness

* fix: responsiveness

* move the mural bank details route

* fix: generated state cleanup & bank details input

* fix: lint & i18n

* Add utoipa support to payout endpoints

* address some PR comments

* polish: qa

* add CORS to new utoipa routes

* feat: legacy paypal/venmo stage

* polish: reset amount on back qa

* revert: navstack mr changes

* polish: loading indicator on method selection stage

* fix: paypal modal doesnt reopen after auth

* fix: lint & i18n

* fix: paypal flow

* polish: qa changes

* fix: gitignore

* polish: qa fixes

* fix: payouts_available in payouts.rs

* fix: bug when limit is zero

* polish: qa changes

* fix: qa stuff & muralpay sub-division fix

* Immediately approve mural payouts

* Add currency support to Tremendous payouts

* Currency forex

* add forex to tremendous fee request

* polish: qa & currency support for paypal tremendous

* polish: fx qa

* feat: demo mode flag

* fix: i18n & padding issues

* polish: qa changes

* fix: ml

* Add Mural balance to bank balance info

* polish: show warning for paypal international USD withdrawals + more currencies

* Add more Tremendous currencies support

* fix: colors on balance bars

* fix: empty states

* fix: pl-8 mobile issue

* fix: hide see all

* Transaction payouts available use the correct date

* Address my own review comment

* Address PR comments

* Change Mural withdrawal limit to 3k

* fix: empty state + paypal warning

* maybe fix tremendous gift cards

* Change how Mural minimum withdrawals are calculated

* Tweak min/max withdrawal values

* fix: segment brightness

* fix: min & max for muralpay & legacy paypal

* Fix some icon issues

* more issues

* fix user menu

* fix: remove + network

---------

Signed-off-by: Calum H. <contact@cal.engineer>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
Co-authored-by: aecsocket <aecsocket@tutanota.com>
Co-authored-by: Alejandro González <me@alegon.dev>
This commit is contained in:
Calum H.
2025-11-03 23:15:25 +00:00
committed by GitHub
parent 92698e4bb5
commit 3765a6ded8
108 changed files with 9071 additions and 2664 deletions

View File

Before

Width:  |  Height:  |  Size: 989 B

After

Width:  |  Height:  |  Size: 989 B

View File

Before

Width:  |  Height:  |  Size: 982 B

After

Width:  |  Height:  |  Size: 982 B

View File

Before

Width:  |  Height:  |  Size: 967 B

After

Width:  |  Height:  |  Size: 967 B

View File

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

Before

Width:  |  Height:  |  Size: 321 B

After

Width:  |  Height:  |  Size: 321 B

View File

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="7.056000232696533 3 37.35095977783203 45">
<g xmlns="http://www.w3.org/2000/svg" clip-path="url(#a)">
<path fill="#002991"
d="M38.914 13.35c0 5.574-5.144 12.15-12.927 12.15H18.49l-.368 2.322L16.373 39H7.056l5.605-36h15.095c5.083 0 9.082 2.833 10.555 6.77a9.687 9.687 0 0 1 .603 3.58z">
</path>
<path fill="#60CDFF"
d="M44.284 23.7A12.894 12.894 0 0 1 31.53 34.5h-5.206L24.157 48H14.89l1.483-9 1.75-11.178.367-2.322h7.497c7.773 0 12.927-6.576 12.927-12.15 3.825 1.974 6.055 5.963 5.37 10.35z">
</path>
<path fill="#008CFF"
d="M38.914 13.35C37.31 12.511 35.365 12 33.248 12h-12.64L18.49 25.5h7.497c7.773 0 12.927-6.576 12.927-12.15z">
</path>
</g>
</svg>

After

Width:  |  Height:  |  Size: 725 B

View File

Before

Width:  |  Height:  |  Size: 839 B

After

Width:  |  Height:  |  Size: 839 B

10
packages/assets/external/color/usdc.svg vendored Normal file
View File

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" data-name="86977684-12db-4850-8f30-233a7c267d11" viewBox="0 0 2000 2000">
<path d="M1000 2000c554.17 0 1000-445.83 1000-1000S1554.17 0 1000 0 0 445.83 0 1000s445.83 1000 1000 1000z"
fill="#2775ca" />
<path
d="M1275 1158.33c0-145.83-87.5-195.83-262.5-216.66-125-16.67-150-50-150-108.34s41.67-95.83 125-95.83c75 0 116.67 25 137.5 87.5 4.17 12.5 16.67 20.83 29.17 20.83h66.66c16.67 0 29.17-12.5 29.17-29.16v-4.17c-16.67-91.67-91.67-162.5-187.5-170.83v-100c0-16.67-12.5-29.17-33.33-33.34h-62.5c-16.67 0-29.17 12.5-33.34 33.34v95.83c-125 16.67-204.16 100-204.16 204.17 0 137.5 83.33 191.66 258.33 212.5 116.67 20.83 154.17 45.83 154.17 112.5s-58.34 112.5-137.5 112.5c-108.34 0-145.84-45.84-158.34-108.34-4.16-16.66-16.66-25-29.16-25h-70.84c-16.66 0-29.16 12.5-29.16 29.17v4.17c16.66 104.16 83.33 179.16 220.83 200v100c0 16.66 12.5 29.16 33.33 33.33h62.5c16.67 0 29.17-12.5 33.34-33.33v-100c125-20.84 208.33-108.34 208.33-220.84z"
fill="#fff" />
<path
d="M787.5 1595.83c-325-116.66-491.67-479.16-370.83-800 62.5-175 200-308.33 370.83-370.83 16.67-8.33 25-20.83 25-41.67V325c0-16.67-8.33-29.17-25-33.33-4.17 0-12.5 0-16.67 4.16-395.83 125-612.5 545.84-487.5 941.67 75 233.33 254.17 412.5 487.5 487.5 16.67 8.33 33.34 0 37.5-16.67 4.17-4.16 4.17-8.33 4.17-16.66v-58.34c0-12.5-12.5-29.16-25-37.5zM1229.17 295.83c-16.67-8.33-33.34 0-37.5 16.67-4.17 4.17-4.17 8.33-4.17 16.67v58.33c0 16.67 12.5 33.33 25 41.67 325 116.66 491.67 479.16 370.83 800-62.5 175-200 308.33-370.83 370.83-16.67 8.33-25 20.83-25 41.67V1700c0 16.67 8.33 29.17 25 33.33 4.17 0 12.5 0 16.67-4.16 395.83-125 612.5-545.84 487.5-941.67-75-237.5-258.34-416.67-487.5-491.67z"
fill="#fff" />
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<g transform="matrix(.124031 0 0 .124031 -.000001 56.062016)">
<rect y="-452" rx="61" height="516" width="516" fill="#3396cd" />
<path
d="M385.16-347c11.1 18.3 16.08 37.17 16.08 61 0 76-64.87 174.7-117.52 244H163.5l-48.2-288.35 105.3-10 25.6 205.17C270-174 299.43-235 299.43-276.56c0-22.77-3.9-38.25-10-51z"
fill="#fff" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 408 B

View File

@@ -1 +1,5 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>PayPal</title><path fill="currentColor" d="M7.016 19.198h-4.2a.562.562 0 0 1-.555-.65L5.093.584A.692.692 0 0 1 5.776 0h7.222c3.417 0 5.904 2.488 5.846 5.5-.006.25-.027.5-.066.747A6.794 6.794 0 0 1 12.071 12H8.743a.69.69 0 0 0-.682.583l-.325 2.056-.013.083-.692 4.39-.015.087zM19.79 6.142c-.01.087-.01.175-.023.261a7.76 7.76 0 0 1-7.695 6.598H9.007l-.283 1.795-.013.083-.692 4.39-.134.843-.014.088H6.86l-.497 3.15a.562.562 0 0 0 .555.65h3.612c.34 0 .63-.249.683-.585l.952-6.031a.692.692 0 0 1 .683-.584h2.126a6.793 6.793 0 0 0 6.707-5.752c.306-1.95-.466-3.744-1.89-4.906z"/></svg>
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<title>PayPal</title>
<path fill="currentColor"
d="M15.607 4.653H8.941L6.645 19.251H1.82L4.862 0h7.995c3.754 0 6.375 2.294 6.473 5.513-.648-.478-2.105-.86-3.722-.86m6.57 5.546c0 3.41-3.01 6.853-6.958 6.853h-2.493L11.595 24H6.74l1.845-11.538h3.592c4.208 0 7.346-3.634 7.153-6.949a5.24 5.24 0 0 1 2.848 4.686M9.653 5.546h6.408c.907 0 1.942.222 2.363.541-.195 2.741-2.655 5.483-6.441 5.483H8.714Z" />
</svg>

Before

Width:  |  Height:  |  Size: 658 B

After

Width:  |  Height:  |  Size: 481 B

5
packages/assets/external/polygon.svg vendored Normal file
View File

@@ -0,0 +1,5 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<title>Polygon</title>
<path fill="currentColor"
d="m17.82 16.342 5.692-3.287A.98.98 0 0 0 24 12.21V5.635a.98.98 0 0 0-.488-.846l-5.693-3.286a.98.98 0 0 0-.977 0L11.15 4.789a.98.98 0 0 0-.489.846v11.747L6.67 19.686l-3.992-2.304v-4.61l3.992-2.304 2.633 1.52V8.896L7.158 7.658a.98.98 0 0 0-.977 0L.488 10.945a.98.98 0 0 0-.488.846v6.573a.98.98 0 0 0 .488.847l5.693 3.286a.981.981 0 0 0 .977 0l5.692-3.286a.98.98 0 0 0 .489-.846V6.618l.072-.041 3.92-2.263 3.99 2.305v4.609l-3.99 2.304-2.63-1.517v3.092l2.14 1.236a.981.981 0 0 0 .978 0v-.001Z" />
</svg>

After

Width:  |  Height:  |  Size: 626 B

View File

@@ -1 +1,5 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Tumblr</title><path fill="currentColor" d="M14.563 24c-5.093 0-7.031-3.756-7.031-6.411V9.747H5.116V6.648c3.63-1.313 4.512-4.596 4.71-6.469C9.84.051 9.941 0 9.999 0h3.517v6.114h4.801v3.633h-4.82v7.47c.016 1.001.375 2.371 2.207 2.371h.09c.631-.02 1.486-.205 1.936-.419l1.156 3.425c-.436.636-2.4 1.374-4.156 1.404h-.178l.011.002z"/></svg>
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<title>Tumblr</title>
<path fill="currentColor"
d="M14.563 24c-5.093 0-7.031-3.756-7.031-6.411V9.747H5.116V6.648c3.63-1.313 4.512-4.596 4.71-6.469C9.84.051 9.941 0 9.999 0h3.517v6.114h4.801v3.633h-4.82v7.47c.016 1.001.375 2.371 2.207 2.371h.09c.631-.02 1.486-.205 1.936-.419l1.156 3.425c-.436.636-2.4 1.374-4.156 1.404h-.178l.011.002z" />
</svg>

Before

Width:  |  Height:  |  Size: 414 B

After

Width:  |  Height:  |  Size: 422 B

5
packages/assets/external/venmo.svg vendored Normal file
View File

@@ -0,0 +1,5 @@
<svg viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M56.4346 0C60.6127 0.000251497 63.9998 3.38727 64 7.56543V56.4346C63.9997 60.6127 60.6127 63.9997 56.4346 64H7.56543C3.38727 63.9998 0.0002515 60.6127 0 56.4346V7.56543C0.000249178 3.38727 3.38727 0.000249181 7.56543 0H56.4346ZM35.8984 15.4346C36.655 17.0159 37.1386 18.9358 37.1387 21.7598C37.1387 26.9145 33.4881 34.481 30.5361 39.2959L27.3613 13.8486L14.3008 15.0889L20.2793 50.8525H35.1904C41.7206 42.2572 49.7666 30.0151 49.7666 20.5889C49.7665 17.6334 49.1482 15.2931 47.7715 13.0234L35.8984 15.4346Z"
fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 625 B

5
packages/assets/external/visa.svg vendored Normal file
View File

@@ -0,0 +1,5 @@
<svg viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M24.2987 22.0321L15.92 42.0214H10.4533L6.33067 26.0667C6.08 25.0854 5.864 24.7254 5.10133 24.3121C3.85867 23.6374 1.80533 23.0054 0 22.6107L0.122667 22.0321H8.92267C9.49773 22.0315 10.0541 22.2365 10.4912 22.6101C10.9284 22.9837 11.2176 23.5013 11.3067 24.0694L13.4853 35.6374L18.8667 22.0321H24.2987ZM45.72 35.4961C45.7413 30.2187 38.424 29.9281 38.4747 27.5707C38.4907 26.8534 39.1733 26.0907 40.6667 25.8961C42.4168 25.7299 44.1793 26.0394 45.768 26.7921L46.6747 22.5521C45.1279 21.9706 43.4898 21.6699 41.8373 21.6641C36.7253 21.6641 33.128 24.3841 33.096 28.2747C33.064 31.1521 35.664 32.7547 37.624 33.7147C39.64 34.6934 40.3173 35.3227 40.3067 36.1974C40.2933 37.5414 38.7013 38.1307 37.2133 38.1547C34.6133 38.1947 33.1067 37.4534 31.9013 36.8934L30.9653 41.2721C32.1733 41.8267 34.4027 42.3121 36.7147 42.3334C42.1467 42.3334 45.7013 39.6507 45.72 35.4961ZM59.216 42.0214H64L59.8267 22.0321H55.4107C54.9388 22.0277 54.4766 22.1652 54.0838 22.4267C53.6911 22.6882 53.3859 23.0617 53.208 23.4987L45.4507 42.0214H50.88L51.96 39.0347H58.5947L59.216 42.0214ZM53.448 34.9387L56.168 27.4321L57.736 34.9387H53.448ZM31.688 22.0321L27.4133 42.0214H22.24L26.52 22.0321H31.688Z"
fill="currentColor" />
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -6,8 +6,13 @@ import _AlignLeftIcon from './icons/align-left.svg?component'
import _ArchiveIcon from './icons/archive.svg?component'
import _ArrowBigRightDashIcon from './icons/arrow-big-right-dash.svg?component'
import _ArrowBigUpDashIcon from './icons/arrow-big-up-dash.svg?component'
import _ArrowDownIcon from './icons/arrow-down.svg?component'
import _ArrowLeftRightIcon from './icons/arrow-left-right.svg?component'
import _ArrowUpIcon from './icons/arrow-up.svg?component'
import _ArrowUpRightIcon from './icons/arrow-up-right.svg?component'
import _AsteriskIcon from './icons/asterisk.svg?component'
import _BadgeCheckIcon from './icons/badge-check.svg?component'
import _BadgeDollarSignIcon from './icons/badge-dollar-sign.svg?component'
import _BanIcon from './icons/ban.svg?component'
import _BellIcon from './icons/bell.svg?component'
import _BellRingIcon from './icons/bell-ring.svg?component'
@@ -72,12 +77,14 @@ import _FolderSearchIcon from './icons/folder-search.svg?component'
import _GameIcon from './icons/game.svg?component'
import _GapIcon from './icons/gap.svg?component'
import _GaugeIcon from './icons/gauge.svg?component'
import _GiftIcon from './icons/gift.svg?component'
import _GitGraphIcon from './icons/git-graph.svg?component'
import _GlassesIcon from './icons/glasses.svg?component'
import _GlobeIcon from './icons/globe.svg?component'
import _GridIcon from './icons/grid.svg?component'
import _HamburgerIcon from './icons/hamburger.svg?component'
import _HammerIcon from './icons/hammer.svg?component'
import _HandHelpingIcon from './icons/hand-helping.svg?component'
import _HashIcon from './icons/hash.svg?component'
import _Heading1Icon from './icons/heading-1.svg?component'
import _Heading2Icon from './icons/heading-2.svg?component'
@@ -94,6 +101,7 @@ import _IssuesIcon from './icons/issues.svg?component'
import _ItalicIcon from './icons/italic.svg?component'
import _KeyIcon from './icons/key.svg?component'
import _KeyboardIcon from './icons/keyboard.svg?component'
import _LandmarkIcon from './icons/landmark.svg?component'
import _LanguagesIcon from './icons/languages.svg?component'
import _LeftArrowIcon from './icons/left-arrow.svg?component'
import _LibraryIcon from './icons/library.svg?component'
@@ -104,6 +112,7 @@ import _ListBulletedIcon from './icons/list-bulleted.svg?component'
import _ListEndIcon from './icons/list-end.svg?component'
import _ListOrderedIcon from './icons/list-ordered.svg?component'
import _LoaderIcon from './icons/loader.svg?component'
import _LoaderCircleIcon from './icons/loader-circle.svg?component'
import _LockIcon from './icons/lock.svg?component'
import _LockOpenIcon from './icons/lock-open.svg?component'
import _LogInIcon from './icons/log-in.svg?component'
@@ -210,8 +219,13 @@ export const AlignLeftIcon = _AlignLeftIcon
export const ArchiveIcon = _ArchiveIcon
export const ArrowBigRightDashIcon = _ArrowBigRightDashIcon
export const ArrowBigUpDashIcon = _ArrowBigUpDashIcon
export const ArrowDownIcon = _ArrowDownIcon
export const ArrowLeftRightIcon = _ArrowLeftRightIcon
export const ArrowUpRightIcon = _ArrowUpRightIcon
export const ArrowUpIcon = _ArrowUpIcon
export const AsteriskIcon = _AsteriskIcon
export const BadgeCheckIcon = _BadgeCheckIcon
export const BadgeDollarSignIcon = _BadgeDollarSignIcon
export const BanIcon = _BanIcon
export const BellRingIcon = _BellRingIcon
export const BellIcon = _BellIcon
@@ -276,12 +290,14 @@ export const FolderSearchIcon = _FolderSearchIcon
export const GameIcon = _GameIcon
export const GapIcon = _GapIcon
export const GaugeIcon = _GaugeIcon
export const GiftIcon = _GiftIcon
export const GitGraphIcon = _GitGraphIcon
export const GlassesIcon = _GlassesIcon
export const GlobeIcon = _GlobeIcon
export const GridIcon = _GridIcon
export const HamburgerIcon = _HamburgerIcon
export const HammerIcon = _HammerIcon
export const HandHelpingIcon = _HandHelpingIcon
export const HashIcon = _HashIcon
export const Heading1Icon = _Heading1Icon
export const Heading2Icon = _Heading2Icon
@@ -298,6 +314,7 @@ export const IssuesIcon = _IssuesIcon
export const ItalicIcon = _ItalicIcon
export const KeyIcon = _KeyIcon
export const KeyboardIcon = _KeyboardIcon
export const LandmarkIcon = _LandmarkIcon
export const LanguagesIcon = _LanguagesIcon
export const LeftArrowIcon = _LeftArrowIcon
export const LibraryIcon = _LibraryIcon
@@ -307,6 +324,7 @@ export const ListBulletedIcon = _ListBulletedIcon
export const ListEndIcon = _ListEndIcon
export const ListOrderedIcon = _ListOrderedIcon
export const ListIcon = _ListIcon
export const LoaderCircleIcon = _LoaderCircleIcon
export const LoaderIcon = _LoaderIcon
export const LockOpenIcon = _LockOpenIcon
export const LockIcon = _LockIcon

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-down-icon lucide-arrow-down">
<path d="M12 5v14" />
<path d="m19 12-7 7-7-7" />
</svg>

After

Width:  |  Height:  |  Size: 275 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-left-right-icon lucide-arrow-left-right"><path d="M8 3 4 7l4 4"/><path d="M4 7h16"/><path d="m16 21 4-4-4-4"/><path d="M20 17H4"/></svg>

After

Width:  |  Height:  |  Size: 344 B

View File

@@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-arrow-up-right-icon lucide-arrow-up-right"
>
<path d="M7 7h10v10" />
<path d="M7 17 17 7" />
</svg>

After

Width:  |  Height:  |  Size: 314 B

View File

@@ -0,0 +1,15 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="lucide lucide-arrow-up-icon lucide-arrow-up"
>
<path d="m5 12 7-7 7 7" />
<path d="M12 19V5" />
</svg>

After

Width:  |  Height:  |  Size: 303 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-badge-dollar-sign-icon lucide-badge-dollar-sign">
<path
d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z" />
<path d="M16 8h-6a2 2 0 1 0 0 4h4a2 2 0 1 1 0 4H8" />
<path d="M12 18V6" />
</svg>

After

Width:  |  Height:  |  Size: 491 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-gift-icon lucide-gift"><rect x="3" y="8" width="18" height="4" rx="1"/><path d="M12 8v13"/><path d="M19 12v7a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2v-7"/><path d="M7.5 8a2.5 2.5 0 0 1 0-5A4.8 8 0 0 1 12 8a4.8 8 0 0 1 4.5-5 2.5 2.5 0 0 1 0 5"/></svg>

After

Width:  |  Height:  |  Size: 441 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-hand-helping-icon lucide-hand-helping">
<path d="M11 12h2a2 2 0 1 0 0-4h-3c-.6 0-1.1.2-1.4.6L3 14" />
<path d="m7 18 1.6-1.4c.3-.4.8-.6 1.4-.6h4c1.1 0 2.1-.4 2.8-1.2l4.6-4.4a2 2 0 0 0-2.75-2.91l-4.2 3.9" />
<path d="m2 13 6 6" />
</svg>

After

Width:  |  Height:  |  Size: 420 B

View File

@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-landmark-icon lucide-landmark">
<path d="M10 18v-7" />
<path d="M11.12 2.198a2 2 0 0 1 1.76.006l7.866 3.847c.476.233.31.949-.22.949H3.474c-.53 0-.695-.716-.22-.949z" />
<path d="M14 18v-7" />
<path d="M18 18v-7" />
<path d="M3 22h18" />
<path d="M6 18v-7" />
</svg>

After

Width:  |  Height:  |  Size: 475 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-loader-circle-icon lucide-loader-circle"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>

After

Width:  |  Height:  |  Size: 288 B

View File

@@ -27,6 +27,15 @@ import _WavingRinthbot from './branding/rinthbot/waving.webp'
import _AppleIcon from './external/apple.svg?component'
import _BlueskyIcon from './external/bluesky.svg?component'
import _BuyMeACoffeeIcon from './external/bmac.svg?component'
import _DiscordColorIcon from './external/color/discord.svg?component'
import _GitHubColorIcon from './external/color/github.svg?component'
import _GitLabColorIcon from './external/color/gitlab.svg?component'
import _GoogleColorIcon from './external/color/google.svg?component'
import _MicrosoftColorIcon from './external/color/microsoft.svg?component'
import _PayPalColorIcon from './external/color/paypal.svg?component'
import _SteamColorIcon from './external/color/steam.svg?component'
import _USDCColorIcon from './external/color/usdc.svg?component'
import _VenmoColorIcon from './external/color/venmo.svg?component'
import _CurseForgeIcon from './external/curseforge.svg?component'
import _DiscordIcon from './external/discord.svg?component'
import _FacebookIcon from './external/facebook.svg?component'
@@ -37,20 +46,17 @@ import _MastodonIcon from './external/mastodon.svg?component'
import _OpenCollectiveIcon from './external/opencollective.svg?component'
import _PatreonIcon from './external/patreon.svg?component'
import _PayPalIcon from './external/paypal.svg?component'
import _PolygonIcon from './external/polygon.svg?component'
import _RedditIcon from './external/reddit.svg?component'
import _ReelsIcon from './external/reels.svg?component'
import _SnapchatIcon from './external/snapchat.svg?component'
import _SSODiscordIcon from './external/sso/discord.svg?component'
import _SSOGitHubIcon from './external/sso/github.svg?component'
import _SSOGitLabIcon from './external/sso/gitlab.svg?component'
import _SSOGoogleIcon from './external/sso/google.svg?component'
import _SSOMicrosoftIcon from './external/sso/microsoft.svg?component'
import _SSOSteamIcon from './external/sso/steam.svg?component'
import _ThreadsIcon from './external/threads.svg?component'
import _TikTokIcon from './external/tiktok.svg?component'
import _TumblrIcon from './external/tumblr.svg?component'
import _TwitchIcon from './external/twitch.svg?component'
import _TwitterIcon from './external/twitter.svg?component'
import _VenmoIcon from './external/venmo.svg?component'
import _VisaIcon from './external/visa.svg?component'
import _WindowsIcon from './external/windows.svg?component'
import _YouTubeIcon from './external/youtube.svg?component'
import _YouTubeGaming from './external/youtubegaming.svg?component'
@@ -70,12 +76,14 @@ export const SleepingRinthbot = _SleepingRinthbot
export const SobbingRinthbot = _SobbingRinthbot
export const ThinkingRinthbot = _ThinkingRinthbot
export const WavingRinthbot = _WavingRinthbot
export const SSODiscordIcon = _SSODiscordIcon
export const SSOGitHubIcon = _SSOGitHubIcon
export const SSOGitLabIcon = _SSOGitLabIcon
export const SSOGoogleIcon = _SSOGoogleIcon
export const SSOMicrosoftIcon = _SSOMicrosoftIcon
export const SSOSteamIcon = _SSOSteamIcon
export const PayPalColorIcon = _PayPalColorIcon
export const VenmoColorIcon = _VenmoColorIcon
export const DiscordColorIcon = _DiscordColorIcon
export const GitHubColorIcon = _GitHubColorIcon
export const GitLabColorIcon = _GitLabColorIcon
export const GoogleColorIcon = _GoogleColorIcon
export const MicrosoftColorIcon = _MicrosoftColorIcon
export const SteamColorIcon = _SteamColorIcon
export const AppleIcon = _AppleIcon
export const BlueskyIcon = _BlueskyIcon
export const BuyMeACoffeeIcon = _BuyMeACoffeeIcon
@@ -101,6 +109,10 @@ export const WindowsIcon = _WindowsIcon
export const YouTubeIcon = _YouTubeIcon
export const YouTubeGaming = _YouTubeGaming
export const YouTubeShortsIcon = _YouTubeShortsIcon
export const VenmoIcon = _VenmoIcon
export const PolygonIcon = _PolygonIcon
export const USDCColorIcon = _USDCColorIcon
export const VisaIcon = _VisaIcon
export * from './generated-icons'
export { default as ClassicPlayerModel } from './models/classic-player.gltf?url'

View File

@@ -1,23 +1,24 @@
<template>
<div
:class="[
'flex rounded-2xl border-2 border-solid p-4 gap-4 font-semibold text-contrast',
'flex flex-col rounded-2xl border-[1px] border-solid p-4 gap-3 text-contrast',
typeClasses[type],
]"
>
<component
:is="icons[type]"
:class="['hidden h-8 w-8 flex-none sm:block', iconClasses[type]]"
/>
<div class="flex flex-col gap-2">
<div class="font-semibold flex justify-between gap-4">
<slot name="header">{{ header }}</slot>
<div :class="['flex gap-2 items-start', (header || $slots.header) && 'flex-col']">
<div class="flex gap-2 items-start" :class="header || $slots.header ? 'w-full' : 'contents'">
<slot name="icon" :icon-class="['h-6 w-6 flex-none', iconClasses[type]]">
<component :is="icons[type]" :class="['h-6 w-6 flex-none', iconClasses[type]]" />
</slot>
<div v-if="header || $slots.header" class="font-semibold text-base">
<slot name="header">{{ header }}</slot>
</div>
</div>
<div class="font-normal text-sm sm:text-base">
<div class="font-normal text-base" :class="!(header || $slots.header) && 'flex-1'">
<slot>{{ body }}</slot>
</div>
</div>
<div class="ml-auto w-fit">
<div v-if="showActionsUnderneath || $slots.actions">
<slot name="actions" />
</div>
</div>
@@ -26,20 +27,20 @@
<script setup lang="ts">
import { InfoIcon, IssuesIcon, XCircleIcon } from '@modrinth/assets'
defineProps({
type: {
type: String as () => 'info' | 'warning' | 'critical',
default: 'info',
withDefaults(
defineProps<{
type?: 'info' | 'warning' | 'critical'
header?: string
body?: string
showActionsUnderneath?: boolean
}>(),
{
type: 'info',
header: '',
body: '',
showActionsUnderneath: false,
},
header: {
type: String,
default: '',
},
body: {
type: String,
default: '',
},
})
)
const typeClasses = {
info: 'border-brand-blue bg-bg-blue',

View File

@@ -0,0 +1,5 @@
<template>
<div
class="min-w-1.5 min-h-1.5 max-h-1.5 max-w-1.5 mx-0.5 rounded-full bg-surface-5 inline-block my-auto align-middle"
></div>
</template>

View File

@@ -6,7 +6,7 @@ const props = withDefaults(
color?: 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple' | 'medal-promo'
size?: 'standard' | 'large' | 'small'
circular?: boolean
type?: 'standard' | 'outlined' | 'transparent' | 'highlight' | 'highlight-colored-text'
type?: 'standard' | 'outlined' | 'transparent' | 'highlight' | 'highlight-colored-text' | 'chip'
colorFill?: 'auto' | 'background' | 'text' | 'none'
hoverColorFill?: 'auto' | 'background' | 'text' | 'none'
highlightedStyle?: 'main-nav-primary' | 'main-nav-secondary'
@@ -172,12 +172,16 @@ const colorVariables = computed(() => {
: 'var(--color-button-bg)',
text: 'var(--color-contrast)',
icon:
props.highlightedStyle === 'main-nav-primary'
? 'var(--color-brand)'
: 'var(--color-contrast)',
props.type === 'chip'
? 'var(--color-contrast)'
: props.highlightedStyle === 'main-nav-primary'
? 'var(--color-brand)'
: 'var(--color-contrast)',
}
const hoverColors = JSON.parse(JSON.stringify(colors))
return `--_bg: ${colors.bg}; --_text: ${colors.text}; --_icon: ${colors.icon}; --_hover-bg: ${hoverColors.bg}; --_hover-text: ${hoverColors.text}; --_hover-icon: ${hoverColors.icon};`
const boxShadow =
props.type === 'chip' && colorVar.value ? `0 0 0 2px ${colorVar.value}` : 'none'
return `--_bg: ${colors.bg}; --_text: ${colors.text}; --_icon: ${colors.icon}; --_hover-bg: ${hoverColors.bg}; --_hover-text: ${hoverColors.text}; --_hover-icon: ${hoverColors.icon}; --_box-shadow: ${boxShadow};`
}
let colors = {
@@ -197,6 +201,14 @@ const colorVariables = computed(() => {
hoverColors,
props.hoverColorFill === 'auto' ? 'text' : props.hoverColorFill,
)
} else if (props.type === 'chip') {
// Chip type uses highlight-colored-text styling when colored
if (colorVar.value && highlightedColorVar.value) {
colors.bg = highlightedColorVar.value
colors.text = colorVar.value
hoverColors.bg = highlightedColorVar.value
hoverColors.text = colorVar.value
}
} else {
colors = setColorFill(colors, props.colorFill === 'auto' ? 'background' : props.colorFill)
hoverColors = setColorFill(
@@ -205,7 +217,8 @@ const colorVariables = computed(() => {
)
}
return `--_bg: ${colors.bg}; --_text: ${colors.text}; --_hover-bg: ${hoverColors.bg}; --_hover-text: ${hoverColors.text};`
const boxShadow = props.type === 'chip' && colorVar.value ? `0 0 0 2px ${colorVar.value}` : 'none'
return `--_bg: ${colors.bg}; --_text: ${colors.text}; --_hover-bg: ${hoverColors.bg}; --_hover-text: ${hoverColors.text}; --_box-shadow: ${boxShadow};`
})
const fontSize = computed(() => {
@@ -219,7 +232,7 @@ const fontSize = computed(() => {
<template>
<div
class="btn-wrapper"
:class="[{ outline: type === 'outlined' }, fontSize]"
:class="[{ outline: type === 'outlined', chip: type === 'chip' }, fontSize]"
:style="`${colorVariables}--_height:${height};--_width:${width};--_radius: ${radius};--_padding-x:${paddingX};--_padding-y:${paddingY};--_gap:${gap};--_font-weight:${fontWeight};--_icon-size:${iconSize};`"
>
<slot />
@@ -242,10 +255,12 @@ const fontSize = computed(() => {
> *:first-child
> :is(button, a, .button-like):first-child {
@apply flex cursor-pointer flex-row items-center justify-center border-solid border-2 border-transparent bg-[--_bg] text-[--_text] h-[--_height] min-w-[--_width] rounded-[--_radius] px-[--_padding-x] py-[--_padding-y] gap-[--_gap] font-[--_font-weight] whitespace-nowrap;
box-shadow: var(--_box-shadow, inset 0 0 0 transparent);
transition:
scale 0.125s ease-in-out,
background-color 0.25s ease-in-out,
color 0.25s ease-in-out;
color 0.25s ease-in-out,
filter 0.25s ease-in-out;
svg:first-child {
color: var(--_icon, var(--_text));
@@ -267,7 +282,7 @@ const fontSize = computed(() => {
}
&:not([disabled]):not([disabled='true']):not(.disabled) {
@apply active:scale-95 hover:brightness-[--hover-brightness] focus-visible:brightness-[--hover-brightness] hover:bg-[--_hover-bg] hover:text-[--_hover-text] focus-visible:bg-[--_hover-bg] focus-visible:text-[--_hover-text];
@apply hover:brightness-[--hover-brightness] focus-visible:brightness-[--hover-brightness] hover:bg-[--_hover-bg] hover:text-[--_hover-text] focus-visible:bg-[--_hover-bg] focus-visible:text-[--_hover-text];
&:hover svg:first-child,
&:focus-visible svg:first-child {
@@ -276,6 +291,20 @@ const fontSize = computed(() => {
}
}
.btn-wrapper:not(.chip) :deep(:is(button, a, .button-like):first-child),
.btn-wrapper:not(.chip) :slotted(:is(button, a, .button-like):first-child),
.btn-wrapper:not(.chip) :slotted(*) > :is(button, a, .button-like):first-child,
.btn-wrapper:not(.chip) :slotted(*) > *:first-child > :is(button, a, .button-like):first-child,
.btn-wrapper:not(.chip)
:slotted(*)
> *:first-child
> *:first-child
> :is(button, a, .button-like):first-child {
&:not([disabled]):not([disabled='true']):not(.disabled) {
@apply active:scale-95;
}
}
.btn-wrapper.outline :deep(:is(button, a, .button-like):first-child),
.btn-wrapper.outline :slotted(:is(button, a, .button-like):first-child),
.btn-wrapper.outline :slotted(*) > :is(button, a, .button-like):first-child,

View File

@@ -0,0 +1,539 @@
<template>
<div ref="containerRef" class="relative inline-block w-full">
<span
ref="triggerRef"
role="button"
tabindex="0"
class="max-h-[36px] relative cursor-pointer flex min-h-5 w-full items-center justify-between overflow-hidden rounded-xl bg-button-bg px-4 py-2.5 text-left transition-all duration-200 text-button-text hover:bg-button-bgHover active:bg-button-bgActive"
:class="[
triggerClasses,
{
'z-[9999]': isOpen,
'rounded-b-none': shouldRoundBottomCorners,
'rounded-t-none': shouldRoundTopCorners,
'cursor-not-allowed opacity-50': disabled,
},
]"
:aria-expanded="isOpen"
:aria-haspopup="listbox ? 'listbox' : 'menu'"
:aria-disabled="disabled || undefined"
@click="handleTriggerClick"
@keydown="handleTriggerKeydown"
>
<div class="flex items-center gap-2">
<slot name="prefix"></slot>
<span class="text-primary font-semibold leading-tight">
<slot name="selected">{{ triggerText }}</slot>
</span>
</div>
<div class="flex items-center gap-1">
<slot name="suffix"></slot>
<ChevronLeftIcon
v-if="showChevron"
class="size-5 shrink-0 transition-transform duration-300"
:class="isOpen ? (openDirection === 'down' ? 'rotate-90' : '-rotate-90') : '-rotate-90'"
/>
</div>
</span>
<Teleport to="#teleports">
<div
v-if="isOpen"
ref="dropdownRef"
class="fixed z-[9999] flex flex-col overflow-hidden rounded-[14px] bg-surface-4 !border-solid border-0 shadow-2xl"
:class="[
shouldRoundBottomCorners
? 'rounded-t-none !border-t-[1px] !border-t-surface-5'
: 'rounded-b-none !border-b-[1px] !border-b-surface-5',
]"
:style="dropdownStyle"
:role="listbox ? 'listbox' : 'menu'"
@mousedown.stop
@keydown="handleDropdownKeydown"
>
<div v-if="searchable" class="p-4">
<div class="iconified-input w-full border-surface-5 border-[1px] border-solid rounded-xl">
<SearchIcon aria-hidden="true" />
<input
ref="searchInputRef"
v-model="searchQuery"
type="text"
:placeholder="searchPlaceholder"
class=""
@keydown.stop="handleSearchKeydown"
/>
</div>
</div>
<div v-if="searchable && filteredOptions.length > 0" class="h-px bg-surface-5"></div>
<div
v-if="filteredOptions.length > 0"
ref="optionsContainerRef"
class="flex flex-col gap-2 overflow-y-auto p-3"
:style="{ maxHeight: `${maxHeight}px` }"
>
<template v-for="(item, index) in filteredOptions" :key="item.key">
<div v-if="item.type === 'divider'" class="h-px bg-surface-5"></div>
<component
:is="item.type === 'link' ? 'a' : 'span'"
v-else
:ref="(el: HTMLElement) => setOptionRef(el as HTMLElement, index)"
:href="item.type === 'link' && !item.disabled ? item.href : undefined"
:target="item.type === 'link' && !item.disabled ? item.target : undefined"
:role="listbox ? 'option' : 'menuitem'"
:aria-selected="listbox && item.value === modelValue"
:aria-disabled="item.disabled || undefined"
:data-focused="focusedIndex === index"
class="flex items-center gap-2.5 cursor-pointer rounded-xl p-3 text-left transition-colors duration-150 text-contrast hover:bg-surface-5 focus:bg-surface-5"
:class="getOptionClasses(item, index)"
tabindex="-1"
@click="handleOptionClick(item, index)"
@mouseenter="!item.disabled && (focusedIndex = index)"
>
<slot :name="`option-${item.value}`" :item="item">
<div class="flex items-center gap-2">
<component :is="item.icon" v-if="item.icon" class="h-5 w-5" />
<span
class="font-semibold leading-tight"
:class="item.value === modelValue ? 'text-contrast' : 'text-primary'"
>
{{ item.label }}
</span>
</div>
</slot>
</component>
</template>
</div>
<div v-else-if="searchQuery" class="p-4 mb-2 text-center text-sm text-secondary">
No results found
</div>
</div>
</Teleport>
</div>
</template>
<script setup lang="ts" generic="T">
import { ChevronLeftIcon, SearchIcon } from '@modrinth/assets'
import { onClickOutside } from '@vueuse/core'
import {
type Component,
computed,
nextTick,
onMounted,
onUnmounted,
ref,
useSlots,
watch,
} from 'vue'
export interface DropdownOption<T> {
value: T
label: string
icon?: Component
disabled?: boolean
class?: string
type?: 'button' | 'link' | 'divider'
href?: string
target?: string
action?: () => void
}
const DROPDOWN_VIEWPORT_MARGIN = 8
const DEFAULT_MAX_HEIGHT = 300
function isDropdownOption<T>(
opt: DropdownOption<T> | { type: 'divider' },
): opt is DropdownOption<T> {
return 'value' in opt
}
function isDivider<T>(opt: DropdownOption<T> | { type: 'divider' }): opt is { type: 'divider' } {
return opt.type === 'divider'
}
const props = withDefaults(
defineProps<{
modelValue?: T
options: (DropdownOption<T> | { type: 'divider' })[]
placeholder?: string
disabled?: boolean
searchable?: boolean
searchPlaceholder?: string
listbox?: boolean
showChevron?: boolean
maxHeight?: number
displayValue?: string
extraPosition?: 'top' | 'bottom'
triggerClass?: string
forceDirection?: 'up' | 'down'
}>(),
{
placeholder: 'Select an option',
disabled: false,
searchable: false,
searchPlaceholder: 'Search...',
listbox: true,
showChevron: true,
maxHeight: DEFAULT_MAX_HEIGHT,
extraPosition: 'bottom',
},
)
const emit = defineEmits<{
'update:modelValue': [value: T]
select: [option: DropdownOption<T>]
open: []
close: []
}>()
const slots = useSlots()
const isOpen = ref(false)
const searchQuery = ref('')
const focusedIndex = ref(-1)
const containerRef = ref<HTMLElement>()
const triggerRef = ref<HTMLElement>()
const dropdownRef = ref<HTMLElement>()
const searchInputRef = ref<HTMLInputElement>()
const optionsContainerRef = ref<HTMLElement>()
const optionRefs = ref<(HTMLElement | null)[]>([])
const dropdownStyle = ref({
top: '0px',
left: '0px',
width: '0px',
})
const openDirection = ref<'down' | 'up'>('down')
const triggerClasses = computed(() => {
const classes = [props.triggerClass]
if (isOpen.value) {
if (props.extraPosition === 'bottom' && slots?.extra) {
classes.push('!rounded-b-none')
} else if (props.extraPosition === 'top' && slots?.extra) {
classes.push('!rounded-t-none')
}
}
return classes
})
const selectedOption = computed<DropdownOption<T> | undefined>(() => {
return props.options.find(
(opt): opt is DropdownOption<T> => isDropdownOption(opt) && opt.value === props.modelValue,
)
})
const triggerText = computed(() => {
if (props.displayValue !== undefined) return props.displayValue
if (selectedOption.value) return selectedOption.value.label
return props.placeholder
})
const optionsWithKeys = computed(() => {
return props.options.map((opt, index) => ({
...opt,
key: isDivider(opt) ? `divider-${index}` : `option-${opt.value}`,
}))
})
const filteredOptions = computed(() => {
if (!searchQuery.value || !props.searchable) {
return optionsWithKeys.value
}
const query = searchQuery.value.toLowerCase()
return optionsWithKeys.value.filter((opt) => {
if (isDivider(opt)) return false
return opt.label.toLowerCase().includes(query)
})
})
const shouldRoundBottomCorners = computed(() => isOpen.value && openDirection.value === 'down')
const shouldRoundTopCorners = computed(() => isOpen.value && openDirection.value === 'up')
function getOptionClasses(item: DropdownOption<T> & { key: string }, index: number) {
return [
item.class,
{
'bg-surface-5':
(props.listbox && item.value === props.modelValue) ||
(focusedIndex.value === index && !(props.listbox && item.value === props.modelValue)),
'cursor-not-allowed opacity-50 pointer-events-none': item.disabled,
},
]
}
function setOptionRef(el: HTMLElement | null, index: number) {
optionRefs.value[index] = el
}
function setInitialFocus() {
focusedIndex.value = props.listbox
? props.options.findIndex((opt) => isDropdownOption(opt) && opt.value === props.modelValue)
: -1
if (focusedIndex.value >= 0 && optionRefs.value[focusedIndex.value]) {
optionRefs.value[focusedIndex.value]?.scrollIntoView({ block: 'center' })
}
}
function focusSearchInput() {
if (props.searchable && searchInputRef.value) {
searchInputRef.value.focus()
}
}
function determineOpenDirection(
triggerRect: DOMRect,
dropdownRect: DOMRect,
viewportHeight: number,
): 'up' | 'down' {
if (props.forceDirection) {
return props.forceDirection
}
const hasSpaceBelow =
triggerRect.bottom + dropdownRect.height + DROPDOWN_VIEWPORT_MARGIN <= viewportHeight
const hasSpaceAbove = triggerRect.top - dropdownRect.height - DROPDOWN_VIEWPORT_MARGIN > 0
return !hasSpaceBelow && hasSpaceAbove ? 'up' : 'down'
}
function calculateVerticalPosition(
triggerRect: DOMRect,
dropdownRect: DOMRect,
direction: 'up' | 'down',
): number {
return direction === 'up' ? triggerRect.top - dropdownRect.height : triggerRect.bottom
}
function calculateHorizontalPosition(
triggerRect: DOMRect,
dropdownRect: DOMRect,
viewportWidth: number,
): number {
let left = triggerRect.left
if (left + dropdownRect.width > viewportWidth - DROPDOWN_VIEWPORT_MARGIN) {
left = Math.max(
DROPDOWN_VIEWPORT_MARGIN,
viewportWidth - dropdownRect.width - DROPDOWN_VIEWPORT_MARGIN,
)
}
return left
}
async function updateDropdownPosition() {
if (!triggerRef.value || !dropdownRef.value) return
await nextTick()
const triggerRect = triggerRef.value.getBoundingClientRect()
const dropdownRect = dropdownRef.value.getBoundingClientRect()
const viewportHeight = window.innerHeight
const viewportWidth = window.innerWidth
const direction = determineOpenDirection(triggerRect, dropdownRect, viewportHeight)
const top = calculateVerticalPosition(triggerRect, dropdownRect, direction)
const left = calculateHorizontalPosition(triggerRect, dropdownRect, viewportWidth)
dropdownStyle.value = {
top: `${top}px`,
left: `${left}px`,
width: `${triggerRect.width}px`,
}
openDirection.value = direction
}
async function openDropdown() {
if (props.disabled || isOpen.value) return
isOpen.value = true
searchQuery.value = ''
emit('open')
await nextTick()
await updateDropdownPosition()
setInitialFocus()
focusSearchInput()
}
function closeDropdown() {
if (!isOpen.value) return
isOpen.value = false
searchQuery.value = ''
focusedIndex.value = -1
emit('close')
nextTick(() => {
triggerRef.value?.focus()
})
}
function handleTriggerClick() {
if (isOpen.value) {
closeDropdown()
} else {
openDropdown()
}
}
function handleOptionClick(option: DropdownOption<T>, index: number) {
if (option.disabled || option.type === 'divider') return
focusedIndex.value = index
if (option.action) {
option.action()
}
if (props.listbox && option.value !== undefined) {
emit('update:modelValue', option.value)
}
emit('select', option)
if (option.type !== 'link') {
closeDropdown()
}
}
function findNextFocusableOption(currentIndex: number, direction: 'next' | 'previous'): number {
const length = filteredOptions.value.length
let index = currentIndex
let option
do {
index = direction === 'next' ? (index + 1) % length : (index - 1 + length) % length
option = filteredOptions.value[index]
} while (isDivider(option) || option.disabled)
return index
}
function focusOption(index: number) {
if (index < 0 || index >= filteredOptions.value.length) return
const option = filteredOptions.value[index]
if (isDivider(option) || option.disabled) return
focusedIndex.value = index
optionRefs.value[index]?.focus()
optionRefs.value[index]?.scrollIntoView({ block: 'nearest' })
}
function focusNextOption() {
const nextIndex = findNextFocusableOption(focusedIndex.value, 'next')
focusOption(nextIndex)
}
function focusPreviousOption() {
const prevIndex = findNextFocusableOption(focusedIndex.value, 'previous')
focusOption(prevIndex)
}
function handleTriggerKeydown(event: KeyboardEvent) {
switch (event.key) {
case 'Enter':
case ' ':
case 'ArrowDown':
event.preventDefault()
openDropdown()
break
case 'ArrowUp':
event.preventDefault()
openDropdown()
break
}
}
function handleDropdownKeydown(event: KeyboardEvent) {
switch (event.key) {
case 'Escape':
event.preventDefault()
closeDropdown()
break
case 'ArrowDown':
event.preventDefault()
focusNextOption()
break
case 'ArrowUp':
event.preventDefault()
focusPreviousOption()
break
case 'Enter':
case ' ':
event.preventDefault()
if (focusedIndex.value >= 0) {
const option = filteredOptions.value[focusedIndex.value]
if (!isDivider(option)) {
handleOptionClick(option, focusedIndex.value)
}
}
break
case 'Tab':
event.preventDefault()
if (event.shiftKey) {
focusPreviousOption()
} else {
focusNextOption()
}
break
}
}
function handleSearchKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.preventDefault()
closeDropdown()
} else if (event.key === 'ArrowDown') {
event.preventDefault()
focusNextOption()
} else if (event.key === 'ArrowUp') {
event.preventDefault()
focusPreviousOption()
}
}
function handleWindowResize() {
if (isOpen.value) {
updateDropdownPosition()
}
}
onClickOutside(
dropdownRef,
() => {
closeDropdown()
},
{ ignore: [triggerRef] },
)
onMounted(() => {
window.addEventListener('resize', handleWindowResize)
})
onUnmounted(() => {
window.removeEventListener('resize', handleWindowResize)
})
watch(isOpen, (value) => {
if (value) {
updateDropdownPosition()
}
})
watch(filteredOptions, () => {
if (isOpen.value) {
updateDropdownPosition()
}
})
</script>

View File

@@ -1,441 +0,0 @@
<template>
<div
ref="dropdown"
data-pyro-dropdown
tabindex="0"
role="combobox"
:aria-expanded="dropdownVisible"
class="relative inline-block h-9 w-full max-w-80"
@focus="onFocus"
@blur="onBlur"
@mousedown.prevent
@keydown="handleKeyDown"
>
<div
data-pyro-dropdown-trigger
class="duration-50 flex h-full w-full cursor-pointer select-none items-center justify-between gap-4 rounded-xl bg-button-bg px-4 py-2 shadow-sm transition-all ease-in-out"
:class="triggerClasses"
@click="toggleDropdown"
>
<span>{{ selectedOption }}</span>
<DropdownIcon
class="transition-transform duration-200 ease-in-out"
:class="{ 'rotate-180': dropdownVisible }"
/>
</div>
<Teleport to="#teleports">
<transition
enter-active-class="transition-opacity duration-200 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-200 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<div
v-if="dropdownVisible"
ref="optionsContainer"
data-pyro-dropdown-options
class="experimental-styles-within fixed z-50 bg-button-bg shadow-lg"
:class="{
'rounded-b-xl': !isRenderingUp,
'rounded-t-xl': isRenderingUp,
}"
:style="positionStyle"
@keydown.stop="handleDropdownKeyDown"
>
<div
class="overflow-y-auto"
:style="{ height: `${virtualListHeight}px` }"
data-pyro-dropdown-options-virtual-scroller
@scroll="handleScroll"
>
<div :style="{ height: `${totalHeight}px`, position: 'relative' }">
<div
v-for="item in visibleOptions"
:key="item.index"
data-pyro-dropdown-option
:style="{
position: 'absolute',
top: 0,
transform: `translateY(${item.index * ITEM_HEIGHT}px)`,
width: '100%',
height: `${ITEM_HEIGHT}px`,
}"
>
<div
:ref="(el) => handleOptionRef(el as HTMLElement, item.index)"
role="option"
:tabindex="focusedOptionIndex === item.index ? 0 : -1"
class="hover:brightness-85 flex h-full cursor-pointer select-none items-center px-4 transition-colors duration-150 ease-in-out focus:border-none focus:outline-none"
:class="{
'bg-brand font-bold text-brand-inverted': selectedValue === item.option,
'bg-bg-raised': focusedOptionIndex === item.index,
}"
:aria-selected="selectedValue === item.option"
@click="selectOption(item.option, item.index)"
@mouseover="focusedOptionIndex = item.index"
@focus="focusedOptionIndex = item.index"
>
<input
:id="`${name}-${item.index}`"
v-model="radioValue"
type="radio"
:value="item.option"
:name="name"
class="hidden"
/>
<label :for="`${name}-${item.index}`" class="w-full cursor-pointer">
{{ displayName(item.option) }}
</label>
</div>
</div>
</div>
</div>
</div>
</transition>
</Teleport>
</div>
</template>
<script setup lang="ts" generic="OptionValue extends string | number | Record<string, any>">
import { DropdownIcon } from '@modrinth/assets'
import type { CSSProperties } from 'vue'
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
const ITEM_HEIGHT = 44
const BUFFER_ITEMS = 5
interface Props {
options: OptionValue[]
name: string
defaultValue?: OptionValue | null
placeholder?: string | number | null
modelValue?: OptionValue | null
renderUp?: boolean
disabled?: boolean
displayName?: (option: OptionValue) => string
}
const props = withDefaults(defineProps<Props>(), {
defaultValue: null,
placeholder: null,
modelValue: null,
renderUp: false,
disabled: false,
displayName: (option: OptionValue) => String(option),
})
const emit = defineEmits<{
(e: 'input' | 'update:modelValue', value: OptionValue): void
(e: 'change', value: { option: OptionValue; index: number }): void
}>()
const dropdownVisible = ref(false)
const selectedValue = ref<OptionValue | null>(props.modelValue || props.defaultValue)
const focusedOptionIndex = ref<number | null>(null)
const focusedOptionRef = ref<HTMLElement | null>(null)
const dropdown = ref<HTMLElement | null>(null)
const optionsContainer = ref<HTMLElement | null>(null)
const scrollTop = ref(0)
const isRenderingUp = ref(false)
const virtualListHeight = ref(300)
const lastFocusedElement = ref<HTMLElement | null>(null)
const positionStyle = ref<CSSProperties>({
position: 'fixed',
top: '0px',
left: '0px',
width: '0px',
zIndex: 999,
})
const handleOptionRef = (el: HTMLElement | null, index: number) => {
if (focusedOptionIndex.value === index) {
focusedOptionRef.value = el
}
}
const onFocus = async () => {
if (!props.disabled) {
focusedOptionIndex.value = props.options.findIndex((option) => option === selectedValue.value)
lastFocusedElement.value = document.activeElement as HTMLElement
dropdownVisible.value = true
await updatePosition()
nextTick(() => {
dropdown.value?.focus()
})
}
}
const onBlur = (event: FocusEvent) => {
if (!isChildOfDropdown(event.relatedTarget as HTMLElement | null)) {
closeDropdown()
}
}
const isChildOfDropdown = (element: HTMLElement | null): boolean => {
let currentNode: HTMLElement | null = element
while (currentNode) {
if (currentNode === dropdown.value || currentNode === optionsContainer.value) {
return true
}
currentNode = currentNode.parentElement
}
return false
}
const totalHeight = computed(() => props.options.length * ITEM_HEIGHT)
const visibleOptions = computed(() => {
const startIndex = Math.floor(scrollTop.value / ITEM_HEIGHT) - BUFFER_ITEMS
const visibleCount = Math.ceil(virtualListHeight.value / ITEM_HEIGHT) + 2 * BUFFER_ITEMS
return Array.from({ length: visibleCount }, (_, i) => {
const index = startIndex + i
if (index >= 0 && index < props.options.length) {
return {
index,
option: props.options[index],
}
}
return null
}).filter((item): item is { index: number; option: OptionValue } => item !== null)
})
const selectedOption = computed(() => {
if (selectedValue.value !== null && selectedValue.value !== undefined) {
return props.displayName(selectedValue.value as OptionValue)
}
return props.placeholder || 'Select an option'
})
const radioValue = computed<OptionValue>({
get() {
return props.modelValue ?? selectedValue.value ?? ''
},
set(newValue: OptionValue) {
emit('update:modelValue', newValue)
selectedValue.value = newValue
},
})
const triggerClasses = computed(() => ({
'cursor-not-allowed opacity-50 grayscale': props.disabled,
'rounded-b-none': dropdownVisible.value && !isRenderingUp.value && !props.disabled,
'rounded-t-none': dropdownVisible.value && isRenderingUp.value && !props.disabled,
}))
const updatePosition = async () => {
if (!dropdown.value) return
await nextTick()
const triggerRect = dropdown.value.getBoundingClientRect()
const viewportHeight = window.innerHeight
const margin = 8
const contentHeight = props.options.length * ITEM_HEIGHT
const preferredHeight = Math.min(contentHeight, 300)
const spaceBelow = viewportHeight - triggerRect.bottom
const spaceAbove = triggerRect.top
isRenderingUp.value = spaceBelow < preferredHeight && spaceAbove > spaceBelow
virtualListHeight.value = isRenderingUp.value
? Math.min(spaceAbove - margin, preferredHeight)
: Math.min(spaceBelow - margin, preferredHeight)
positionStyle.value = {
position: 'fixed',
left: `${triggerRect.left}px`,
width: `${triggerRect.width}px`,
zIndex: 999,
...(isRenderingUp.value
? { bottom: `${viewportHeight - triggerRect.top}px`, top: 'auto' }
: { top: `${triggerRect.bottom}px`, bottom: 'auto' }),
}
}
const openDropdown = async () => {
if (!props.disabled) {
closeAllDropdowns()
dropdownVisible.value = true
focusedOptionIndex.value = props.options.findIndex((option) => option === selectedValue.value)
lastFocusedElement.value = document.activeElement as HTMLElement
await updatePosition()
requestAnimationFrame(() => {
updatePosition()
})
}
}
const toggleDropdown = () => {
if (!props.disabled) {
if (dropdownVisible.value) {
closeDropdown()
} else {
openDropdown()
}
}
}
const handleResize = () => {
if (dropdownVisible.value) {
requestAnimationFrame(() => {
updatePosition()
})
}
}
const handleScroll = (event: Event) => {
const target = event.target as HTMLElement
scrollTop.value = target.scrollTop
}
const handleKeyDown = (event: KeyboardEvent) => {
if (!dropdownVisible.value) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
lastFocusedElement.value = document.activeElement as HTMLElement
toggleDropdown()
}
} else {
handleDropdownKeyDown(event)
}
}
const handleDropdownKeyDown = (event: KeyboardEvent) => {
event.stopPropagation()
switch (event.key) {
case 'ArrowDown':
event.preventDefault()
focusNextOption()
break
case 'ArrowUp':
event.preventDefault()
focusPreviousOption()
break
case 'Enter':
event.preventDefault()
if (focusedOptionIndex.value !== null) {
selectOption(props.options[focusedOptionIndex.value], focusedOptionIndex.value)
}
break
case 'Escape':
event.preventDefault()
event.stopPropagation()
closeDropdown()
break
case 'Tab':
event.preventDefault()
if (event.shiftKey) {
focusPreviousOption()
} else {
focusNextOption()
}
break
}
}
const closeDropdown = () => {
dropdownVisible.value = false
focusedOptionIndex.value = null
if (lastFocusedElement.value) {
lastFocusedElement.value.focus()
lastFocusedElement.value = null
}
}
const closeAllDropdowns = () => {
const event = new CustomEvent('close-all-dropdowns')
window.dispatchEvent(event)
}
const selectOption = (option: OptionValue, index: number) => {
radioValue.value = option
emit('change', { option, index })
closeDropdown()
}
const focusNextOption = () => {
if (focusedOptionIndex.value === null) {
focusedOptionIndex.value = 0
} else {
focusedOptionIndex.value = (focusedOptionIndex.value + 1) % props.options.length
}
scrollToFocused()
nextTick(() => {
focusedOptionRef.value?.focus()
})
}
const focusPreviousOption = () => {
if (focusedOptionIndex.value === null) {
focusedOptionIndex.value = props.options.length - 1
} else {
focusedOptionIndex.value =
(focusedOptionIndex.value - 1 + props.options.length) % props.options.length
}
scrollToFocused()
nextTick(() => {
focusedOptionRef.value?.focus()
})
}
const scrollToFocused = () => {
if (focusedOptionIndex.value === null) return
const optionsElement = optionsContainer.value?.querySelector('.overflow-y-auto')
if (!optionsElement) return
const targetScrollTop = focusedOptionIndex.value * ITEM_HEIGHT
const scrollBottom = optionsElement.clientHeight
if (targetScrollTop < optionsElement.scrollTop) {
optionsElement.scrollTop = targetScrollTop
} else if (targetScrollTop + ITEM_HEIGHT > optionsElement.scrollTop + scrollBottom) {
optionsElement.scrollTop = targetScrollTop - scrollBottom + ITEM_HEIGHT
}
}
onMounted(() => {
window.addEventListener('resize', handleResize)
window.addEventListener('scroll', handleResize, true)
window.addEventListener('click', (event) => {
if (!isChildOfDropdown(event.target as HTMLElement)) {
closeDropdown()
}
})
window.addEventListener('close-all-dropdowns', closeDropdown)
})
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
window.removeEventListener('scroll', handleResize, true)
window.removeEventListener('click', (event) => {
if (!isChildOfDropdown(event.target as HTMLElement)) {
closeDropdown()
}
})
window.removeEventListener('close-all-dropdowns', closeDropdown)
lastFocusedElement.value = null
})
watch(
() => props.modelValue,
(newValue) => {
selectedValue.value = newValue
},
)
watch(dropdownVisible, async (newValue) => {
if (newValue) {
await updatePosition()
scrollTop.value = 0
}
})
</script>

View File

@@ -6,6 +6,7 @@ export { default as AutoBrandIcon } from './base/AutoBrandIcon.vue'
export { default as AutoLink } from './base/AutoLink.vue'
export { default as Avatar } from './base/Avatar.vue'
export { default as Badge } from './base/Badge.vue'
export { default as BulletDivider } from './base/BulletDivider.vue'
export { default as Button } from './base/Button.vue'
export { default as ButtonStyled } from './base/ButtonStyled.vue'
export { default as Card } from './base/Card.vue'
@@ -13,6 +14,7 @@ export { default as Checkbox } from './base/Checkbox.vue'
export { default as Chips } from './base/Chips.vue'
export { default as Collapsible } from './base/Collapsible.vue'
export { default as CollapsibleRegion } from './base/CollapsibleRegion.vue'
export { default as Combobox } from './base/Combobox.vue'
export { default as ContentPageHeader } from './base/ContentPageHeader.vue'
export { default as CopyCode } from './base/CopyCode.vue'
export { default as DoubleIcon } from './base/DoubleIcon.vue'
@@ -50,7 +52,6 @@ export { default as Slider } from './base/Slider.vue'
export { default as SmartClickable } from './base/SmartClickable.vue'
export { default as StatItem } from './base/StatItem.vue'
export { default as TagItem } from './base/TagItem.vue'
export { default as TeleportDropdownMenu } from './base/TeleportDropdownMenu.vue'
export { default as Timeline } from './base/Timeline.vue'
export { default as Toggle } from './base/Toggle.vue'
export { default as UnsavedChangesPopup } from './base/UnsavedChangesPopup.vue'

View File

@@ -38,9 +38,66 @@
</button>
</ButtonStyled>
</div>
<div class="overflow-y-auto p-6">
<ButtonStyled
v-if="props.mergeHeader && closable"
class="absolute top-4 right-4 z-10"
circular
>
<button v-tooltip="'Close'" aria-label="Close" @click="hide">
<XIcon aria-hidden="true" />
</button>
</ButtonStyled>
<div v-if="scrollable" class="relative">
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-24"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-24"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showTopFade"
class="pointer-events-none absolute left-0 right-0 top-0 z-10 h-24 bg-gradient-to-b from-bg-raised to-transparent"
/>
</Transition>
<div
ref="scrollContainer"
:class="[
'overflow-y-auto p-6 !pb-1 sm:pb-6',
{ 'pt-12': props.mergeHeader && closable },
]"
:style="{ maxHeight: maxContentHeight }"
@scroll="checkScrollState"
>
<slot> You just lost the game.</slot>
</div>
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-24"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-24"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="showBottomFade"
class="pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-24 bg-gradient-to-t from-bg-raised to-transparent"
/>
</Transition>
</div>
<div v-else :class="['overflow-y-auto p-6', { 'pt-12': props.mergeHeader && closable }]">
<slot> You just lost the game.</slot>
</div>
<div v-if="$slots.actions" class="p-6 pt-0">
<slot name="actions" />
</div>
</div>
</div>
</div>
@@ -51,6 +108,7 @@
import { XIcon } from '@modrinth/assets'
import { ref } from 'vue'
import { useScrollIndicator } from '../../composables/scroll-indicator'
import ButtonStyled from '../base/ButtonStyled.vue'
const props = withDefaults(
@@ -65,6 +123,9 @@ const props = withDefaults(
hideHeader?: boolean
onHide?: () => void
onShow?: () => void
mergeHeader?: boolean
scrollable?: boolean
maxContentHeight?: string
}>(),
{
type: true,
@@ -77,12 +138,19 @@ const props = withDefaults(
hideHeader: false,
onHide: () => {},
onShow: () => {},
mergeHeader: false,
// TODO: migrate all modals to use scrollable and remove this prop
scrollable: false,
maxContentHeight: '70vh',
},
)
const open = ref(false)
const visible = ref(false)
const scrollContainer = ref<HTMLElement | null>(null)
const { showTopFade, showBottomFade, checkScrollState } = useScrollIndicator(scrollContainer)
// make modal opening not shift page when there's a vertical scrollbar
function addBodyPadding() {
const scrollBarWidth = window.innerWidth - document.documentElement.clientWidth
@@ -127,6 +195,7 @@ function hide() {
defineExpose({
show,
hide,
checkScrollState,
})
const mouseX = ref(-1)

View File

@@ -0,0 +1,28 @@
function getCallerLocation(): string {
try {
const stack = new Error().stack
if (!stack) return ''
const lines = stack.split('\n')
const callerLine = lines[3]
if (!callerLine) return ''
const match = callerLine.match(/(https?:\/\/.+?|file:\/\/.+?|\/.*?):(\d+):\d+/)
if (!match) return ''
const [, fullPath, line] = match
const fileName = fullPath.split('/').pop()?.split('?')[0] || fullPath
return `${fileName}:${line}`
} catch {
return ''
}
}
export function useDebugLogger(namespace: string) {
// eslint-disable-next-line
return (...args: any[]) => {
const location = getCallerLocation()
const prefix = location ? `[${namespace}] [${location}]` : `[${namespace}]`
console.debug(prefix, ...args)
}
}

View File

@@ -1,2 +1,4 @@
export * from './debug-logger'
export * from './dynamic-font-size'
export * from './how-ago'
export * from './scroll-indicator'

View File

@@ -0,0 +1,181 @@
import { nextTick, onUnmounted, type Ref, ref, watchEffect } from 'vue'
import { useDebugLogger } from './debug-logger'
export interface ScrollIndicatorOptions {
watchContent?: boolean
debounceMs?: number
tolerance?: number
debug?: boolean
}
export interface ScrollIndicator {
showTopFade: Ref<boolean>
showBottomFade: Ref<boolean>
checkScrollState: () => void
forceCheck: () => void
}
export function useScrollIndicator(
containerRef: Ref<HTMLElement | null>,
options: ScrollIndicatorOptions = {},
): ScrollIndicator {
const { watchContent = true, debounceMs = 10, tolerance = 1, debug = false } = options
const showTopFade = ref(false)
const showBottomFade = ref(false)
let resizeObserver: ResizeObserver | null = null
let mutationObserver: MutationObserver | null = null
let rafId: number | null = null
let debounceTimer: number | null = null
const log = useDebugLogger('ScrollIndicator')
const checkScrollStateInternal = () => {
const container = containerRef.value
if (!container) {
showTopFade.value = false
showBottomFade.value = false
if (debug) log('Container not found, hiding fades')
return
}
if (rafId) {
cancelAnimationFrame(rafId)
}
rafId = requestAnimationFrame(() => {
const { scrollTop, scrollHeight, clientHeight } = container
const isScrollable = scrollHeight > clientHeight + tolerance
if (debug) {
log('Checking scroll state', {
scrollTop,
scrollHeight,
clientHeight,
isScrollable,
})
}
if (!isScrollable) {
showTopFade.value = false
showBottomFade.value = false
if (debug) log('Content fits, no fades needed')
} else {
showTopFade.value = scrollTop > tolerance
showBottomFade.value = scrollTop < scrollHeight - clientHeight - tolerance
if (debug) {
log('Fades updated', {
showTop: showTopFade.value,
showBottom: showBottomFade.value,
})
}
}
})
}
const checkScrollState = () => {
if (debounceTimer) {
clearTimeout(debounceTimer)
}
debounceTimer = window.setTimeout(() => {
checkScrollStateInternal()
}, debounceMs)
}
const forceCheck = () => {
if (debounceTimer) {
clearTimeout(debounceTimer)
debounceTimer = null
}
checkScrollStateInternal()
}
watchEffect((onCleanup) => {
const container = containerRef.value
if (!container) {
if (debug) log('No container, skipping setup')
return
}
if (debug) log('Setting up observers for container', container)
nextTick(() => {
forceCheck()
})
resizeObserver = new ResizeObserver(() => {
if (debug) log('ResizeObserver triggered')
checkScrollState()
})
resizeObserver.observe(container)
if (watchContent) {
mutationObserver = new MutationObserver(() => {
if (debug) log('MutationObserver triggered')
checkScrollState()
})
mutationObserver.observe(container, {
childList: true,
subtree: true,
characterData: true,
attributes: false,
})
}
const handleScroll = () => {
if (debug) log('Scroll event triggered')
checkScrollState()
}
container.addEventListener('scroll', handleScroll, { passive: true })
const handleResize = () => {
if (debug) log('Window resize triggered')
checkScrollState()
}
window.addEventListener('resize', handleResize, { passive: true })
onCleanup(() => {
if (debug) log('Cleaning up observers and listeners')
if (debounceTimer) {
clearTimeout(debounceTimer)
debounceTimer = null
}
if (rafId) {
cancelAnimationFrame(rafId)
rafId = null
}
resizeObserver?.disconnect()
resizeObserver = null
mutationObserver?.disconnect()
mutationObserver = null
container.removeEventListener('scroll', handleScroll)
window.removeEventListener('resize', handleResize)
})
})
onUnmounted(() => {
if (debounceTimer) {
clearTimeout(debounceTimer)
}
if (rafId) {
cancelAnimationFrame(rafId)
}
})
return {
showTopFade,
showBottomFade,
checkScrollState,
forceCheck,
}
}

View File

@@ -86,6 +86,9 @@
"button.follow": {
"defaultMessage": "Follow"
},
"button.max": {
"defaultMessage": "Max"
},
"button.more-options": {
"defaultMessage": "More options"
},
@@ -158,6 +161,84 @@
"collection.label.private": {
"defaultMessage": "Private"
},
"form.label.address-line": {
"defaultMessage": "Address line"
},
"form.label.address-line-2": {
"defaultMessage": "Address line 2 (optional)"
},
"form.label.amount": {
"defaultMessage": "Amount"
},
"form.label.bank-name": {
"defaultMessage": "Bank name"
},
"form.label.business-name": {
"defaultMessage": "Business name"
},
"form.label.city": {
"defaultMessage": "City"
},
"form.label.country": {
"defaultMessage": "Country"
},
"form.label.date-of-birth": {
"defaultMessage": "Date of birth"
},
"form.label.email": {
"defaultMessage": "Email"
},
"form.label.first-name": {
"defaultMessage": "First name"
},
"form.label.last-name": {
"defaultMessage": "Last name"
},
"form.label.postal-code": {
"defaultMessage": "Postal code/ZIP code"
},
"form.label.state-province": {
"defaultMessage": "State/province"
},
"form.placeholder.address": {
"defaultMessage": "Enter address"
},
"form.placeholder.address-2": {
"defaultMessage": "Apartment, suite, etc."
},
"form.placeholder.amount": {
"defaultMessage": "Enter amount"
},
"form.placeholder.bank-name": {
"defaultMessage": "Enter bank name"
},
"form.placeholder.bank-name-dropdown": {
"defaultMessage": "Select bank name"
},
"form.placeholder.business-name": {
"defaultMessage": "Enter business name"
},
"form.placeholder.city": {
"defaultMessage": "Enter city"
},
"form.placeholder.country": {
"defaultMessage": "Select country"
},
"form.placeholder.email": {
"defaultMessage": "Enter email address"
},
"form.placeholder.first-name": {
"defaultMessage": "Enter first name"
},
"form.placeholder.last-name": {
"defaultMessage": "Enter last name"
},
"form.placeholder.postal-code": {
"defaultMessage": "Enter postal code"
},
"form.placeholder.state": {
"defaultMessage": "Enter state/province"
},
"icon-select.edit": {
"defaultMessage": "Edit icon"
},
@@ -197,6 +278,9 @@
"instance.worlds.game_mode.unknown": {
"defaultMessage": "Unknown game mode"
},
"label.available": {
"defaultMessage": "{amount} available."
},
"label.changes-saved": {
"defaultMessage": "Changes saved"
},
@@ -245,6 +329,9 @@
"label.rejected": {
"defaultMessage": "Rejected"
},
"label.rewards-program-terms-agreement": {
"defaultMessage": "I agree to the <terms-link>Rewards Program Terms</terms-link>"
},
"label.saved": {
"defaultMessage": "Saved"
},
@@ -392,6 +479,9 @@
"omorphia.component.purchase_modal.payment_method_type.paypal": {
"defaultMessage": "PayPal"
},
"omorphia.component.purchase_modal.payment_method_type.paypal_international": {
"defaultMessage": "PayPal International"
},
"omorphia.component.purchase_modal.payment_method_type.unionpay": {
"defaultMessage": "UnionPay"
},
@@ -401,6 +491,27 @@
"omorphia.component.purchase_modal.payment_method_type.visa": {
"defaultMessage": "Visa"
},
"payment-method.charity": {
"defaultMessage": "Charity"
},
"payment-method.charity-plural": {
"defaultMessage": "Charities"
},
"payment-method.gift-card": {
"defaultMessage": "Gift card"
},
"payment-method.gift-card-plural": {
"defaultMessage": "Gift cards"
},
"payment-method.venmo": {
"defaultMessage": "Venmo"
},
"payment-method.virtual-visa": {
"defaultMessage": "Virtual Visa"
},
"payment-method.virtual-visa-plural": {
"defaultMessage": "Virtual Visas"
},
"project-type.all": {
"defaultMessage": "All"
},

View File

@@ -281,6 +281,131 @@ export const commonMessages = defineMessages({
id: 'label.visit-your-profile',
defaultMessage: 'Visit your profile',
},
maxButton: {
id: 'button.max',
defaultMessage: 'Max',
},
})
export const formFieldLabels = defineMessages({
email: {
id: 'form.label.email',
defaultMessage: 'Email',
},
firstName: {
id: 'form.label.first-name',
defaultMessage: 'First name',
},
lastName: {
id: 'form.label.last-name',
defaultMessage: 'Last name',
},
dateOfBirth: {
id: 'form.label.date-of-birth',
defaultMessage: 'Date of birth',
},
businessName: {
id: 'form.label.business-name',
defaultMessage: 'Business name',
},
addressLine: {
id: 'form.label.address-line',
defaultMessage: 'Address line',
},
addressLine2: {
id: 'form.label.address-line-2',
defaultMessage: 'Address line 2 (optional)',
},
city: {
id: 'form.label.city',
defaultMessage: 'City',
},
stateProvince: {
id: 'form.label.state-province',
defaultMessage: 'State/province',
},
postalCode: {
id: 'form.label.postal-code',
defaultMessage: 'Postal code/ZIP code',
},
country: {
id: 'form.label.country',
defaultMessage: 'Country',
},
bankName: {
id: 'form.label.bank-name',
defaultMessage: 'Bank name',
},
amount: {
id: 'form.label.amount',
defaultMessage: 'Amount',
},
})
export const formFieldPlaceholders = defineMessages({
emailPlaceholder: {
id: 'form.placeholder.email',
defaultMessage: 'Enter email address',
},
firstNamePlaceholder: {
id: 'form.placeholder.first-name',
defaultMessage: 'Enter first name',
},
lastNamePlaceholder: {
id: 'form.placeholder.last-name',
defaultMessage: 'Enter last name',
},
businessNamePlaceholder: {
id: 'form.placeholder.business-name',
defaultMessage: 'Enter business name',
},
addressPlaceholder: {
id: 'form.placeholder.address',
defaultMessage: 'Enter address',
},
address2Placeholder: {
id: 'form.placeholder.address-2',
defaultMessage: 'Apartment, suite, etc.',
},
cityPlaceholder: {
id: 'form.placeholder.city',
defaultMessage: 'Enter city',
},
statePlaceholder: {
id: 'form.placeholder.state',
defaultMessage: 'Enter state/province',
},
postalCodePlaceholder: {
id: 'form.placeholder.postal-code',
defaultMessage: 'Enter postal code',
},
countryPlaceholder: {
id: 'form.placeholder.country',
defaultMessage: 'Select country',
},
bankNamePlaceholder: {
id: 'form.placeholder.bank-name',
defaultMessage: 'Enter bank name',
},
bankNamePlaceholderDropdown: {
id: 'form.placeholder.bank-name-dropdown',
defaultMessage: 'Select bank name',
},
amountPlaceholder: {
id: 'form.placeholder.amount',
defaultMessage: 'Enter amount',
},
})
export const financialMessages = defineMessages({
available: {
id: 'label.available',
defaultMessage: '{amount} available.',
},
rewardsProgramTermsAgreement: {
id: 'label.rewards-program-terms-agreement',
defaultMessage: 'I agree to the <terms-link>Rewards Program Terms</terms-link>',
},
})
export const commonProjectTypeCategoryMessages = defineMessages({
@@ -499,6 +624,14 @@ export const paymentMethodMessages = defineMessages({
id: 'omorphia.component.purchase_modal.payment_method_type.paypal',
defaultMessage: 'PayPal',
},
paypalInternational: {
id: 'omorphia.component.purchase_modal.payment_method_type.paypal_international',
defaultMessage: 'PayPal International',
},
paypalUS: {
id: 'omorphia.component.purchase_modal.payment_method_type.paypal',
defaultMessage: 'PayPal',
},
unionpay: {
id: 'omorphia.component.purchase_modal.payment_method_type.unionpay',
defaultMessage: 'UnionPay',
@@ -511,4 +644,32 @@ export const paymentMethodMessages = defineMessages({
id: 'omorphia.component.purchase_modal.payment_method_type.visa',
defaultMessage: 'Visa',
},
venmo: {
id: 'payment-method.venmo',
defaultMessage: 'Venmo',
},
virtualVisa: {
id: 'payment-method.virtual-visa',
defaultMessage: 'Virtual Visa',
},
virtualVisaPlural: {
id: 'payment-method.virtual-visa-plural',
defaultMessage: 'Virtual Visas',
},
giftCard: {
id: 'payment-method.gift-card',
defaultMessage: 'Gift card',
},
giftCardPlural: {
id: 'payment-method.gift-card-plural',
defaultMessage: 'Gift cards',
},
charity: {
id: 'payment-method.charity',
defaultMessage: 'Charity',
},
charityPlural: {
id: 'payment-method.charity-plural',
defaultMessage: 'Charities',
},
})

View File

@@ -599,6 +599,81 @@ export interface DelphiReport {
content?: string
}
export type PayoutId = string
export type UserId = string
export type PayoutStatus =
| 'success'
| 'in-transit'
| 'cancelled'
| 'cancelling'
| 'failed'
| 'unknown'
export type PayoutMethodType = 'venmo' | 'paypal' | 'tremendous' | 'muralpay' | 'unknown'
export interface Payout {
id: PayoutId
user_id: UserId
status: PayoutStatus
created: string // ISO 8601
amount: number
fee: number | null
method: PayoutMethodType | null
method_address: string | null
platform_id: string | null
}
export type PayoutList = Payout[]
// Revenue event types for transaction history
export interface IncomeEvent {
type: 'payout_available'
created: string // ISO 8601
payout_source: string
amount: number
}
export interface WithdrawalEvent {
type: 'withdrawal'
id: string
status: PayoutStatus
created: string // ISO 8601
amount: number
fee: number | null
method_type: PayoutMethodType | null
method_address: string | null
}
export type RevenueEvent = IncomeEvent | WithdrawalEvent
export type RevenueEventList = RevenueEvent[]
export interface PayoutMethodFee {
percentage: number
min: number
max: number | null
}
export type PayoutInterval =
| {
type: 'standard'
min: number
max: number
}
| {
type: 'fixed'
values: number[]
}
export interface PayoutMethod {
id: string
type: PayoutMethodType
name: string
supported_countries: string[]
image_url: string | null
interval: PayoutInterval
fee: PayoutMethodFee
}
export type AffiliateLink = {
id: string
created_at: string