commit
6d83cb99fd
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
@ -7,6 +7,7 @@
|
||||
"Astia",
|
||||
"camelcase",
|
||||
"cloudflarestorage",
|
||||
"cmdk",
|
||||
"CredentialsSignin",
|
||||
"Eterna",
|
||||
"exif",
|
||||
|
||||
@ -29,6 +29,7 @@
|
||||
"autoprefixer": "10.4.17",
|
||||
"camelcase-keys": "^9.1.3",
|
||||
"clsx": "^2.1.0",
|
||||
"cmdk": "^0.2.1",
|
||||
"date-fns": "^3.3.1",
|
||||
"eslint": "8.56.0",
|
||||
"eslint-config-next": "14.1.0",
|
||||
@ -47,6 +48,7 @@
|
||||
"sonner": "^1.4.0",
|
||||
"tailwindcss": "3.4.1",
|
||||
"ts-exif-parser": "^0.2.2",
|
||||
"typescript": "5.3.3"
|
||||
"typescript": "5.3.3",
|
||||
"use-debounce": "^10.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
232
pnpm-lock.yaml
generated
232
pnpm-lock.yaml
generated
@ -65,6 +65,9 @@ dependencies:
|
||||
clsx:
|
||||
specifier: ^2.1.0
|
||||
version: 2.1.0
|
||||
cmdk:
|
||||
specifier: ^0.2.1
|
||||
version: 0.2.1(@types/react@18.2.55)(react-dom@18.2.0)(react@18.2.0)
|
||||
date-fns:
|
||||
specifier: ^3.3.1
|
||||
version: 3.3.1
|
||||
@ -122,6 +125,9 @@ dependencies:
|
||||
typescript:
|
||||
specifier: 5.3.3
|
||||
version: 5.3.3
|
||||
use-debounce:
|
||||
specifier: ^10.0.0
|
||||
version: 10.0.0(react@18.2.0)
|
||||
|
||||
packages:
|
||||
|
||||
@ -1684,6 +1690,12 @@ packages:
|
||||
resolution: {integrity: sha512-2LuNTFBIO0m7kKIQvvPHN6UE63VjpmL9rnEEaOOaiSPbZK+zUOYIzBAWcED+3XYzhYsd/0mD57VdxAEqqV52CQ==}
|
||||
dev: false
|
||||
|
||||
/@radix-ui/primitive@1.0.0:
|
||||
resolution: {integrity: sha512-3e7rn8FDMin4CgeL7Z/49smCA3rFYY3Ha2rUQ7HRWFadS5iCRw08ZgVT1LaNTCNqgvrUiyczLflrVrF0SRQtNA==}
|
||||
dependencies:
|
||||
'@babel/runtime': 7.23.9
|
||||
dev: false
|
||||
|
||||
/@radix-ui/primitive@1.0.1:
|
||||
resolution: {integrity: sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==}
|
||||
dependencies:
|
||||
@ -1735,6 +1747,15 @@ packages:
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-compose-refs@1.0.0(react@18.2.0):
|
||||
resolution: {integrity: sha512-0KaSv6sx787/hK3eF53iOkiSLwAGlFMx5lotrqD2pTjB18KbybKoEIgkNZTKC60YECDQTKGTRcDBILwZVqVKvA==}
|
||||
peerDependencies:
|
||||
react: ^16.8 || ^17.0 || ^18.0
|
||||
dependencies:
|
||||
'@babel/runtime': 7.23.9
|
||||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-compose-refs@1.0.1(@types/react@18.2.55)(react@18.2.0):
|
||||
resolution: {integrity: sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==}
|
||||
peerDependencies:
|
||||
@ -1749,6 +1770,15 @@ packages:
|
||||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-context@1.0.0(react@18.2.0):
|
||||
resolution: {integrity: sha512-1pVM9RfOQ+n/N5PJK33kRSKsr1glNxomxONs5c49MliinBY6Yw2Q995qfBUUo0/Mbg05B/sGA0gkgPI7kmSHBg==}
|
||||
peerDependencies:
|
||||
react: ^16.8 || ^17.0 || ^18.0
|
||||
dependencies:
|
||||
'@babel/runtime': 7.23.9
|
||||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-context@1.0.1(@types/react@18.2.55)(react@18.2.0):
|
||||
resolution: {integrity: sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==}
|
||||
peerDependencies:
|
||||
@ -1763,6 +1793,33 @@ packages:
|
||||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-dialog@1.0.0(@types/react@18.2.55)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-Yn9YU+QlHYLWwV1XfKiqnGVpWYWk6MeBVM6x/bcoyPvxgjQGoeT35482viLPctTMWoMw0PoHgqfSox7Ig+957Q==}
|
||||
peerDependencies:
|
||||
react: ^16.8 || ^17.0 || ^18.0
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0
|
||||
dependencies:
|
||||
'@babel/runtime': 7.23.9
|
||||
'@radix-ui/primitive': 1.0.0
|
||||
'@radix-ui/react-compose-refs': 1.0.0(react@18.2.0)
|
||||
'@radix-ui/react-context': 1.0.0(react@18.2.0)
|
||||
'@radix-ui/react-dismissable-layer': 1.0.0(react-dom@18.2.0)(react@18.2.0)
|
||||
'@radix-ui/react-focus-guards': 1.0.0(react@18.2.0)
|
||||
'@radix-ui/react-focus-scope': 1.0.0(react-dom@18.2.0)(react@18.2.0)
|
||||
'@radix-ui/react-id': 1.0.0(react@18.2.0)
|
||||
'@radix-ui/react-portal': 1.0.0(react-dom@18.2.0)(react@18.2.0)
|
||||
'@radix-ui/react-presence': 1.0.0(react-dom@18.2.0)(react@18.2.0)
|
||||
'@radix-ui/react-primitive': 1.0.0(react-dom@18.2.0)(react@18.2.0)
|
||||
'@radix-ui/react-slot': 1.0.0(react@18.2.0)
|
||||
'@radix-ui/react-use-controllable-state': 1.0.0(react@18.2.0)
|
||||
aria-hidden: 1.2.3
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
react-remove-scroll: 2.5.4(@types/react@18.2.55)(react@18.2.0)
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-direction@1.0.1(@types/react@18.2.55)(react@18.2.0):
|
||||
resolution: {integrity: sha512-RXcvnXgyvYvBEOhCBuddKecVkoMiI10Jcm5cTI7abJRAHYfFxeu+FBQs/DvdxSYucxR5mna0dNsL6QFlds5TMA==}
|
||||
peerDependencies:
|
||||
@ -1777,6 +1834,22 @@ packages:
|
||||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-dismissable-layer@1.0.0(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-n7kDRfx+LB1zLueRDvZ1Pd0bxdJWDUZNQ/GWoxDn2prnuJKRdxsjulejX/ePkOsLi2tTm6P24mDqlMSgQpsT6g==}
|
||||
peerDependencies:
|
||||
react: ^16.8 || ^17.0 || ^18.0
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0
|
||||
dependencies:
|
||||
'@babel/runtime': 7.23.9
|
||||
'@radix-ui/primitive': 1.0.0
|
||||
'@radix-ui/react-compose-refs': 1.0.0(react@18.2.0)
|
||||
'@radix-ui/react-primitive': 1.0.0(react-dom@18.2.0)(react@18.2.0)
|
||||
'@radix-ui/react-use-callback-ref': 1.0.0(react@18.2.0)
|
||||
'@radix-ui/react-use-escape-keydown': 1.0.0(react@18.2.0)
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-dismissable-layer@1.0.5(@types/react-dom@18.2.19)(@types/react@18.2.55)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-aJeDjQhywg9LBu2t/At58hCvr7pEm0o2Ke1x33B+MhjNmmZ17sy4KImo0KPLgsnc/zN7GPdce8Cnn0SWvwZO7g==}
|
||||
peerDependencies:
|
||||
@ -1829,6 +1902,15 @@ packages:
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-focus-guards@1.0.0(react@18.2.0):
|
||||
resolution: {integrity: sha512-UagjDk4ijOAnGu4WMUPj9ahi7/zJJqNZ9ZAiGPp7waUWJO0O1aWXi/udPphI0IUjvrhBsZJGSN66dR2dsueLWQ==}
|
||||
peerDependencies:
|
||||
react: ^16.8 || ^17.0 || ^18.0
|
||||
dependencies:
|
||||
'@babel/runtime': 7.23.9
|
||||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-focus-guards@1.0.1(@types/react@18.2.55)(react@18.2.0):
|
||||
resolution: {integrity: sha512-Rect2dWbQ8waGzhMavsIbmSVCgYxkXLxxR3ZvCX79JOglzdEy4JXMb98lq4hPxUbLr77nP0UOGf4rcMU+s1pUA==}
|
||||
peerDependencies:
|
||||
@ -1843,6 +1925,20 @@ packages:
|
||||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-focus-scope@1.0.0(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-C4SWtsULLGf/2L4oGeIHlvWQx7Rf+7cX/vKOAD2dXW0A1b5QXwi3wWeaEgW+wn+SEVrraMUk05vLU9fZZz5HbQ==}
|
||||
peerDependencies:
|
||||
react: ^16.8 || ^17.0 || ^18.0
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0
|
||||
dependencies:
|
||||
'@babel/runtime': 7.23.9
|
||||
'@radix-ui/react-compose-refs': 1.0.0(react@18.2.0)
|
||||
'@radix-ui/react-primitive': 1.0.0(react-dom@18.2.0)(react@18.2.0)
|
||||
'@radix-ui/react-use-callback-ref': 1.0.0(react@18.2.0)
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-focus-scope@1.0.4(@types/react-dom@18.2.19)(@types/react@18.2.55)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-sL04Mgvf+FmyvZeYfNu1EPAaaxD+aw7cYeIB9L9Fvq8+urhltTRaEo5ysKOpHuKPclsZcSUMKlN05x4u+CINpA==}
|
||||
peerDependencies:
|
||||
@ -1866,6 +1962,16 @@ packages:
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-id@1.0.0(react@18.2.0):
|
||||
resolution: {integrity: sha512-Q6iAB/U7Tq3NTolBBQbHTgclPmGWE3OlktGGqrClPozSw4vkQ1DfQAOtzgRPecKsMdJINE05iaoDUG8tRzCBjw==}
|
||||
peerDependencies:
|
||||
react: ^16.8 || ^17.0 || ^18.0
|
||||
dependencies:
|
||||
'@babel/runtime': 7.23.9
|
||||
'@radix-ui/react-use-layout-effect': 1.0.0(react@18.2.0)
|
||||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-id@1.0.1(@types/react@18.2.55)(react@18.2.0):
|
||||
resolution: {integrity: sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==}
|
||||
peerDependencies:
|
||||
@ -1949,6 +2055,18 @@ packages:
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-portal@1.0.0(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-a8qyFO/Xb99d8wQdu4o7qnigNjTPG123uADNecz0eX4usnQEj7o+cG4ZX4zkqq98NYekT7UoEQIjxBNWIFuqTA==}
|
||||
peerDependencies:
|
||||
react: ^16.8 || ^17.0 || ^18.0
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0
|
||||
dependencies:
|
||||
'@babel/runtime': 7.23.9
|
||||
'@radix-ui/react-primitive': 1.0.0(react-dom@18.2.0)(react@18.2.0)
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-portal@1.0.4(@types/react-dom@18.2.19)(@types/react@18.2.55)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-Qki+C/EuGUVCQTOTD5vzJzJuMUlewbzuKyUy+/iHM2uwGiru9gZeBJtHAPKAEkB5KWGi9mP/CHKcY0wt1aW45Q==}
|
||||
peerDependencies:
|
||||
@ -1970,6 +2088,19 @@ packages:
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-presence@1.0.0(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-A+6XEvN01NfVWiKu38ybawfHsBjWum42MRPnEuqPsBZ4eV7e/7K321B5VgYMPv3Xx5An6o1/l9ZuDBgmcmWK3w==}
|
||||
peerDependencies:
|
||||
react: ^16.8 || ^17.0 || ^18.0
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0
|
||||
dependencies:
|
||||
'@babel/runtime': 7.23.9
|
||||
'@radix-ui/react-compose-refs': 1.0.0(react@18.2.0)
|
||||
'@radix-ui/react-use-layout-effect': 1.0.0(react@18.2.0)
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-presence@1.0.1(@types/react-dom@18.2.19)(@types/react@18.2.55)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-UXLW4UAbIY5ZjcvzjfRFo5gxva8QirC9hF7wRE4U5gz+TP0DbRk+//qyuAQ1McDxBt1xNMBTaciFGvEmJvAZCg==}
|
||||
peerDependencies:
|
||||
@ -1992,6 +2123,18 @@ packages:
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-primitive@1.0.0(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-EyXe6mnRlHZ8b6f4ilTDrXmkLShICIuOTTj0GX4w1rp+wSxf3+TD05u1UOITC8VsJ2a9nwHvdXtOXEOl0Cw/zQ==}
|
||||
peerDependencies:
|
||||
react: ^16.8 || ^17.0 || ^18.0
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0
|
||||
dependencies:
|
||||
'@babel/runtime': 7.23.9
|
||||
'@radix-ui/react-slot': 1.0.0(react@18.2.0)
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-primitive@1.0.3(@types/react-dom@18.2.19)(@types/react@18.2.55)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==}
|
||||
peerDependencies:
|
||||
@ -2042,6 +2185,16 @@ packages:
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-slot@1.0.0(react@18.2.0):
|
||||
resolution: {integrity: sha512-3mrKauI/tWXo1Ll+gN5dHcxDPdm/Df1ufcDLCecn+pnCIVcdWE7CujXo8QaXOWRJyZyQWWbpB8eFwHzWXlv5mQ==}
|
||||
peerDependencies:
|
||||
react: ^16.8 || ^17.0 || ^18.0
|
||||
dependencies:
|
||||
'@babel/runtime': 7.23.9
|
||||
'@radix-ui/react-compose-refs': 1.0.0(react@18.2.0)
|
||||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-slot@1.0.2(@types/react@18.2.55)(react@18.2.0):
|
||||
resolution: {integrity: sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==}
|
||||
peerDependencies:
|
||||
@ -2057,6 +2210,15 @@ packages:
|
||||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-use-callback-ref@1.0.0(react@18.2.0):
|
||||
resolution: {integrity: sha512-GZtyzoHz95Rhs6S63D2t/eqvdFCm7I+yHMLVQheKM7nBD8mbZIt+ct1jz4536MDnaOGKIxynJ8eHTkVGVVkoTg==}
|
||||
peerDependencies:
|
||||
react: ^16.8 || ^17.0 || ^18.0
|
||||
dependencies:
|
||||
'@babel/runtime': 7.23.9
|
||||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-use-callback-ref@1.0.1(@types/react@18.2.55)(react@18.2.0):
|
||||
resolution: {integrity: sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==}
|
||||
peerDependencies:
|
||||
@ -2071,6 +2233,16 @@ packages:
|
||||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-use-controllable-state@1.0.0(react@18.2.0):
|
||||
resolution: {integrity: sha512-FohDoZvk3mEXh9AWAVyRTYR4Sq7/gavuofglmiXB2g1aKyboUD4YtgWxKj8O5n+Uak52gXQ4wKz5IFST4vtJHg==}
|
||||
peerDependencies:
|
||||
react: ^16.8 || ^17.0 || ^18.0
|
||||
dependencies:
|
||||
'@babel/runtime': 7.23.9
|
||||
'@radix-ui/react-use-callback-ref': 1.0.0(react@18.2.0)
|
||||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-use-controllable-state@1.0.1(@types/react@18.2.55)(react@18.2.0):
|
||||
resolution: {integrity: sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA==}
|
||||
peerDependencies:
|
||||
@ -2086,6 +2258,16 @@ packages:
|
||||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-use-escape-keydown@1.0.0(react@18.2.0):
|
||||
resolution: {integrity: sha512-JwfBCUIfhXRxKExgIqGa4CQsiMemo1Xt0W/B4ei3fpzpvPENKpMKQ8mZSB6Acj3ebrAEgi2xiQvcI1PAAodvyg==}
|
||||
peerDependencies:
|
||||
react: ^16.8 || ^17.0 || ^18.0
|
||||
dependencies:
|
||||
'@babel/runtime': 7.23.9
|
||||
'@radix-ui/react-use-callback-ref': 1.0.0(react@18.2.0)
|
||||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-use-escape-keydown@1.0.3(@types/react@18.2.55)(react@18.2.0):
|
||||
resolution: {integrity: sha512-vyL82j40hcFicA+M4Ex7hVkB9vHgSse1ZWomAqV2Je3RleKGO5iM8KMOEtfoSB0PnIelMd2lATjTGMYqN5ylTg==}
|
||||
peerDependencies:
|
||||
@ -2101,6 +2283,15 @@ packages:
|
||||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-use-layout-effect@1.0.0(react@18.2.0):
|
||||
resolution: {integrity: sha512-6Tpkq+R6LOlmQb1R5NNETLG0B4YP0wc+klfXafpUCj6JGyaUc8il7/kUZ7m59rGbXGczE9Bs+iz2qloqsZBduQ==}
|
||||
peerDependencies:
|
||||
react: ^16.8 || ^17.0 || ^18.0
|
||||
dependencies:
|
||||
'@babel/runtime': 7.23.9
|
||||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
/@radix-ui/react-use-layout-effect@1.0.1(@types/react@18.2.55)(react@18.2.0):
|
||||
resolution: {integrity: sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==}
|
||||
peerDependencies:
|
||||
@ -3654,6 +3845,19 @@ packages:
|
||||
engines: {node: '>=6'}
|
||||
dev: false
|
||||
|
||||
/cmdk@0.2.1(@types/react@18.2.55)(react-dom@18.2.0)(react@18.2.0):
|
||||
resolution: {integrity: sha512-U6//9lQ6JvT47+6OF6Gi8BvkxYQ8SCRRSKIJkthIMsFsLZRG0cKvTtuTaefyIKMQb8rvvXy0wGdpTNq/jPtm+g==}
|
||||
peerDependencies:
|
||||
react: ^18.0.0
|
||||
react-dom: ^18.0.0
|
||||
dependencies:
|
||||
'@radix-ui/react-dialog': 1.0.0(@types/react@18.2.55)(react-dom@18.2.0)(react@18.2.0)
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
transitivePeerDependencies:
|
||||
- '@types/react'
|
||||
dev: false
|
||||
|
||||
/co@4.6.0:
|
||||
resolution: {integrity: sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==}
|
||||
engines: {iojs: '>= 1.0.0', node: '>= 0.12.0'}
|
||||
@ -6563,6 +6767,25 @@ packages:
|
||||
tslib: 2.6.2
|
||||
dev: false
|
||||
|
||||
/react-remove-scroll@2.5.4(@types/react@18.2.55)(react@18.2.0):
|
||||
resolution: {integrity: sha512-xGVKJJr0SJGQVirVFAUZ2k1QLyO6m+2fy0l8Qawbp5Jgrv3DeLalrfMNBFSlmz5kriGGzsVBtGVnf4pTKIhhWA==}
|
||||
engines: {node: '>=10'}
|
||||
peerDependencies:
|
||||
'@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
dependencies:
|
||||
'@types/react': 18.2.55
|
||||
react: 18.2.0
|
||||
react-remove-scroll-bar: 2.3.4(@types/react@18.2.55)(react@18.2.0)
|
||||
react-style-singleton: 2.2.1(@types/react@18.2.55)(react@18.2.0)
|
||||
tslib: 2.6.2
|
||||
use-callback-ref: 1.3.1(@types/react@18.2.55)(react@18.2.0)
|
||||
use-sidecar: 1.1.2(@types/react@18.2.55)(react@18.2.0)
|
||||
dev: false
|
||||
|
||||
/react-remove-scroll@2.5.5(@types/react@18.2.55)(react@18.2.0):
|
||||
resolution: {integrity: sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw==}
|
||||
engines: {node: '>=10'}
|
||||
@ -7349,6 +7572,15 @@ packages:
|
||||
tslib: 2.6.2
|
||||
dev: false
|
||||
|
||||
/use-debounce@10.0.0(react@18.2.0):
|
||||
resolution: {integrity: sha512-XRjvlvCB46bah9IBXVnq/ACP2lxqXyZj0D9hj4K5OzNroMDpTEBg8Anuh1/UfRTRs7pLhQ+RiNxxwZu9+MVl1A==}
|
||||
engines: {node: '>= 16.0.0'}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
dependencies:
|
||||
react: 18.2.0
|
||||
dev: false
|
||||
|
||||
/use-sidecar@1.1.2(@types/react@18.2.55)(react@18.2.0):
|
||||
resolution: {integrity: sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
@ -72,13 +72,7 @@ export default async function AdminPhotosPage({
|
||||
<AdminGrid>
|
||||
{photos.map(photo =>
|
||||
<Fragment key={photo.id}>
|
||||
<PhotoTiny
|
||||
className={clsx(
|
||||
'rounded-sm overflow-hidden',
|
||||
'border border-gray-200 dark:border-gray-800',
|
||||
)}
|
||||
photo={photo}
|
||||
/>
|
||||
<PhotoTiny photo={photo} />
|
||||
<div className="flex flex-col lg:flex-row">
|
||||
<Link
|
||||
key={photo.id}
|
||||
|
||||
@ -13,6 +13,7 @@ import Footer from '@/site/Footer';
|
||||
import { Suspense } from 'react';
|
||||
import FooterClient from '@/site/FooterClient';
|
||||
import NavClient from '@/site/NavClient';
|
||||
import CommandK from '@/site/CommandK';
|
||||
|
||||
import '../site/globals.css';
|
||||
|
||||
@ -91,6 +92,7 @@ export default function RootLayout({
|
||||
<Footer />
|
||||
</Suspense>
|
||||
</main>
|
||||
<CommandK />
|
||||
</ThemeProviderClient>
|
||||
</StateProvider>
|
||||
<Analytics />
|
||||
|
||||
211
src/components/CommandKClient.tsx
Normal file
211
src/components/CommandKClient.tsx
Normal file
@ -0,0 +1,211 @@
|
||||
'use client';
|
||||
|
||||
import { Command } from 'cmdk';
|
||||
import { ReactNode, useEffect, useState } from 'react';
|
||||
import Modal from './Modal';
|
||||
import { clsx } from 'clsx/lite';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
import Spinner from './Spinner';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { BiDesktop, BiMoon, BiSun } from 'react-icons/bi';
|
||||
|
||||
const LISTENER_KEYDOWN = 'keydown';
|
||||
|
||||
export type CommandKSection = {
|
||||
heading: string
|
||||
accessory?: ReactNode
|
||||
items: {
|
||||
label: string
|
||||
annotation?: ReactNode
|
||||
annotationAria?: string
|
||||
accessory?: ReactNode
|
||||
path?: string
|
||||
action?: () => void
|
||||
}[]
|
||||
}
|
||||
|
||||
export default function CommandKClient({
|
||||
onQueryChange,
|
||||
sections = [],
|
||||
}: {
|
||||
onQueryChange?: (query: string) => Promise<CommandKSection[]>
|
||||
sections?: CommandKSection[]
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [queryRaw, setQueryRaw] = useState('');
|
||||
const [queryDebounced] = useDebounce(queryRaw, 500, { trailing: true });
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [queriedSections, setQueriedSections] = useState<CommandKSection[]>([]);
|
||||
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const down = (e: KeyboardEvent) => {
|
||||
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
setIsOpen((open) => !open);
|
||||
}
|
||||
};
|
||||
document.addEventListener(LISTENER_KEYDOWN, down);
|
||||
return () => document.removeEventListener(LISTENER_KEYDOWN, down);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (queryDebounced) {
|
||||
setIsLoading(true);
|
||||
onQueryChange?.(queryDebounced).then(querySections => {
|
||||
setQueriedSections(querySections);
|
||||
setIsLoading(false);
|
||||
});
|
||||
}
|
||||
}, [queryDebounced, onQueryChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (queryRaw === '') {
|
||||
setQueriedSections([]);
|
||||
} else {
|
||||
setIsLoading(true);
|
||||
}
|
||||
}, [queryRaw]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setQueryRaw('');
|
||||
setQueriedSections([]);
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const sectionTheme: CommandKSection = {
|
||||
heading: 'Theme',
|
||||
items: [{
|
||||
label: 'Use System',
|
||||
annotation: <BiDesktop />,
|
||||
action: () => setTheme('system'),
|
||||
}, {
|
||||
label: 'Light Mode',
|
||||
annotation: <BiSun size={16} className="translate-x-[1.25px]" />,
|
||||
action: () => setTheme('light'),
|
||||
}, {
|
||||
label: 'Dark Mode',
|
||||
annotation: <BiMoon className="translate-x-[1px]" />,
|
||||
action: () => setTheme('dark'),
|
||||
}],
|
||||
};
|
||||
|
||||
return (
|
||||
<Command.Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
label="Global Command Menu"
|
||||
filter={(value, search) =>
|
||||
value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0}
|
||||
loop
|
||||
>
|
||||
<Modal
|
||||
anchor='top'
|
||||
onClose={() => setIsOpen(false)}
|
||||
fast
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<div className="relative">
|
||||
<Command.Input
|
||||
onChangeCapture={(e) => setQueryRaw(e.currentTarget.value)}
|
||||
className={clsx(
|
||||
'w-full',
|
||||
'focus:ring-0',
|
||||
'!border-gray-200 dark:!border-gray-800',
|
||||
'focus:border-gray-200 focus:dark:border-gray-800',
|
||||
'placeholder:text-gray-400/80',
|
||||
'placeholder:dark:text-gray-700',
|
||||
)}
|
||||
style={{ paddingRight: '2rem' }}
|
||||
placeholder="Search photos, views, settings ..."
|
||||
/>
|
||||
{isLoading &&
|
||||
<span className="absolute top-2.5 right-3">
|
||||
<Spinner size={16} />
|
||||
</span>}
|
||||
</div>
|
||||
<Command.List className="relative max-h-72 overflow-y-scroll">
|
||||
<Command.Empty className="mt-1 pl-3 text-dim">
|
||||
{isLoading ? 'Loading ...' : 'No results found'}
|
||||
</Command.Empty>
|
||||
{queriedSections
|
||||
.concat(sections)
|
||||
.concat(sectionTheme)
|
||||
.filter(({ items }) => items.length > 0)
|
||||
.map(({ heading, accessory, items }) =>
|
||||
<Command.Group
|
||||
key={heading}
|
||||
heading={<div className={clsx(
|
||||
'flex items-center',
|
||||
'px-2',
|
||||
)}>
|
||||
{accessory &&
|
||||
<div className="w-5">{accessory}</div>}
|
||||
{heading}
|
||||
</div>}
|
||||
className={clsx(
|
||||
'uppercase',
|
||||
'select-none',
|
||||
'[&>*:first-child]:py-1',
|
||||
'[&>*:first-child]:font-medium',
|
||||
'[&>*:first-child]:text-dim',
|
||||
'[&>*:first-child]:text-xs',
|
||||
'[&>*:first-child]:tracking-wider',
|
||||
)}
|
||||
>
|
||||
{items.map(({
|
||||
accessory,
|
||||
label,
|
||||
annotation,
|
||||
annotationAria,
|
||||
path,
|
||||
action,
|
||||
}) =>
|
||||
<Command.Item
|
||||
key={`${heading}-${label}`}
|
||||
value={`${heading}-${label}`}
|
||||
className={clsx(
|
||||
'px-2',
|
||||
accessory ? 'py-2' : 'py-1',
|
||||
'rounded-md cursor-pointer tracking-wide',
|
||||
'data-[selected=true]:bg-gray-100',
|
||||
'data-[selected=true]:dark:bg-gray-900/75',
|
||||
)}
|
||||
onSelect={() => {
|
||||
setIsOpen(false);
|
||||
action?.();
|
||||
if (path) {
|
||||
router.push(path);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
{accessory}
|
||||
<span className="grow text-ellipsis truncate">
|
||||
{label}
|
||||
</span>
|
||||
{annotation &&
|
||||
<span
|
||||
className="text-dim whitespace-nowrap"
|
||||
aria-label={annotationAria}
|
||||
>
|
||||
<span aria-hidden={Boolean(annotationAria)}>
|
||||
{annotation}
|
||||
</span>
|
||||
</span>}
|
||||
</div>
|
||||
</Command.Item>)}
|
||||
</Command.Group>)}
|
||||
</Command.List>
|
||||
</div>
|
||||
</Modal>
|
||||
</Command.Dialog>
|
||||
);
|
||||
}
|
||||
@ -11,10 +11,18 @@ import usePrefersReducedMotion from '@/utility/usePrefersReducedMotion';
|
||||
|
||||
export default function Modal({
|
||||
onClosePath,
|
||||
onClose,
|
||||
className,
|
||||
anchor = 'center',
|
||||
children,
|
||||
fast,
|
||||
}: {
|
||||
onClosePath?: string
|
||||
onClose?: () => void
|
||||
className?: string
|
||||
anchor?: 'top' | 'center'
|
||||
children: ReactNode
|
||||
fast?: boolean
|
||||
}) {
|
||||
const router = useRouter();
|
||||
|
||||
@ -32,16 +40,25 @@ export default function Modal({
|
||||
|
||||
useClickInsideOutside({
|
||||
htmlElements,
|
||||
onClickOutside: () => router.push(
|
||||
onClosePath ?? PATH_ROOT,
|
||||
{ scroll: false },
|
||||
),
|
||||
onClickOutside: () => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
} else {
|
||||
router.push(
|
||||
onClosePath ?? PATH_ROOT,
|
||||
{ scroll: false },
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className={clsx(
|
||||
'fixed inset-0 z-50 flex items-center justify-center',
|
||||
'fixed inset-0 z-50 flex justify-center',
|
||||
anchor === 'top'
|
||||
? 'items-start pt-4 sm:pt-24'
|
||||
: 'items-center',
|
||||
'bg-black',
|
||||
)}
|
||||
initial={!prefersReducedMotion
|
||||
@ -51,7 +68,7 @@ export default function Modal({
|
||||
transition={{ duration: 0.3, easing: 'easeOut' }}
|
||||
>
|
||||
<AnimateItems
|
||||
duration={0.3}
|
||||
duration={fast ? 0.1 : 0.3}
|
||||
items={[<div
|
||||
key="modalContent"
|
||||
className={clsx(
|
||||
@ -59,6 +76,7 @@ export default function Modal({
|
||||
'bg-white dark:bg-black',
|
||||
'dark:border dark:border-gray-800',
|
||||
'md:p-4 md:rounded-xl',
|
||||
className,
|
||||
)}
|
||||
style={{ width: 'min(500px, 90vw)' }}
|
||||
ref={contentRef}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { ReactNode, useState } from 'react';
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||
import clsx from 'clsx';
|
||||
import { clsx } from 'clsx/lite';
|
||||
import { FiMoreHorizontal } from 'react-icons/fi';
|
||||
import Link from 'next/link';
|
||||
|
||||
|
||||
@ -23,6 +23,8 @@ export default function PhotoTiny({
|
||||
'active:brightness-75',
|
||||
selected && 'brightness-50',
|
||||
'min-w-[50px]',
|
||||
'rounded-[0.15rem] overflow-hidden',
|
||||
'border border-gray-200 dark:border-gray-800',
|
||||
)}
|
||||
>
|
||||
<ImageTiny
|
||||
|
||||
@ -24,6 +24,7 @@ import { getDimensionsFromSize } from '@/utility/size';
|
||||
import ImageBlurFallback from '@/components/ImageBlurFallback';
|
||||
import { BLUR_ENABLED } from '@/site/config';
|
||||
import { Tags, sortTagsObjectWithoutFavs } from '@/tag';
|
||||
import { formatCount, formatCountDescriptive } from '@/utility/string';
|
||||
|
||||
const THUMBNAIL_SIZE = 300;
|
||||
|
||||
@ -150,9 +151,8 @@ export default function PhotoForm({
|
||||
sortTagsObjectWithoutFavs(uniqueTags ?? [])
|
||||
.map(({ tag, count }) => ({
|
||||
value: tag,
|
||||
annotation: `× ${count}`,
|
||||
annotationAria:
|
||||
`tagged in ${count} photo${count === 1 ? '' : 's'}`,
|
||||
annotation: formatCount(count),
|
||||
annotationAria: formatCountDescriptive(count, 'tagged'),
|
||||
}))
|
||||
)
|
||||
.map(([key, {
|
||||
|
||||
@ -268,6 +268,7 @@ export type GetPhotosOptions = {
|
||||
sortBy?: 'createdAt' | 'takenAt' | 'priority'
|
||||
limit?: number
|
||||
offset?: number
|
||||
title?: string
|
||||
tag?: string
|
||||
camera?: Camera
|
||||
simulation?: FilmSimulation
|
||||
@ -309,6 +310,7 @@ export const getPhotos = async (options: GetPhotosOptions = {}) => {
|
||||
sortBy = PRIORITY_ORDER_ENABLED ? 'priority' : 'takenAt',
|
||||
limit = PHOTO_DEFAULT_LIMIT,
|
||||
offset = 0,
|
||||
title,
|
||||
tag,
|
||||
camera,
|
||||
simulation,
|
||||
@ -334,6 +336,10 @@ export const getPhotos = async (options: GetPhotosOptions = {}) => {
|
||||
wheres.push(`taken_at <= $${valueIndex++}`);
|
||||
values.push(takenAfterInclusive.toISOString());
|
||||
}
|
||||
if (title) {
|
||||
wheres.push(`LOWER(title) LIKE $${valueIndex++}`);
|
||||
values.push(`%${title.toLowerCase()}%`);
|
||||
}
|
||||
if (tag) {
|
||||
wheres.push(`$${valueIndex++}=ANY(tags)`);
|
||||
values.push(tag);
|
||||
|
||||
136
src/site/CommandK.tsx
Normal file
136
src/site/CommandK.tsx
Normal file
@ -0,0 +1,136 @@
|
||||
import CommandKClient, { CommandKSection } from '@/components/CommandKClient';
|
||||
import {
|
||||
getUniqueCamerasCached,
|
||||
getUniqueFilmSimulationsCached,
|
||||
getUniqueTagsCached,
|
||||
} from '@/photo/cache';
|
||||
import {
|
||||
PATH_ADMIN_CONFIGURATION,
|
||||
PATH_ADMIN_PHOTOS,
|
||||
PATH_ADMIN_TAGS,
|
||||
PATH_ADMIN_UPLOADS,
|
||||
pathForCamera,
|
||||
pathForFilmSimulation,
|
||||
pathForPhoto,
|
||||
pathForTag,
|
||||
} from './paths';
|
||||
import { formatCameraText } from '@/camera';
|
||||
import { authCached } from '@/auth/cache';
|
||||
import { getPhotos } from '@/services/vercel-postgres';
|
||||
import { titleForPhoto } from '@/photo';
|
||||
import PhotoTiny from '@/photo/PhotoTiny';
|
||||
import { formatDate } from '@/utility/date';
|
||||
import { formatCount, formatCountDescriptive } from '@/utility/string';
|
||||
import { BiLockAlt } from 'react-icons/bi';
|
||||
import { sortTagsObject } from '@/tag';
|
||||
import PhotoFilmSimulationIcon from '@/simulation/PhotoFilmSimulationIcon';
|
||||
import { IoMdCamera } from 'react-icons/io';
|
||||
import { FaTag } from 'react-icons/fa';
|
||||
import { TbPhoto } from 'react-icons/tb';
|
||||
|
||||
export default async function CommandK() {
|
||||
const [
|
||||
tags,
|
||||
cameras,
|
||||
filmSimulations,
|
||||
] = await Promise.all([
|
||||
getUniqueTagsCached().catch(() => []),
|
||||
getUniqueCamerasCached().catch(() => []),
|
||||
getUniqueFilmSimulationsCached().catch(() => []),
|
||||
]);
|
||||
|
||||
const session = await authCached().catch(() => null);
|
||||
|
||||
const showAdminPages = Boolean(session?.user?.email);
|
||||
|
||||
const SECTION_TAGS: CommandKSection = {
|
||||
heading: 'Tags',
|
||||
accessory: <FaTag
|
||||
size={10}
|
||||
className="translate-x-[1px] translate-y-[0.5px]"
|
||||
/>,
|
||||
items: sortTagsObject(tags).map(({ tag, count }) => ({
|
||||
label: tag,
|
||||
annotation: formatCount(count),
|
||||
annotationAria: formatCountDescriptive(count),
|
||||
path: pathForTag(tag),
|
||||
})),
|
||||
};
|
||||
|
||||
const SECTION_CAMERAS: CommandKSection = {
|
||||
heading: 'Cameras',
|
||||
accessory: <IoMdCamera />,
|
||||
items: cameras.map(({ camera, count }) => ({
|
||||
label: formatCameraText(camera),
|
||||
annotation: formatCount(count),
|
||||
annotationAria: formatCountDescriptive(count),
|
||||
path: pathForCamera(camera),
|
||||
})),
|
||||
};
|
||||
|
||||
const SECTION_FILM: CommandKSection = {
|
||||
heading: 'Film Simulations',
|
||||
accessory: <span className="w-3">
|
||||
<PhotoFilmSimulationIcon />
|
||||
</span>,
|
||||
items: filmSimulations.map(({ simulation, count }) => ({
|
||||
label: simulation,
|
||||
annotation: formatCount(count),
|
||||
annotationAria: formatCountDescriptive(count),
|
||||
path: pathForFilmSimulation(simulation),
|
||||
})),
|
||||
};
|
||||
|
||||
const SECTION_PAGES: CommandKSection = {
|
||||
heading: 'Pages',
|
||||
items: ([{
|
||||
label: 'Home',
|
||||
path: '/',
|
||||
}, {
|
||||
label: 'Grid',
|
||||
path:'/grid',
|
||||
}] as CommandKSection['items']).concat(showAdminPages ? [{
|
||||
label: 'Admin / Photos',
|
||||
annotation: <BiLockAlt />,
|
||||
path: PATH_ADMIN_PHOTOS,
|
||||
}, {
|
||||
label: 'Admin / Uploads',
|
||||
annotation: <BiLockAlt />,
|
||||
path: PATH_ADMIN_UPLOADS,
|
||||
}, {
|
||||
label: 'Admin / Tags',
|
||||
annotation: <BiLockAlt />,
|
||||
path: PATH_ADMIN_TAGS,
|
||||
}, {
|
||||
label: 'Admin / Config',
|
||||
annotation: <BiLockAlt />,
|
||||
path: PATH_ADMIN_CONFIGURATION,
|
||||
}] : []),
|
||||
};
|
||||
|
||||
return <CommandKClient
|
||||
sections={[
|
||||
SECTION_TAGS,
|
||||
SECTION_CAMERAS,
|
||||
SECTION_FILM,
|
||||
SECTION_PAGES,
|
||||
]}
|
||||
onQueryChange={async (query) => {
|
||||
'use server';
|
||||
const photos = (await getPhotos({ title: query }))
|
||||
.filter(({ title }) => Boolean(title));
|
||||
return photos.length > 0
|
||||
? [{
|
||||
heading: 'Photos',
|
||||
accessory: <TbPhoto size={14} />,
|
||||
items: photos.map(photo => ({
|
||||
accessory: <PhotoTiny photo={photo} />,
|
||||
label: titleForPhoto(photo),
|
||||
annotation: formatDate(photo.takenAt),
|
||||
path: pathForPhoto(photo),
|
||||
})),
|
||||
}]
|
||||
: [];
|
||||
}}
|
||||
/>;
|
||||
}
|
||||
@ -24,3 +24,14 @@ export const parameterize = (string: string) =>
|
||||
// Removes all non-alphanumeric characters
|
||||
.replaceAll(/([^a-z0-9-])/gi, '')
|
||||
.toLowerCase();
|
||||
|
||||
export const formatCount = (count: number) => `× ${count}`;
|
||||
|
||||
export const formatCountDescriptive = (
|
||||
count: number,
|
||||
verb = 'found',
|
||||
noun = 'photo',
|
||||
singular = '',
|
||||
plural = 's',
|
||||
) =>
|
||||
`${verb} in ${count} ${noun}${count === 1 ? singular : plural}`;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user