forked from Mirrors/elk
Compare commits
581 commits
gh-readonl
...
main
Author | SHA1 | Date | |
---|---|---|---|
b217b4dc25 | |||
f2edec194f | |||
087f8cc992 | |||
6efbf8768b | |||
cd934fcd55 | |||
7f1e97a37c | |||
58c84b2f9b | |||
322ffeef12 | |||
4a8110a52e | |||
d481c0ae7a | |||
68ffb627e7 | |||
b221687139 | |||
7b10620cca | |||
498face3cf | |||
672ff46072 | |||
81b5f8b8e7 | |||
721e55be06 | |||
eba4cc0757 | |||
80818ed52d | |||
|
25fb7c1c97 | ||
|
839aa52e86 | ||
|
9ff55289ea | ||
|
73293fbcd3 | ||
|
a27c218802 | ||
|
f8fc0efadc | ||
|
618a5b2df3 | ||
|
1146dca5f6 | ||
|
f86e856ee6 | ||
|
6d13d61227 | ||
|
0de9825bf2 | ||
|
3f0b234cc4 | ||
|
8f04ea8eee | ||
|
7dcafa3fe0 | ||
|
bead2183b2 | ||
|
59dda09cd4 | ||
|
d0b115751f | ||
|
c6787aae3f | ||
|
9025416ab3 | ||
|
aa28257754 | ||
|
d807e06fa0 | ||
|
611d556936 | ||
|
4313002950 | ||
|
de11a60b17 | ||
|
5064b269e7 | ||
|
d8d9975756 | ||
|
eee671cdc3 | ||
|
587c063aba | ||
|
28514e956d | ||
|
42aeb8fa35 | ||
|
f6f50a582e | ||
|
f86818867b | ||
|
82d962a54b | ||
|
1b189043e4 | ||
|
a4867566d9 | ||
|
0757db69b2 | ||
|
f0de25c992 | ||
|
660549b08b | ||
|
7807730118 | ||
|
b526db0860 | ||
|
0133324ded | ||
|
e9ab0cd40b | ||
|
9251ec496b | ||
|
bd4cd02b2b | ||
|
74ccfece5d | ||
35b4c80311 | |||
f6a081d199 | |||
5e419045ed | |||
cfdbb66b96 | |||
c717aace86 | |||
6713329a5e | |||
7470468077 | |||
9044b3bcad | |||
5bd9dce91e | |||
12e14cfc05 | |||
1ea4399765 | |||
991cb812da | |||
|
c89e499f96 | ||
|
89e3582dd7 | ||
|
48c013709a | ||
|
f90f0a2e61 | ||
|
c58b585855 | ||
|
ded2e0f3d7 | ||
9b77efe1d7 | |||
aec670a903 | |||
1218f92133 | |||
7c0986a005 | |||
ac01207283 | |||
ef169d682f | |||
|
21d5633233 | ||
|
7703565c75 | ||
|
5a9546ec0a | ||
|
bc30a8bd82 | ||
|
c432c2bd0d | ||
|
364fbd350b | ||
|
c64580f782 | ||
|
e7dfdafd59 | ||
|
b06ec9356d | ||
|
3b1a66c93c | ||
|
ed8a1811cc | ||
|
dfbe2e080d | ||
|
0fd9374e8c | ||
|
1c8e48bee4 | ||
|
3448335356 | ||
|
4954473f50 | ||
|
efa17caf5e | ||
|
df165f0023 | ||
|
0f583ece28 | ||
|
d579977790 | ||
|
8786c83db7 | ||
|
1ce913e69d | ||
|
48a8b74e7c | ||
|
1ff13952b0 | ||
|
02f7c4b291 | ||
|
9da77637b2 | ||
|
62f70250d5 | ||
|
873c62e9ef | ||
|
b1ff1e6277 | ||
|
f644148844 | ||
|
3120bbb77f | ||
|
6cbe65c9d8 | ||
|
1c908363cb | ||
|
c01a15c930 | ||
|
0c15aa55d8 | ||
|
9f04e17e57 | ||
|
308b50cbad | ||
|
e44833b18a | ||
|
0fa87f71a4 | ||
|
edfbe2c3ed | ||
|
70c7e93919 | ||
|
95e466146d | ||
|
efec212a9f | ||
|
1844af0a41 | ||
|
72b80d4984 | ||
|
6dc5a68c80 | ||
|
310b32c123 | ||
|
748dd5e19f | ||
|
c00d6f7bf8 | ||
|
fc5d248094 | ||
|
6f20ce5bba | ||
|
edcc8741bf | ||
|
3584151fab | ||
|
efb6967e6a | ||
|
eddbb1eee9 | ||
|
6b40319723 | ||
|
913e2892f7 | ||
|
a3c5272e07 | ||
|
55037f04cd | ||
|
1fefb6e5b6 | ||
|
3769176eaa | ||
|
082650d458 | ||
|
36004a7eba | ||
|
81ef8ff9aa | ||
|
da163903b1 | ||
|
ccfa7a8d10 | ||
|
b9394c2fa5 | ||
|
1954c34628 | ||
|
9f005a0a59 | ||
|
bf0c562794 | ||
|
54fe0c1ab9 | ||
|
1bbc2eca24 | ||
|
dcc1b74824 | ||
|
8eb6b2378a | ||
|
40415f34a4 | ||
|
be4752ee0c | ||
|
30e2295af4 | ||
|
285f83e2fa | ||
|
8db37617d4 | ||
|
172883a499 | ||
|
2a59543836 | ||
|
77b917a921 | ||
|
af8a6e6809 | ||
|
6d8b33a58a | ||
|
7322711609 | ||
|
b8e8693342 | ||
|
f0bc78ba2c | ||
|
cadf1b4a7c | ||
|
f79d84ad6e | ||
|
b0125eb3fc | ||
|
77175416a6 | ||
|
7836edd10a | ||
|
0ae189207f | ||
|
56d4967eb7 | ||
|
0451ac98c9 | ||
|
54e53889e5 | ||
|
149963c304 | ||
|
44f5ec1fa2 | ||
|
6c5bb83ac3 | ||
|
d8ea685803 | ||
|
3fa1fc349c | ||
|
3adf92ea56 | ||
|
b016320eaf | ||
|
77588c1890 | ||
|
e43993770d | ||
|
9070fa4053 | ||
|
7f041c3ac8 | ||
|
b7c22287d6 | ||
|
07042b9f31 | ||
|
c0bb6e293c | ||
|
74138a9a58 | ||
|
e63473a5f8 | ||
|
24378e0be8 | ||
|
5ce005b55a | ||
|
3ae2d50bff | ||
|
2b421f1039 | ||
|
e0ddbc1da2 | ||
|
ca3a818678 | ||
|
9155c32ece | ||
|
3dbdb99118 | ||
|
c3d96d2811 | ||
|
429d1d7ce8 | ||
|
5503ecbea2 | ||
|
21376e013a | ||
|
17f6d93c7c | ||
|
0e701afb98 | ||
|
cdcc89518a | ||
|
1f6a7186f8 | ||
|
ad1461bd2d | ||
|
7ba9b05d12 | ||
|
9c39eed209 | ||
|
7ed95e317f | ||
|
46105c86c6 | ||
|
7785f4fe06 | ||
|
585d8c6f0b | ||
|
1f752e65ed | ||
|
7595162a0e | ||
|
20c30e92a3 | ||
|
e00e4074e1 | ||
|
7ec76ffed9 | ||
|
c41b427c2e | ||
|
c55545612e | ||
|
dab0502319 | ||
|
10bd555926 | ||
|
53dc1f37ca | ||
|
68f92e07b7 | ||
|
957f0d3b17 | ||
|
0bd1209bee | ||
|
00c4a369cc | ||
|
8a5ddb7c87 | ||
|
90878f97b5 | ||
|
09189378e0 | ||
|
769968c2e8 | ||
|
5d09e7d2ab | ||
|
0924c9d9be | ||
|
293534fb8b | ||
|
5fceb70971 | ||
|
d825a71d1f | ||
|
a47071d341 | ||
|
52c947f9e5 | ||
|
07b7f38386 | ||
|
291f99cbea | ||
|
1fe598f554 | ||
|
e9f274f304 | ||
|
ca0afe585d | ||
|
1a0e83365a | ||
|
77a3bd833d | ||
|
02abe2d920 | ||
|
c8d9c4b871 | ||
|
fee811dd75 | ||
|
1910a1d782 | ||
|
c387702bb1 | ||
|
e015adcf4e | ||
|
12eaae6bbb | ||
|
e199e02e79 | ||
|
b42e77af24 | ||
|
0343898146 | ||
|
b5f2cea1dc | ||
|
2a9f607049 | ||
|
043883bd8e | ||
|
ed5592260f | ||
|
f4b0be8aed | ||
|
b723d51786 | ||
|
25d4325bd0 | ||
|
319f9c4ece | ||
|
06ef226440 | ||
|
3691ec389a | ||
|
ca2ca2bef4 | ||
|
907d9999dc | ||
|
e9c5de577e | ||
|
87d6ed39eb | ||
|
cf20ac29db | ||
|
1a96f87da0 | ||
|
0f825a6efb | ||
|
1b8d72105d | ||
|
5ef2996b40 | ||
|
1ac3164d0c | ||
|
e4725d433e | ||
|
3716e3af6e | ||
|
366f3e07df | ||
|
6801ea6c2d | ||
|
4b37d19f65 | ||
|
c1bca79c50 | ||
|
ee88c111f2 | ||
|
067550720a | ||
|
c5b1b32f2c | ||
|
bd7436e5d2 | ||
|
8b883dc957 | ||
|
06808da616 | ||
|
0b900128c1 | ||
|
b74eec6a03 | ||
|
92d0f18389 | ||
|
c0f936f8fe | ||
|
7e492422fe | ||
|
4325bca22b | ||
|
5a765187ab | ||
|
e9a02ca337 | ||
|
221a6f2fc3 | ||
|
2267db11c6 | ||
|
a4d34323ed | ||
|
91db9b03a8 | ||
|
0be47261c7 | ||
|
49b39b7fa8 | ||
|
1f37e3ab8b | ||
|
e183c62036 | ||
|
370c8dd58e | ||
|
b19f73c870 | ||
|
dccdcbbbe2 | ||
|
280911b233 | ||
|
f3d17d3be2 | ||
|
3f6cc16850 | ||
|
4ebc8b6798 | ||
|
7079564ffe | ||
|
991034115b | ||
|
57814915d6 | ||
|
8181738d48 | ||
|
0a8cc317a9 | ||
|
8df73b13bd | ||
|
d975c6fc2c | ||
|
67d5d5c00a | ||
|
603e10b6ca | ||
|
9ae0d9b744 | ||
|
28f9540113 | ||
|
468a17ad58 | ||
|
b84a6ccc32 | ||
|
a45b7173e5 | ||
|
d23f1d39eb | ||
|
e6172ad38b | ||
|
5870e8d6e6 | ||
|
3d696646c5 | ||
|
6edb6ccb15 | ||
|
6cb7fca3ab | ||
|
3375563e64 | ||
|
832ee35a93 | ||
|
550540fad0 | ||
|
e59c2af818 | ||
|
ee6ee30df1 | ||
|
675f5184a0 | ||
|
0a9f2d99d5 | ||
|
35dcf91a06 | ||
|
7876727a41 | ||
|
3c3fad808d | ||
|
357fac4d49 | ||
|
f45f51d44b | ||
|
5bbbf14c92 | ||
|
0a933614fa | ||
|
22a1388d50 | ||
|
0719ad0afd | ||
|
1671dfb617 | ||
|
b730fab643 | ||
|
81e1383da5 | ||
|
cdc43775a6 | ||
|
2a57c64fa0 | ||
|
8a86282951 | ||
|
da31709677 | ||
|
b14a8e63c6 | ||
|
cc89692d80 | ||
|
5782c326b2 | ||
|
af444391b5 | ||
|
9bc44f44a0 | ||
|
f7f4167b06 | ||
|
8fa9c40e86 | ||
|
31a4924186 | ||
|
a6b9d4a82b | ||
|
ca897bdd2f | ||
|
fa44850686 | ||
|
a6e4da8c41 | ||
|
895c1ecd8d | ||
|
ae35f9d11d | ||
|
2506c02c39 | ||
|
a08d9d147c | ||
|
cb109b49b8 | ||
|
d51303cb8b | ||
|
c00354c833 | ||
|
1ee0ec68c5 | ||
|
5c1411b3de | ||
|
2d8ec4ab89 | ||
|
c7e20296a2 | ||
|
a98ca69382 | ||
|
886fc89df6 | ||
|
112502155e | ||
|
be446f5433 | ||
|
a9f5e4b5e7 | ||
|
78b8b441ba | ||
|
d52755a153 | ||
|
d5856b83c6 | ||
|
338e203b6f | ||
|
026ef988c4 | ||
|
5f2dca1979 | ||
|
676470bae2 | ||
|
e0525e5f55 | ||
|
d4ce90a7e8 | ||
|
171f0ec857 | ||
|
fbf49368c1 | ||
|
3f8d68c7f7 | ||
|
0c6260367e | ||
|
0b5797249f | ||
|
e453a316f7 | ||
|
25a5d3fe7b | ||
|
34aca66fef | ||
|
5ea09d323f | ||
|
4541486d0d | ||
|
a94fe1c9d0 | ||
|
58f3ff6cd6 | ||
|
fbc779d174 | ||
|
7c1873c4e3 | ||
|
123cf13145 | ||
|
58053d0b53 | ||
|
68d0d55532 | ||
|
b6304ab18c | ||
|
e996e53a64 | ||
|
f15150c40d | ||
|
01486c2aef | ||
|
5ad32c9e26 | ||
|
2b1a5072d7 | ||
|
c6c4d52556 | ||
|
d601a117c0 | ||
|
0767df3f78 | ||
|
670a4ef632 | ||
|
0633c09726 | ||
|
d535ae6ee1 | ||
|
209013af09 | ||
|
dfa0e5f300 | ||
|
897968027c | ||
|
ad0725e9ae | ||
|
4a167c5bf5 | ||
|
4c0c6f1325 | ||
|
dff36d5c43 | ||
|
a50f97e5f3 | ||
|
9c9a1f7c35 | ||
|
e251a8a50b | ||
|
dfb5a665f0 | ||
|
22556984fa | ||
|
1fda33848e | ||
|
d9add9f670 | ||
|
49ee431676 | ||
|
0092c8cbe9 | ||
|
d0a4c51ef5 | ||
|
52b2d12bf9 | ||
|
5e5fb0e287 | ||
|
886488a3c9 | ||
|
69f9004917 | ||
|
f635e0a634 | ||
|
29f6a73de1 | ||
|
f28c90498b | ||
|
66484bac80 | ||
|
99077da1bf | ||
|
fc97e8ff5b | ||
|
9d3c7ef116 | ||
|
e9740fe693 | ||
|
1fbd88c826 | ||
|
d3cdadd444 | ||
|
582a9847a1 | ||
|
126cd4d535 | ||
|
c9265028d2 | ||
|
77717c960c | ||
|
454ad18f1b | ||
|
4be5d81f17 | ||
|
5ffb96baf6 | ||
|
1487932c1d | ||
|
d9e7a09d24 | ||
|
a3116e703a | ||
|
8dd29039cd | ||
|
0034b22da4 | ||
|
eebe57840b | ||
|
d59cdb0aa4 | ||
|
16561845f8 | ||
|
e9de11000b | ||
|
c6c844f3fd | ||
|
fb61891c29 | ||
|
94323c8fe1 | ||
|
f1f5a96929 | ||
|
df0c30c2f2 | ||
|
68f2c3fc5b | ||
|
d0ede35e89 | ||
|
c54aed62fb | ||
|
5adc5eecf1 | ||
|
4c1e37caa2 | ||
|
847de1b39b | ||
|
0a98c5f13d | ||
|
835269fa1d | ||
|
61526df93f | ||
|
076c47b7b0 | ||
|
98777f078c | ||
|
dcf0f93eb9 | ||
|
656b789e7c | ||
|
70cb620ccd | ||
|
3c3324f070 | ||
|
f3e1b6db67 | ||
|
d4e0d5c5f5 | ||
|
ccf115ca4c | ||
|
c7b77216c1 | ||
|
8b8de1182c | ||
|
99dc8a0479 | ||
|
28a68f47eb | ||
|
9cc4c23e50 | ||
|
2b42f225dd | ||
|
320ed81555 | ||
|
5bd17d3006 | ||
|
5dc136372b | ||
|
5c3b8be055 | ||
|
cbba846c4f | ||
|
574d72af61 | ||
|
a0d036952d | ||
|
23c1dfec10 | ||
|
1ceb3e2857 | ||
|
3f1cdbbfa9 | ||
|
59e418e2e0 | ||
|
ac4188274c | ||
|
450908ecb2 | ||
|
85260e8aaa | ||
|
b2c1a4ddef | ||
|
85ac040c2f | ||
|
94d22fd488 | ||
|
5b1ad44875 | ||
|
21f57f1cfa | ||
|
6727e63626 | ||
|
54e2afa56b | ||
|
56405f52bb | ||
|
9564985a4e | ||
|
aa77919925 | ||
|
1eb47b98f5 | ||
|
acb2b80cdd | ||
|
dbbbe8aa01 | ||
|
bda18e7ac5 | ||
|
ce149e4cb4 | ||
|
6f19d54586 | ||
|
6fc6517811 | ||
|
58a7f15216 | ||
|
ea44f8bc30 | ||
|
d34a5e6e96 | ||
|
1f559fae08 | ||
|
ca8d785d9e | ||
|
18ea4ffb6e | ||
|
f07d32375a | ||
|
c71259334c | ||
|
13581323b0 | ||
|
587f73c4a0 | ||
|
2267556b8b | ||
|
dac044e6ad | ||
|
3442dfe75d | ||
|
ce5e81e160 | ||
|
621d280a96 | ||
|
fb1ca7d8f1 | ||
|
189d358b2a | ||
|
3acf87d5b6 | ||
|
d33ac87c64 | ||
|
a037583631 | ||
|
d6e199b83a | ||
|
79538a65ee | ||
|
2dc7ad27bf | ||
|
921eaae949 | ||
|
2f79f53877 | ||
|
973805f16d | ||
|
685b16d403 | ||
|
ab2881b9a2 | ||
|
60a37e0bf8 | ||
|
da94117f61 | ||
|
605359b9df | ||
|
c513907dbb | ||
|
4cc0101a06 | ||
|
f4f6208420 | ||
|
3c43a1cdd1 | ||
|
2838e18ff7 | ||
|
da2ac06d8a | ||
|
881a49e0d9 | ||
|
e062fb5e52 | ||
|
a1026d3aab | ||
|
42dc99929e | ||
|
0916b05afd | ||
|
331d652ef0 | ||
|
e9ddf3e6a0 | ||
|
5dd3a52865 |
399 changed files with 26287 additions and 12813 deletions
|
@ -11,7 +11,6 @@ dist
|
||||||
.netlify/
|
.netlify/
|
||||||
.eslintcache
|
.eslintcache
|
||||||
|
|
||||||
public/shiki
|
|
||||||
public/emojis
|
public/emojis
|
||||||
|
|
||||||
*~
|
*~
|
||||||
|
|
|
@ -8,7 +8,7 @@ NUXT_CLOUDFLARE_ACCOUNT_ID=
|
||||||
NUXT_CLOUDFLARE_NAMESPACE_ID=
|
NUXT_CLOUDFLARE_NAMESPACE_ID=
|
||||||
NUXT_CLOUDFLARE_API_TOKEN=
|
NUXT_CLOUDFLARE_API_TOKEN=
|
||||||
|
|
||||||
# 'cloudflare' | 'fs'
|
# 'cloudflare' | 'vercel' | 'fs'
|
||||||
NUXT_STORAGE_DRIVER=
|
NUXT_STORAGE_DRIVER=
|
||||||
NUXT_STORAGE_FS_BASE=
|
NUXT_STORAGE_FS_BASE=
|
||||||
|
|
||||||
|
|
|
@ -1,13 +0,0 @@
|
||||||
*.css
|
|
||||||
*.png
|
|
||||||
*.ico
|
|
||||||
*.toml
|
|
||||||
*.patch
|
|
||||||
*.txt
|
|
||||||
Dockerfile
|
|
||||||
public/
|
|
||||||
https-dev-config/localhost.crt
|
|
||||||
https-dev-config/localhost.key
|
|
||||||
Dockerfile
|
|
||||||
elk-translation-status.json
|
|
||||||
docs/translation-status.json
|
|
18
.eslintrc
18
.eslintrc
|
@ -1,18 +0,0 @@
|
||||||
{
|
|
||||||
"extends": "@antfu",
|
|
||||||
"ignorePatterns": ["!pages/public"],
|
|
||||||
"overrides": [
|
|
||||||
{
|
|
||||||
"files": ["locales/**.json"],
|
|
||||||
"rules": {
|
|
||||||
"jsonc/sort-keys": "error"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"rules": {
|
|
||||||
"vue/no-restricted-syntax":["error", {
|
|
||||||
"selector": "VElement[name='a']",
|
|
||||||
"message": "Use NuxtLink instead."
|
|
||||||
}]
|
|
||||||
}
|
|
||||||
}
|
|
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
* text=auto eol=lf
|
13
.github/renovate.json5
vendored
13
.github/renovate.json5
vendored
|
@ -3,7 +3,14 @@
|
||||||
"extends": ["config:base", "schedule:weekly", "group:allNonMajor"],
|
"extends": ["config:base", "schedule:weekly", "group:allNonMajor"],
|
||||||
"labels": ["c: dependencies"],
|
"labels": ["c: dependencies"],
|
||||||
"rangeStrategy": "bump",
|
"rangeStrategy": "bump",
|
||||||
"node": false,
|
"ignoreDeps": [
|
||||||
|
"vue",
|
||||||
|
"vue-tsc",
|
||||||
|
"typescript",
|
||||||
|
|
||||||
|
// Intl.Segmenter is not supported in Firefox
|
||||||
|
"string-length"
|
||||||
|
],
|
||||||
"packageRules": [
|
"packageRules": [
|
||||||
{
|
{
|
||||||
"groupName": "devDependencies",
|
"groupName": "devDependencies",
|
||||||
|
@ -57,6 +64,10 @@
|
||||||
{
|
{
|
||||||
"groupName": "typescript",
|
"groupName": "typescript",
|
||||||
"matchPackageNames": ["typescript"]
|
"matchPackageNames": ["typescript"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matchDatasources": ["node-version"],
|
||||||
|
"enabled": false
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"vulnerabilityAlerts": {
|
"vulnerabilityAlerts": {
|
||||||
|
|
10
.github/workflows/ci.yml
vendored
10
.github/workflows/ci.yml
vendored
|
@ -10,17 +10,18 @@ on:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
workflow_dispatch: {}
|
workflow_dispatch: {}
|
||||||
|
merge_group: {}
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
ci:
|
ci:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
- run: corepack enable
|
- run: corepack enable
|
||||||
- uses: actions/setup-node@v3
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 18
|
node-version: 20
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
||||||
- name: 📦 Install dependencies
|
- name: 📦 Install dependencies
|
||||||
|
@ -30,7 +31,8 @@ jobs:
|
||||||
run: pnpm nuxi prepare
|
run: pnpm nuxi prepare
|
||||||
|
|
||||||
- name: 🧪 Test project
|
- name: 🧪 Test project
|
||||||
run: pnpm test tests/unit
|
run: pnpm test:ci
|
||||||
|
timeout-minutes: 10
|
||||||
|
|
||||||
- name: 📝 Lint
|
- name: 📝 Lint
|
||||||
run: pnpm lint
|
run: pnpm lint
|
||||||
|
|
20
.github/workflows/docker.yml
vendored
20
.github/workflows/docker.yml
vendored
|
@ -16,29 +16,29 @@ jobs:
|
||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
- name: Docker meta
|
- name: Docker meta
|
||||||
id: metal
|
id: metal
|
||||||
uses: docker/metadata-action@v4
|
uses: docker/metadata-action@v5
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
ghcr.io/elk-zone/elk
|
ghcr.io/${{ github.repository }}
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v2
|
uses: docker/setup-qemu-action@v3
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v2
|
uses: docker/setup-buildx-action@v3
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ github.token }}
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v3
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64,linux/arm64
|
||||||
push: ${{ github.event_name != 'pull_request' }}
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.metal.outputs.tags }}
|
tags: ${{ steps.metal.outputs.tags }}
|
||||||
labels: ${{ steps.metal.outputs.labels }}
|
labels: ${{ steps.metal.outputs.labels }}
|
||||||
|
|
4
.github/workflows/release.yml
vendored
4
.github/workflows/release.yml
vendored
|
@ -12,12 +12,12 @@ jobs:
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set node
|
- name: Set node
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 18
|
node-version: 18
|
||||||
|
|
||||||
|
|
2
.github/workflows/semantic-pull-request.yml
vendored
2
.github/workflows/semantic-pull-request.yml
vendored
|
@ -19,6 +19,6 @@ jobs:
|
||||||
name: Semantic Pull Request
|
name: Semantic Pull Request
|
||||||
steps:
|
steps:
|
||||||
- name: Validate PR title
|
- name: Validate PR title
|
||||||
uses: amannn/action-semantic-pull-request@v5.2.0
|
uses: amannn/action-semantic-pull-request@v5.4.0
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -2,6 +2,7 @@ node_modules
|
||||||
*.log
|
*.log
|
||||||
dist
|
dist
|
||||||
.output
|
.output
|
||||||
|
.pnpm-store
|
||||||
.nuxt
|
.nuxt
|
||||||
.env
|
.env
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
@ -11,7 +12,6 @@ dist
|
||||||
.eslintcache
|
.eslintcache
|
||||||
elk-translation-status.json
|
elk-translation-status.json
|
||||||
|
|
||||||
public/shiki
|
|
||||||
public/emojis
|
public/emojis
|
||||||
|
|
||||||
*~
|
*~
|
||||||
|
|
1
.npmrc
1
.npmrc
|
@ -1,4 +1,3 @@
|
||||||
shamefully-hoist=true
|
shamefully-hoist=true
|
||||||
strict-peer-dependencies=false
|
|
||||||
shell-emulator=true
|
shell-emulator=true
|
||||||
ignore-workspace-root-check=true
|
ignore-workspace-root-check=true
|
||||||
|
|
2
.nvmrc
2
.nvmrc
|
@ -1 +1 @@
|
||||||
v18
|
20
|
45
.vscode/settings.json
vendored
45
.vscode/settings.json
vendored
|
@ -5,10 +5,6 @@
|
||||||
"unmute",
|
"unmute",
|
||||||
"unstorage"
|
"unstorage"
|
||||||
],
|
],
|
||||||
"editor.codeActionsOnSave": {
|
|
||||||
"source.fixAll.eslint": true
|
|
||||||
},
|
|
||||||
"editor.formatOnSave": false,
|
|
||||||
"files.associations": {
|
"files.associations": {
|
||||||
"*.css": "postcss"
|
"*.css": "postcss"
|
||||||
},
|
},
|
||||||
|
@ -23,7 +19,44 @@
|
||||||
"i18n-ally.preferredDelimiter": "_",
|
"i18n-ally.preferredDelimiter": "_",
|
||||||
"i18n-ally.sortKeys": true,
|
"i18n-ally.sortKeys": true,
|
||||||
"i18n-ally.sourceLanguage": "en",
|
"i18n-ally.sourceLanguage": "en",
|
||||||
|
|
||||||
|
// Enable the ESlint flat config support
|
||||||
|
"eslint.experimental.useFlatConfig": true,
|
||||||
|
|
||||||
|
// Disable the default formatter, use eslint instead
|
||||||
"prettier.enable": false,
|
"prettier.enable": false,
|
||||||
"volar.completion.preferredTagNameCase": "pascal",
|
"editor.formatOnSave": false,
|
||||||
"volar.completion.preferredAttrNameCase": "kebab"
|
|
||||||
|
// Auto fix
|
||||||
|
"editor.codeActionsOnSave": {
|
||||||
|
"source.fixAll": "explicit",
|
||||||
|
"source.organizeImports": "never"
|
||||||
|
},
|
||||||
|
|
||||||
|
// Silent the stylistic rules in you IDE, but still auto fix them
|
||||||
|
"eslint.rules.customizations": [
|
||||||
|
{ "rule": "style/*", "severity": "off" },
|
||||||
|
{ "rule": "*-indent", "severity": "off" },
|
||||||
|
{ "rule": "*-spacing", "severity": "off" },
|
||||||
|
{ "rule": "*-spaces", "severity": "off" },
|
||||||
|
{ "rule": "*-order", "severity": "off" },
|
||||||
|
{ "rule": "*-dangle", "severity": "off" },
|
||||||
|
{ "rule": "*-newline", "severity": "off" },
|
||||||
|
{ "rule": "*quotes", "severity": "off" },
|
||||||
|
{ "rule": "*semi", "severity": "off" }
|
||||||
|
],
|
||||||
|
|
||||||
|
// Enable eslint for all supported languages
|
||||||
|
"eslint.validate": [
|
||||||
|
"javascript",
|
||||||
|
"javascriptreact",
|
||||||
|
"typescript",
|
||||||
|
"typescriptreact",
|
||||||
|
"vue",
|
||||||
|
"html",
|
||||||
|
"markdown",
|
||||||
|
"json",
|
||||||
|
"jsonc",
|
||||||
|
"yaml"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
22
.woodpecker.yml
Normal file
22
.woodpecker.yml
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
steps:
|
||||||
|
- name: build docker
|
||||||
|
image: docker:25-cli
|
||||||
|
secrets: [user, pass]
|
||||||
|
commands:
|
||||||
|
- apk add git
|
||||||
|
- REPO=$(echo "$CI_REPO" | tr '[:upper:]' '[:lower:]')
|
||||||
|
- REGISTRY="dev.cat-enby.club"
|
||||||
|
- MAJOR=$(echo ${CI_COMMIT_TAG} | cut -d '.' -f 1 | tr -d 'v')
|
||||||
|
- MINOR=$(echo ${CI_COMMIT_TAG} | cut -d '.' -f 2)
|
||||||
|
- PATCH=$(echo ${CI_COMMIT_TAG} | cut -d '.' -f 3 | cut -d '-' -f 1)
|
||||||
|
- docker buildx build -t $${REGISTRY}/$${REPO}:v$${MAJOR:-0}.$${MINOR:-0}.$${PATCH-0} -t $${REGISTRY}/$${REPO}:v$${MAJOR:-0}.$${MINOR} -t $${REGISTRY}/$$REPO:v$${MAJOR:-0} -t $${REGISTRY}/$$REPO:latest .
|
||||||
|
- docker login --username $USER --password $PASS $${REGISTRY}
|
||||||
|
- docker push $${REGISTRY}/$${REPO}:v$${MAJOR:-0}.$${MINOR:-0}.$${PATCH-0}
|
||||||
|
- docker push $${REGISTRY}/$${REPO}:v$${MAJOR:-0}.$${MINOR}
|
||||||
|
- docker push $${REGISTRY}/$${REPO}:v$${MAJOR:-0}
|
||||||
|
- docker push $${REGISTRY}/$${REPO}:latest
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
when:
|
||||||
|
- repo: nikurasu:elk-test-ci
|
||||||
|
- event: tag
|
|
@ -21,7 +21,6 @@ To develop and test the Elk package:
|
||||||
2. Ensure using the latest Node.js (16.x).
|
2. Ensure using the latest Node.js (16.x).
|
||||||
If you have [nvm](https://github.com/nvm-sh/nvm), you can run `nvm i` to install the required version.
|
If you have [nvm](https://github.com/nvm-sh/nvm), you can run `nvm i` to install the required version.
|
||||||
|
|
||||||
|
|
||||||
3. The package manager used to install and link dependencies must be [pnpm](https://pnpm.io/) v7. To use it you must first enable [Corepack](https://github.com/nodejs/corepack) by running `corepack enable`. (Note: on Linux in a standard Node 16+ environment, you should follow the instructions to install via Node's `corepack` rather than using the `curl` command)
|
3. The package manager used to install and link dependencies must be [pnpm](https://pnpm.io/) v7. To use it you must first enable [Corepack](https://github.com/nodejs/corepack) by running `corepack enable`. (Note: on Linux in a standard Node 16+ environment, you should follow the instructions to install via Node's `corepack` rather than using the `curl` command)
|
||||||
|
|
||||||
4. Check out a branch where you can work and commit your changes:
|
4. Check out a branch where you can work and commit your changes:
|
||||||
|
@ -84,7 +83,7 @@ Simple approach used by most websites of relying on direction set in HTML elemen
|
||||||
We've added some `UnoCSS` utilities styles to help you with that:
|
We've added some `UnoCSS` utilities styles to help you with that:
|
||||||
- Do not use `left/right` padding and margin: for example `pl-1`. Use `padding-inline-start/end` instead. So `pl-1` should be `ps-1`, `pr-1` should be `pe-1`. The same rules apply to margin.
|
- Do not use `left/right` padding and margin: for example `pl-1`. Use `padding-inline-start/end` instead. So `pl-1` should be `ps-1`, `pr-1` should be `pe-1`. The same rules apply to margin.
|
||||||
- Do not use `rtl-` classes, such as `rtl-left-0`.
|
- Do not use `rtl-` classes, such as `rtl-left-0`.
|
||||||
- For icons that should be rotated for RTL, add `class="rtl-flip"`. This can only be used for icons outside of elements with `dir="auto"`, such as timeline, and is the only exception from the rule above. For icons inside the timeline, it might not work as expected.
|
- For icons that should be rotated for RTL, add `class="rtl-flip"`. This can only be used for icons outside of elements with `dir="auto"`, such as timeline, and is the only exception to the rule above. For icons inside the timeline, it might not work as expected.
|
||||||
- For absolute positioned elements, don't use `left/right`: for example `left-0`. Use `inset-inline-start/end` instead. `UnoCSS` shortcuts are `inset-is` for `inset-inline-start` and `inset-ie` for `inset-inline-end`. Example: `left-0` should be replaced with `inset-is-0`.
|
- For absolute positioned elements, don't use `left/right`: for example `left-0`. Use `inset-inline-start/end` instead. `UnoCSS` shortcuts are `inset-is` for `inset-inline-start` and `inset-ie` for `inset-inline-end`. Example: `left-0` should be replaced with `inset-is-0`.
|
||||||
- If you need to change the border radius for an entire left or right side, use `border-inline-start/end`. `UnoCSS` shortcuts are `rounded-is` for left side, `rounded-ie` for right side. Example: `rounded-l-5` should be replaced with `rounded-ie-5`.
|
- If you need to change the border radius for an entire left or right side, use `border-inline-start/end`. `UnoCSS` shortcuts are `rounded-is` for left side, `rounded-ie` for right side. Example: `rounded-l-5` should be replaced with `rounded-ie-5`.
|
||||||
- If you need to change the border radius for one corner, use `border-start-end-radius` and similar rules. `UnoCSS` shortcuts are `rounded` + top/bottom as either `-bs` (top) or `-be` (bottom) + left/right as either `-is` (left) or `-ie` (right). Example: `rounded-tl-0` should be replaced with `rounded-bs-is-0`.
|
- If you need to change the border radius for one corner, use `border-start-end-radius` and similar rules. `UnoCSS` shortcuts are `rounded` + top/bottom as either `-bs` (top) or `-be` (bottom) + left/right as either `-is` (left) or `-ie` (right). Example: `rounded-tl-0` should be replaced with `rounded-bs-is-0`.
|
||||||
|
@ -103,7 +102,7 @@ If you are updating a translation in your local environment, you can run the fol
|
||||||
### Adding a new language
|
### Adding a new language
|
||||||
|
|
||||||
1. Add a new file in [locales](./locales) folder with the language code as the filename.
|
1. Add a new file in [locales](./locales) folder with the language code as the filename.
|
||||||
2. Copy [en-US](./locales/en-US.json) and translate the strings.
|
2. Copy [en](./locales/en.json) and translate the strings.
|
||||||
3. Add the language to the `locales` array in [config/i18n.ts](./config/i18n.ts#L61), below `en` and `ar`:
|
3. Add the language to the `locales` array in [config/i18n.ts](./config/i18n.ts#L61), below `en` and `ar`:
|
||||||
- If your language has multiple country variants, add the generic one for language only (only if there are a lot of common entries, you can always add it as a new one)
|
- If your language has multiple country variants, add the generic one for language only (only if there are a lot of common entries, you can always add it as a new one)
|
||||||
- Add all country variants in [country variants object](./config/i18n.ts#L12)
|
- Add all country variants in [country variants object](./config/i18n.ts#L12)
|
||||||
|
|
|
@ -14,6 +14,7 @@ RUN apk add git --no-cache
|
||||||
|
|
||||||
# Prepare build deps ( ignore postinstall scripts for now )
|
# Prepare build deps ( ignore postinstall scripts for now )
|
||||||
COPY package.json ./
|
COPY package.json ./
|
||||||
|
COPY .npmrc ./
|
||||||
COPY pnpm-lock.yaml ./
|
COPY pnpm-lock.yaml ./
|
||||||
COPY patches ./patches
|
COPY patches ./patches
|
||||||
RUN pnpm i --frozen-lockfile --ignore-scripts
|
RUN pnpm i --frozen-lockfile --ignore-scripts
|
||||||
|
|
14
README.md
14
README.md
|
@ -39,7 +39,7 @@ The Elk team maintains a deployment at:
|
||||||
|
|
||||||
### Self-Host Docker Deployment
|
### Self-Host Docker Deployment
|
||||||
|
|
||||||
In order to host Elk yourself you can use the provided Dockerfile to build a container with elk. Be aware, that Elk only loads properly of the connection is done via SSL/TLS. The Docker container itself does not provide any SSL/TLS handling. You'll have to add this bit yourself.
|
In order to host Elk yourself you can use the provided Dockerfile to build a container with elk. Be aware, that Elk only loads properly if the connection is done via SSL/TLS. The Docker container itself does not provide any SSL/TLS handling. You'll have to add this bit yourself.
|
||||||
One could put Elk behind popular reverse proxies with SSL Handling like Traefik, NGINX etc.
|
One could put Elk behind popular reverse proxies with SSL Handling like Traefik, NGINX etc.
|
||||||
|
|
||||||
1. checkout source ```git clone https://github.com/elk-zone/elk.git```
|
1. checkout source ```git clone https://github.com/elk-zone/elk.git```
|
||||||
|
@ -49,20 +49,24 @@ One could put Elk behind popular reverse proxies with SSL Handling like Traefik,
|
||||||
1. adjust permissions of storage dir: ```sudo chown 911:911 ./elk-storage```
|
1. adjust permissions of storage dir: ```sudo chown 911:911 ./elk-storage```
|
||||||
1. start container: ```docker-compose up -d```
|
1. start container: ```docker-compose up -d```
|
||||||
|
|
||||||
Note: The provided Dockerfile creates a container which will eventually run Elk as non-root user and create a persistent named Docker volume upon first start (if that volume does not yet exist). This volume is always created with root permission. Failing to change the permissions of ```/elk/data``` inside this volume to UID:GID 911 (as specified for Elk in the Dockerfile) will prevent Elk from storing it's config for user accounts. You either have to fix the permission in the created named volume, or mount a directory with the correct permission to ```/elk/data``` into the container.
|
> [!NOTE]
|
||||||
|
> The provided Dockerfile creates a container which will eventually run Elk as non-root user and create a persistent named Docker volume upon first start (if that volume does not yet exist). This volume is always created with root permission. Failing to change the permissions of ```/elk/data``` inside this volume to UID:GID 911 (as specified for Elk in the Dockerfile) will prevent Elk from storing it's config for user accounts. You either have to fix the permission in the created named volume, or mount a directory with the correct permission to ```/elk/data``` into the container.
|
||||||
|
|
||||||
### Ecosystem
|
### Ecosystem
|
||||||
|
|
||||||
These are known deployments using Elk as an alternative Web client for Mastodon servers or as a base for other projects in the fediverse:
|
These are known deployments using Elk as an alternative Web client for Mastodon servers or as a base for other projects in the fediverse:
|
||||||
|
|
||||||
- [elk.fedified.com](https://elk.fedified.com) - Use Elk to log into any compatible instance
|
- [elk.fedified.com](https://elk.fedified.com) - Use Elk to log into any compatible instance
|
||||||
|
- [elk.me.uk](https://elk.me.uk) - Use Elk to log into any compatible instance, hosted on Google Cloud Run with no Cloudflare proxy
|
||||||
- [elk.h4.io](https://elk.h4.io) - Use Elk for the `h4.io` Server
|
- [elk.h4.io](https://elk.h4.io) - Use Elk for the `h4.io` Server
|
||||||
- [elk.universeodon.com](https://elk.universeodon.com) - Use Elk for the Universeodon Server
|
- [elk.universeodon.com](https://elk.universeodon.com) - Use Elk for the Universeodon Server
|
||||||
- [elk.vmst.io](https://elk.vmst.io) - Use Elk for the `vmst.io` Server
|
- [elk.vmst.io](https://elk.vmst.io) - Use Elk for the `vmst.io` Server
|
||||||
- [elk.hostux.social](https://elk.hostux.social) - Use Elk for the `hostux.social` Server
|
- [elk.hostux.social](https://elk.hostux.social) - Use Elk for the `hostux.social` Server
|
||||||
- [elk.cupoftea.social](https://elk.cupoftea.social) - Use Elk for the `cupoftea.social` Server
|
- [elk.cupoftea.social](https://elk.cupoftea.social) - Use Elk for the `cupoftea.social` Server
|
||||||
- [elk.aus.social](https://elk.aus.social) - Use Elk for the `aus.social` Server
|
- [elk.aus.social](https://elk.aus.social) - Use Elk for the `aus.social` Server
|
||||||
|
- [elk.mstdn.ca](https://elk.mstdn.ca) - Use Elk for the `mstdn.ca` Server
|
||||||
|
- [elk.mastodonapp.uk](https://elk.mastodonapp.uk) - Use Elk for the `mastodonapp.uk` Server
|
||||||
|
- [elk.bolha.us](https://elk.bolha.us) - Use Elk for the `bolha.us` Server
|
||||||
|
|
||||||
> **Note**: Community deployments are **NOT** maintained by the Elk team. It may not be synced with Elk's source code. Please do your own research about the host servers before using them.
|
> **Note**: Community deployments are **NOT** maintained by the Elk team. It may not be synced with Elk's source code. Please do your own research about the host servers before using them.
|
||||||
|
|
||||||
|
@ -134,7 +138,7 @@ nr test
|
||||||
|
|
||||||
## 📲 PWA
|
## 📲 PWA
|
||||||
|
|
||||||
You can consult the [PWA documentation](https://docs.elk.zone/docs/pwa) to learn more about the PWA capabilities on Elk, how to install Elk PWA in your desktop or mobile device and some hints about PWA stuff on Elk.
|
You can consult the [PWA documentation](https://docs.elk.zone/pwa) to learn more about the PWA capabilities on Elk, how to install Elk PWA in your desktop or mobile device and some hints about PWA stuff on Elk.
|
||||||
|
|
||||||
## 🦄 Stack
|
## 🦄 Stack
|
||||||
|
|
||||||
|
@ -147,7 +151,7 @@ You can consult the [PWA documentation](https://docs.elk.zone/docs/pwa) to learn
|
||||||
- [UnoCSS](https://uno.antfu.me/) - The instant on-demand atomic CSS engine
|
- [UnoCSS](https://uno.antfu.me/) - The instant on-demand atomic CSS engine
|
||||||
- [Iconify](https://github.com/iconify/icon-sets#iconify-icon-sets-in-json-format) - Iconify icon sets in JSON format
|
- [Iconify](https://github.com/iconify/icon-sets#iconify-icon-sets-in-json-format) - Iconify icon sets in JSON format
|
||||||
- [Masto.js](https://neet.github.io/masto.js) - Mastodon API client in TypeScript
|
- [Masto.js](https://neet.github.io/masto.js) - Mastodon API client in TypeScript
|
||||||
- [shiki](https://shiki.matsu.io/) - A beautiful Syntax Highlighter
|
- [shiki](https://shiki.style/) - A beautiful yet powerful syntax highlighter
|
||||||
- [vite-plugin-pwa](https://github.com/vite-pwa/vite-plugin-pwa) - Prompt for update, Web Push Notifications and Web Share Target API
|
- [vite-plugin-pwa](https://github.com/vite-pwa/vite-plugin-pwa) - Prompt for update, Web Push Notifications and Web Share Target API
|
||||||
|
|
||||||
## 👨💻 Contributors
|
## 👨💻 Contributors
|
||||||
|
|
6
app.vue
6
app.vue
|
@ -4,10 +4,12 @@ provideGlobalCommands()
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
if (process.server && !route.path.startsWith('/settings')) {
|
if (import.meta.server && !route.path.startsWith('/settings')) {
|
||||||
|
const url = useRequestURL()
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
meta: [
|
meta: [
|
||||||
{ property: 'og:url', content: `https://elk.zone${route.path}` },
|
{ property: 'og:url', content: `${url.origin}${route.path}` },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,8 +6,8 @@ defineProps<{
|
||||||
square?: boolean
|
square?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const loaded = $ref(false)
|
const loaded = ref(false)
|
||||||
const error = $ref(false)
|
const error = ref(false)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
|
@ -1,16 +1,16 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { mastodon } from 'masto'
|
import type { mastodon } from 'masto'
|
||||||
|
|
||||||
const { account, as = 'div' } = $defineProps<{
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { account, as = 'div' } = defineProps<{
|
||||||
account: mastodon.v1.Account
|
account: mastodon.v1.Account
|
||||||
as?: string
|
as?: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
cacheAccount(account)
|
cacheAccount(account)
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
inheritAttrs: false,
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
|
@ -19,8 +19,10 @@ cacheAccount(account)
|
||||||
overflow-hidden
|
overflow-hidden
|
||||||
:to="getAccountRoute(account)"
|
:to="getAccountRoute(account)"
|
||||||
/>
|
/>
|
||||||
<div h-full p1 shrink-0>
|
<slot>
|
||||||
<AccountFollowButton :account="account" :context="relationshipContext" />
|
<div h-full p1 shrink-0>
|
||||||
</div>
|
<AccountFollowButton :account="account" :context="relationshipContext" />
|
||||||
|
</div>
|
||||||
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { mastodon } from 'masto'
|
import type { mastodon } from 'masto'
|
||||||
|
import { toggleFollowAccount, useRelationship } from '~~/composables/masto/relationship'
|
||||||
|
|
||||||
const { account, command, context, ...props } = defineProps<{
|
const { account, command, context, ...props } = defineProps<{
|
||||||
account: mastodon.v1.Account
|
account: mastodon.v1.Account
|
||||||
|
@ -9,55 +10,36 @@ const { account, command, context, ...props } = defineProps<{
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const isSelf = $(useSelfAccount(() => account))
|
const isSelf = useSelfAccount(() => account)
|
||||||
const enable = $computed(() => !isSelf && currentUser.value)
|
const enable = computed(() => !isSelf.value && currentUser.value)
|
||||||
const relationship = $computed(() => props.relationship || useRelationship(account).value)
|
const relationship = computed(() => props.relationship || useRelationship(account).value)
|
||||||
|
const isLoading = computed(() => relationship.value === undefined)
|
||||||
|
|
||||||
const { client } = $(useMasto())
|
const { client } = useMasto()
|
||||||
async function toggleFollow() {
|
|
||||||
if (relationship!.following) {
|
|
||||||
if (await openConfirmDialog({
|
|
||||||
title: t('confirm.unfollow.title'),
|
|
||||||
confirm: t('confirm.unfollow.confirm'),
|
|
||||||
cancel: t('confirm.unfollow.cancel'),
|
|
||||||
}) !== 'confirm')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
relationship!.following = !relationship!.following
|
|
||||||
try {
|
|
||||||
const newRel = await client.v1.accounts[relationship!.following ? 'follow' : 'unfollow'](account.id)
|
|
||||||
Object.assign(relationship!, newRel)
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
// TODO error handling
|
|
||||||
relationship!.following = !relationship!.following
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function unblock() {
|
async function unblock() {
|
||||||
relationship!.blocking = false
|
relationship.value!.blocking = false
|
||||||
try {
|
try {
|
||||||
const newRel = await client.v1.accounts.unblock(account.id)
|
const newRel = await client.value.v1.accounts.$select(account.id).unblock()
|
||||||
Object.assign(relationship!, newRel)
|
Object.assign(relationship!, newRel)
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
// TODO error handling
|
// TODO error handling
|
||||||
relationship!.blocking = true
|
relationship.value!.blocking = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function unmute() {
|
async function unmute() {
|
||||||
relationship!.muting = false
|
relationship.value!.muting = false
|
||||||
try {
|
try {
|
||||||
const newRel = await client.v1.accounts.unmute(account.id)
|
const newRel = await client.value.v1.accounts.$select(account.id).unmute()
|
||||||
Object.assign(relationship!, newRel)
|
Object.assign(relationship!, newRel)
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
// TODO error handling
|
// TODO error handling
|
||||||
relationship!.muting = true
|
relationship.value!.muting = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -65,21 +47,25 @@ useCommand({
|
||||||
scope: 'Actions',
|
scope: 'Actions',
|
||||||
order: -2,
|
order: -2,
|
||||||
visible: () => command && enable,
|
visible: () => command && enable,
|
||||||
name: () => `${relationship?.following ? t('account.unfollow') : t('account.follow')} ${getShortHandle(account)}`,
|
name: () => `${relationship.value?.following ? t('account.unfollow') : t('account.follow')} ${getShortHandle(account)}`,
|
||||||
icon: 'i-ri:star-line',
|
icon: 'i-ri:star-line',
|
||||||
onActivate: () => toggleFollow(),
|
onActivate: () => toggleFollowAccount(relationship.value!, account),
|
||||||
})
|
})
|
||||||
|
|
||||||
const buttonStyle = $computed(() => {
|
const buttonStyle = computed(() => {
|
||||||
if (relationship?.blocking)
|
if (relationship.value?.blocking)
|
||||||
return 'text-inverted bg-red border-red'
|
return 'text-inverted bg-red border-red'
|
||||||
|
|
||||||
if (relationship?.muting)
|
if (relationship.value?.muting)
|
||||||
return 'text-base bg-card border-base'
|
return 'text-base bg-card border-base'
|
||||||
|
|
||||||
// If following, use a label style with a strong border for Mutuals
|
// If following, use a label style with a strong border for Mutuals
|
||||||
if (relationship ? relationship.following : context === 'following')
|
if (relationship.value ? relationship.value.following : context === 'following')
|
||||||
return `text-base ${relationship?.followedBy ? 'border-strong' : 'border-base'}`
|
return `text-base ${relationship.value?.followedBy ? 'border-strong' : 'border-base'}`
|
||||||
|
|
||||||
|
// If loading, use a plain style
|
||||||
|
if (isLoading.value)
|
||||||
|
return 'text-base border-base'
|
||||||
|
|
||||||
// If not following, use a button style
|
// If not following, use a button style
|
||||||
return 'text-inverted bg-primary border-primary'
|
return 'text-inverted bg-primary border-primary'
|
||||||
|
@ -90,34 +76,39 @@ const buttonStyle = $computed(() => {
|
||||||
<button
|
<button
|
||||||
v-if="enable"
|
v-if="enable"
|
||||||
gap-1 items-center group
|
gap-1 items-center group
|
||||||
:disabled="relationship?.requested"
|
|
||||||
border-1
|
border-1
|
||||||
rounded-full flex="~ gap2 center" font-500 min-w-30 h-fit px3 py1
|
rounded-full flex="~ gap2 center" font-500 min-w-30 h-fit px3 py1
|
||||||
:class="buttonStyle"
|
:class="buttonStyle"
|
||||||
:hover="!relationship?.blocking && !relationship?.muting && relationship?.following ? 'border-red text-red' : 'bg-base border-primary text-primary'"
|
:hover="!relationship?.blocking && !relationship?.muting && relationship?.following ? 'border-red text-red' : 'bg-base border-primary text-primary'"
|
||||||
@click="relationship?.blocking ? unblock() : relationship?.muting ? unmute() : toggleFollow()"
|
@click="relationship?.blocking ? unblock() : relationship?.muting ? unmute() : toggleFollowAccount(relationship!, account)"
|
||||||
>
|
>
|
||||||
<template v-if="relationship?.blocking">
|
<template v-if="isLoading">
|
||||||
<span elk-group-hover="hidden">{{ $t('account.blocking') }}</span>
|
<span i-svg-spinners-180-ring-with-bg />
|
||||||
<span hidden elk-group-hover="inline">{{ $t('account.unblock') }}</span>
|
|
||||||
</template>
|
|
||||||
<template v-if="relationship?.muting">
|
|
||||||
<span elk-group-hover="hidden">{{ $t('account.muting') }}</span>
|
|
||||||
<span hidden elk-group-hover="inline">{{ $t('account.unmute') }}</span>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="relationship ? relationship.following : context === 'following'">
|
|
||||||
<span elk-group-hover="hidden">{{ relationship?.followedBy ? $t('account.mutuals') : $t('account.following') }}</span>
|
|
||||||
<span hidden elk-group-hover="inline">{{ $t('account.unfollow') }}</span>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="relationship?.requested">
|
|
||||||
<span>{{ $t('account.follow_requested') }}</span>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="relationship ? relationship.followedBy : context === 'followedBy'">
|
|
||||||
<span elk-group-hover="hidden">{{ $t('account.follows_you') }}</span>
|
|
||||||
<span hidden elk-group-hover="inline">{{ $t('account.follow_back') }}</span>
|
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<span>{{ account.locked ? $t('account.request_follow') : $t('account.follow') }}</span>
|
<template v-if="relationship?.blocking">
|
||||||
|
<span elk-group-hover="hidden">{{ $t('account.blocking') }}</span>
|
||||||
|
<span hidden elk-group-hover="inline">{{ $t('account.unblock') }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-if="relationship?.muting">
|
||||||
|
<span elk-group-hover="hidden">{{ $t('account.muting') }}</span>
|
||||||
|
<span hidden elk-group-hover="inline">{{ $t('account.unmute') }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="relationship ? relationship.following : context === 'following'">
|
||||||
|
<span elk-group-hover="hidden">{{ relationship?.followedBy ? $t('account.mutuals') : $t('account.following') }}</span>
|
||||||
|
<span hidden elk-group-hover="inline">{{ $t('account.unfollow') }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="relationship?.requested">
|
||||||
|
<span elk-group-hover="hidden">{{ $t('account.follow_requested') }}</span>
|
||||||
|
<span hidden elk-group-hover="inline">{{ $t('account.withdraw_follow_request') }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="relationship ? relationship.followedBy : context === 'followedBy'">
|
||||||
|
<span elk-group-hover="hidden">{{ $t('account.follows_you') }}</span>
|
||||||
|
<span hidden elk-group-hover="inline">{{ account.locked ? $t('account.request_follow') : $t('account.follow_back') }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span>{{ account.locked ? $t('account.request_follow') : $t('account.follow') }}</span>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
68
components/account/AccountFollowRequestButton.vue
Normal file
68
components/account/AccountFollowRequestButton.vue
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { mastodon } from 'masto'
|
||||||
|
|
||||||
|
const { account, ...props } = defineProps<{
|
||||||
|
account: mastodon.v1.Account
|
||||||
|
relationship?: mastodon.v1.Relationship
|
||||||
|
}>()
|
||||||
|
const relationship = computed(() => props.relationship || useRelationship(account).value)
|
||||||
|
const { client } = useMasto()
|
||||||
|
|
||||||
|
async function authorizeFollowRequest() {
|
||||||
|
relationship.value!.requestedBy = false
|
||||||
|
relationship.value!.followedBy = true
|
||||||
|
try {
|
||||||
|
const newRel = await client.value.v1.followRequests.$select(account.id).authorize()
|
||||||
|
Object.assign(relationship!, newRel)
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
relationship.value!.requestedBy = true
|
||||||
|
relationship.value!.followedBy = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rejectFollowRequest() {
|
||||||
|
relationship.value!.requestedBy = false
|
||||||
|
try {
|
||||||
|
const newRel = await client.value.v1.followRequests.$select(account.id).reject()
|
||||||
|
Object.assign(relationship!, newRel)
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
relationship.value!.requestedBy = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div flex gap-4>
|
||||||
|
<template v-if="relationship?.requestedBy">
|
||||||
|
<CommonTooltip :content="$t('account.authorize')" no-auto-focus>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
rounded-full text-sm p2 border-1
|
||||||
|
hover:text-green transition-colors
|
||||||
|
@click="authorizeFollowRequest"
|
||||||
|
>
|
||||||
|
<span block text-current i-ri:check-fill />
|
||||||
|
</button>
|
||||||
|
</CommonTooltip>
|
||||||
|
<CommonTooltip :content="$t('account.reject')" no-auto-focus>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
rounded-full text-sm p2 border-1
|
||||||
|
hover:text-red transition-colors
|
||||||
|
@click="rejectFollowRequest"
|
||||||
|
>
|
||||||
|
<span block text-current i-ri:close-fill />
|
||||||
|
</button>
|
||||||
|
</CommonTooltip>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span text-secondary>
|
||||||
|
{{ relationship?.followedBy ? $t('account.authorized') : $t('account.rejected') }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
|
@ -5,7 +5,7 @@ const { account } = defineProps<{
|
||||||
account: mastodon.v1.Account
|
account: mastodon.v1.Account
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const serverName = $computed(() => getServerName(account))
|
const serverName = computed(() => getServerName(account))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
|
@ -6,28 +6,30 @@ const { account } = defineProps<{
|
||||||
command?: boolean
|
command?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { client } = $(useMasto())
|
const { client } = useMasto()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const createdAt = $(useFormattedDateTime(() => account.createdAt, {
|
const createdAt = useFormattedDateTime(() => account.createdAt, {
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
}))
|
})
|
||||||
|
|
||||||
const relationship = $(useRelationship(account))
|
const relationship = useRelationship(account)
|
||||||
|
|
||||||
const namedFields = ref<mastodon.v1.AccountField[]>([])
|
const namedFields = ref<mastodon.v1.AccountField[]>([])
|
||||||
const iconFields = ref<mastodon.v1.AccountField[]>([])
|
const iconFields = ref<mastodon.v1.AccountField[]>([])
|
||||||
const hasHeader = $computed(() => !account.header.endsWith('/original/missing.png'))
|
const isEditingPersonalNote = ref<boolean>(false)
|
||||||
|
const hasHeader = computed(() => !account.header.endsWith('/original/missing.png'))
|
||||||
|
const isCopied = ref<boolean>(false)
|
||||||
|
|
||||||
function getFieldIconTitle(fieldName: string) {
|
function getFieldIconTitle(fieldName: string) {
|
||||||
return fieldName === 'Joined' ? t('account.joined') : fieldName
|
return fieldName === 'Joined' ? t('account.joined') : fieldName
|
||||||
}
|
}
|
||||||
|
|
||||||
function getNotificationIconTitle() {
|
function getNotificationIconTitle() {
|
||||||
return relationship?.notifying ? t('account.notifications_on_post_disable', { username: `@${account.username}` }) : t('account.notifications_on_post_enable', { username: `@${account.username}` })
|
return relationship.value?.notifying ? t('account.notifications_on_post_disable', { username: `@${account.username}` }) : t('account.notifications_on_post_enable', { username: `@${account.username}` })
|
||||||
}
|
}
|
||||||
|
|
||||||
function previewHeader() {
|
function previewHeader() {
|
||||||
|
@ -49,14 +51,14 @@ function previewAvatar() {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleNotifications() {
|
async function toggleNotifications() {
|
||||||
relationship!.notifying = !relationship?.notifying
|
relationship.value!.notifying = !relationship.value?.notifying
|
||||||
try {
|
try {
|
||||||
const newRel = await client.v1.accounts.follow(account.id, { notify: relationship?.notifying })
|
const newRel = await client.value.v1.accounts.$select(account.id).follow({ notify: relationship.value?.notifying })
|
||||||
Object.assign(relationship!, newRel)
|
Object.assign(relationship!, newRel)
|
||||||
}
|
}
|
||||||
catch {
|
catch {
|
||||||
// TODO error handling
|
// TODO error handling
|
||||||
relationship!.notifying = !relationship?.notifying
|
relationship.value!.notifying = !relationship.value?.notifying
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -73,27 +75,70 @@ watchEffect(() => {
|
||||||
})
|
})
|
||||||
icons.push({
|
icons.push({
|
||||||
name: 'Joined',
|
name: 'Joined',
|
||||||
value: createdAt,
|
value: createdAt.value,
|
||||||
})
|
})
|
||||||
|
|
||||||
namedFields.value = named
|
namedFields.value = named
|
||||||
iconFields.value = icons
|
iconFields.value = icons
|
||||||
})
|
})
|
||||||
|
|
||||||
const isSelf = $(useSelfAccount(() => account))
|
const personalNoteDraft = ref(relationship.value?.note ?? '')
|
||||||
const isNotifiedOnPost = $computed(() => !!relationship?.notifying)
|
watch(relationship, (relationship, oldValue) => {
|
||||||
|
if (!oldValue && relationship)
|
||||||
|
personalNoteDraft.value = relationship.note ?? ''
|
||||||
|
})
|
||||||
|
|
||||||
|
async function editNote(event: Event) {
|
||||||
|
if (!event.target || !('value' in event.target) || !relationship.value)
|
||||||
|
return
|
||||||
|
|
||||||
|
const newNote = event.target?.value as string
|
||||||
|
|
||||||
|
if (relationship.value.note?.trim() === newNote.trim())
|
||||||
|
return
|
||||||
|
|
||||||
|
const newNoteApiResult = await client.value.v1.accounts.$select(account.id).note.create({ comment: newNote })
|
||||||
|
relationship.value.note = newNoteApiResult.note
|
||||||
|
personalNoteDraft.value = relationship.value.note ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSelf = useSelfAccount(() => account)
|
||||||
|
const isNotifiedOnPost = computed(() => !!relationship.value?.notifying)
|
||||||
|
|
||||||
|
const personalNoteMaxLength = 2000
|
||||||
|
|
||||||
|
async function copyAccountName() {
|
||||||
|
try {
|
||||||
|
const shortHandle = getShortHandle(account)
|
||||||
|
const serverName = getServerName(account)
|
||||||
|
const accountName = `${shortHandle}@${serverName}`
|
||||||
|
await navigator.clipboard.writeText(accountName)
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
console.error('Failed to copy account name:', err)
|
||||||
|
}
|
||||||
|
|
||||||
|
isCopied.value = true
|
||||||
|
setTimeout(() => {
|
||||||
|
isCopied.value = false
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div flex flex-col>
|
<div flex flex-col>
|
||||||
|
<div v-if="relationship?.requestedBy" p-4 flex justify-between items-center bg-card>
|
||||||
|
<span text-primary font-bold>{{ $t('account.requested', [account.displayName]) }}</span>
|
||||||
|
<AccountFollowRequestButton :account="account" :relationship="relationship" />
|
||||||
|
</div>
|
||||||
<component :is="hasHeader ? 'button' : 'div'" border="b base" z-1 @click="hasHeader ? previewHeader() : undefined">
|
<component :is="hasHeader ? 'button' : 'div'" border="b base" z-1 @click="hasHeader ? previewHeader() : undefined">
|
||||||
<img h-50 height="200" w-full object-cover :src="account.header" :alt="t('account.profile_description', [account.username])">
|
<img h-50 height="200" w-full object-cover :src="account.header" :alt="t('account.profile_description', [account.username])">
|
||||||
</component>
|
</component>
|
||||||
<div p4 mt--18 flex flex-col gap-4>
|
<div p4 mt--18 flex flex-col gap-4>
|
||||||
<div relative>
|
<div relative>
|
||||||
<div flex justify-between>
|
<div flex justify-between>
|
||||||
<button shrink-0 :class="{ 'rounded-full': !isSelf, 'squircle': isSelf }" w-30 h-30 p1 bg-base border-bg-base z-2 @click="previewAvatar">
|
<button shrink-0 h-full :class="{ 'rounded-full': !isSelf, 'squircle': isSelf }" p1 bg-base border-bg-base z-2 @click="previewAvatar">
|
||||||
<AccountAvatar :square="isSelf" :account="account" hover:opacity-90 transition-opacity />
|
<AccountAvatar :square="isSelf" :account="account" hover:opacity-90 transition-opacity w-28 h-28 />
|
||||||
</button>
|
</button>
|
||||||
<div inset-ie-0 flex="~ wrap row-reverse" gap-2 items-center pt18 justify-start>
|
<div inset-ie-0 flex="~ wrap row-reverse" gap-2 items-center pt18 justify-start>
|
||||||
<!-- Edit profile -->
|
<!-- Edit profile -->
|
||||||
|
@ -107,7 +152,11 @@ const isNotifiedOnPost = $computed(() => !!relationship?.notifying)
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<AccountFollowButton :account="account" :command="command" />
|
<AccountFollowButton :account="account" :command="command" />
|
||||||
<span inset-ie-0 flex gap-2 items-center>
|
<span inset-ie-0 flex gap-2 items-center>
|
||||||
<AccountMoreButton :account="account" :command="command" />
|
<AccountMoreButton
|
||||||
|
:account="account" :command="command"
|
||||||
|
@add-note="isEditingPersonalNote = true"
|
||||||
|
@remove-note="() => { isEditingPersonalNote = false; personalNoteDraft = '' }"
|
||||||
|
/>
|
||||||
<CommonTooltip v-if="!isSelf && relationship?.following" :content="getNotificationIconTitle()">
|
<CommonTooltip v-if="!isSelf && relationship?.following" :content="getNotificationIconTitle()">
|
||||||
<button
|
<button
|
||||||
:aria-pressed="isNotifiedOnPost"
|
:aria-pressed="isNotifiedOnPost"
|
||||||
|
@ -138,21 +187,70 @@ const isNotifiedOnPost = $computed(() => !!relationship?.notifying)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div flex="~ col gap1" pt2>
|
<div flex="~ col gap1" pt2>
|
||||||
<div flex justify-between>
|
<div flex gap2 items-center flex-wrap>
|
||||||
<AccountDisplayName :account="account" font-bold sm:text-2xl text-xl />
|
<AccountDisplayName :account="account" font-bold sm:text-2xl text-xl />
|
||||||
|
<AccountRolesIndicator v-if="account.roles?.length" :account="account" />
|
||||||
|
<AccountLockIndicator v-if="account.locked" show-label />
|
||||||
<AccountBotIndicator v-if="account.bot" show-label />
|
<AccountBotIndicator v-if="account.bot" show-label />
|
||||||
</div>
|
</div>
|
||||||
<AccountHandle :account="account" />
|
|
||||||
|
<div flex items-center gap-1>
|
||||||
|
<AccountHandle :account="account" overflow-unset line-clamp-unset />
|
||||||
|
<CommonTooltip placement="bottom" :content="$t('account.copy_account_name')" no-auto-focus flex>
|
||||||
|
<button text-secondary-light text-sm :class="isCopied ? 'i-ri:check-fill text-green' : 'i-ri:file-copy-line'" @click="copyAccountName">
|
||||||
|
<span sr-only>{{ $t('account.copy_account_name') }}</span>
|
||||||
|
</button>
|
||||||
|
</CommonTooltip>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<label
|
||||||
|
v-if="isEditingPersonalNote || (relationship?.note && relationship.note.length > 0)"
|
||||||
|
space-y-2
|
||||||
|
pb-4
|
||||||
|
block
|
||||||
|
border="b base"
|
||||||
|
>
|
||||||
|
<div flex flex-row space-x-2 flex-v-center>
|
||||||
|
<div i-ri-edit-2-line />
|
||||||
|
<p font-medium>
|
||||||
|
{{ $t('account.profile_personal_note') }}
|
||||||
|
</p>
|
||||||
|
<p text-secondary text-sm :class="{ 'text-orange': personalNoteDraft.length > (personalNoteMaxLength - 100) }">
|
||||||
|
{{ personalNoteDraft.length }} / {{ personalNoteMaxLength }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div position-relative>
|
||||||
|
<div
|
||||||
|
input-base
|
||||||
|
min-h-10ex
|
||||||
|
whitespace-pre-wrap
|
||||||
|
opacity-0
|
||||||
|
:class="{ 'trailing-newline': personalNoteDraft.endsWith('\n') }"
|
||||||
|
>
|
||||||
|
{{ personalNoteDraft }}
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
v-model="personalNoteDraft"
|
||||||
|
input-base
|
||||||
|
position-absolute
|
||||||
|
style="height: 100%"
|
||||||
|
top-0
|
||||||
|
resize-none
|
||||||
|
:maxlength="personalNoteMaxLength"
|
||||||
|
@change="editNote"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
<div v-if="account.note" max-h-100 overflow-y-auto>
|
<div v-if="account.note" max-h-100 overflow-y-auto>
|
||||||
<ContentRich text-4 text-base :content="account.note" :emojis="account.emojis" />
|
<ContentRich text-4 text-base :content="account.note" :emojis="account.emojis" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="namedFields.length" flex="~ col wrap gap1">
|
<div v-if="namedFields.length" flex="~ col wrap gap1">
|
||||||
<div v-for="field in namedFields" :key="field.name" flex="~ gap-1" items-center>
|
<div v-for="field in namedFields" :key="field.name" flex="~ gap-1" items-center>
|
||||||
<div text-secondary uppercase text-xs font-bold>
|
<div mt="0.5" text-secondary uppercase text-xs font-bold>
|
||||||
{{ field.name }} |
|
<ContentRich :content="field.name" :emojis="account.emojis" />
|
||||||
</div>
|
</div>
|
||||||
|
<span text-secondary text-xs font-bold>|</span>
|
||||||
<ContentRich :content="field.value" :emojis="account.emojis" />
|
<ContentRich :content="field.value" :emojis="account.emojis" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -168,3 +266,9 @@ const isNotifiedOnPost = $computed(() => !!relationship?.notifying)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.trailing-newline::after {
|
||||||
|
content: '\a';
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
@ -5,7 +5,7 @@ const { account } = defineProps<{
|
||||||
account: mastodon.v1.Account
|
account: mastodon.v1.Account
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const relationship = $(useRelationship(account))
|
const relationship = useRelationship(account)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -19,6 +19,6 @@ const relationship = $(useRelationship(account))
|
||||||
<div v-if="account.note" max-h-100 overflow-y-auto>
|
<div v-if="account.note" max-h-100 overflow-y-auto>
|
||||||
<ContentRich text-4 text-secondary :content="account.note" :emojis="account.emojis" />
|
<ContentRich text-4 text-secondary :content="account.note" :emojis="account.emojis" />
|
||||||
</div>
|
</div>
|
||||||
<AccountPostsFollowers text-sm :account="account" />
|
<AccountPostsFollowers text-sm :account="account" :is-hover-card="true" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -1,26 +1,69 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { mastodon } from 'masto'
|
import type { mastodon } from 'masto'
|
||||||
|
import { fetchAccountByHandle } from '~/composables/cache'
|
||||||
|
|
||||||
const props = defineProps<{
|
type WatcherType = [acc?: mastodon.v1.Account | null, h?: string, v?: boolean]
|
||||||
account?: mastodon.v1.Account
|
|
||||||
handle?: string
|
|
||||||
disabled?: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const account = props.account || (props.handle ? useAccountByHandle(props.handle!) : undefined)
|
|
||||||
const userSettings = useUserSettings()
|
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
inheritAttrs: false,
|
inheritAttrs: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
account?: mastodon.v1.Account | null
|
||||||
|
handle?: string
|
||||||
|
disabled?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const accountHover = ref()
|
||||||
|
const hovered = useElementHover(accountHover)
|
||||||
|
const account = ref<mastodon.v1.Account | null | undefined>(props.account)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.account, props.handle, hovered.value] satisfies WatcherType,
|
||||||
|
([newAccount, newHandle, newVisible], oldProps) => {
|
||||||
|
if (!newVisible || process.test)
|
||||||
|
return
|
||||||
|
|
||||||
|
if (newAccount) {
|
||||||
|
account.value = newAccount
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newHandle) {
|
||||||
|
const [_oldAccount, oldHandle, _oldVisible] = oldProps ?? [undefined, undefined, false]
|
||||||
|
if (!oldHandle || newHandle !== oldHandle || !account.value) {
|
||||||
|
// new handle can be wrong: using server instead of webDomain
|
||||||
|
fetchAccountByHandle(newHandle).then((acc) => {
|
||||||
|
if (newHandle === props.handle)
|
||||||
|
account.value = acc
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
account.value = undefined
|
||||||
|
},
|
||||||
|
{ immediate: true, flush: 'post' },
|
||||||
|
)
|
||||||
|
|
||||||
|
const userSettings = useUserSettings()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VMenu v-if="!disabled && account && !getPreferences(userSettings, 'hideAccountHoverCard')" placement="bottom-start" :delay="{ show: 500, hide: 100 }" v-bind="$attrs" :close-on-content-click="false">
|
<span ref="accountHover">
|
||||||
<slot />
|
<VMenu
|
||||||
<template #popper>
|
v-if="!disabled && account && !getPreferences(userSettings, 'hideAccountHoverCard')"
|
||||||
<AccountHoverCard v-if="account" :account="account" />
|
placement="bottom-start"
|
||||||
</template>
|
:delay="{ show: 500, hide: 100 }"
|
||||||
</VMenu>
|
v-bind="$attrs"
|
||||||
<slot v-else />
|
:close-on-content-click="false"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
<template #popper>
|
||||||
|
<AccountHoverCard v-if="account" :account="account" />
|
||||||
|
</template>
|
||||||
|
</VMenu>
|
||||||
|
<slot v-else />
|
||||||
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -1,16 +1,16 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { mastodon } from 'masto'
|
import type { mastodon } from 'masto'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
|
||||||
const { account, as = 'div' } = defineProps<{
|
const { account, as = 'div' } = defineProps<{
|
||||||
account: mastodon.v1.Account
|
account: mastodon.v1.Account
|
||||||
as?: string
|
as?: string
|
||||||
hoverCard?: boolean
|
hoverCard?: boolean
|
||||||
square?: boolean
|
square?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
inheritAttrs: false,
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- TODO: Make this work for both buttons and links -->
|
<!-- TODO: Make this work for both buttons and links -->
|
||||||
|
@ -23,6 +23,8 @@ defineOptions({
|
||||||
<div flex="~ col" shrink pt-1 h-full overflow-hidden justify-center leading-none select-none>
|
<div flex="~ col" shrink pt-1 h-full overflow-hidden justify-center leading-none select-none>
|
||||||
<div flex="~" gap-2>
|
<div flex="~" gap-2>
|
||||||
<AccountDisplayName :account="account" font-bold line-clamp-1 ws-pre-wrap break-all text-lg />
|
<AccountDisplayName :account="account" font-bold line-clamp-1 ws-pre-wrap break-all text-lg />
|
||||||
|
<AccountRolesIndicator v-if="account.roles?.length" :account="account" :limit="1" />
|
||||||
|
<AccountLockIndicator v-if="account.locked" text-xs />
|
||||||
<AccountBotIndicator v-if="account.bot" text-xs />
|
<AccountBotIndicator v-if="account.bot" text-xs />
|
||||||
</div>
|
</div>
|
||||||
<AccountHandle :account="account" text-secondary-light />
|
<AccountHandle :account="account" text-secondary-light />
|
||||||
|
|
23
components/account/AccountLockIndicator.vue
Normal file
23
components/account/AccountLockIndicator.vue
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
showLabel?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
flex="~ gap1" items-center
|
||||||
|
:class="{ 'border border-base rounded-md px-1': showLabel }"
|
||||||
|
text-secondary-light
|
||||||
|
>
|
||||||
|
<slot name="prepend" />
|
||||||
|
<CommonTooltip no-auto-focus content="Lock" :disabled="showLabel">
|
||||||
|
<div i-ri:lock-line />
|
||||||
|
</CommonTooltip>
|
||||||
|
<div v-if="showLabel">
|
||||||
|
{{ t('account.lock') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
|
@ -1,67 +1,56 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { mastodon } from 'masto'
|
import type { mastodon } from 'masto'
|
||||||
|
import { toggleBlockAccount, toggleBlockDomain, toggleMuteAccount } from '~~/composables/masto/relationship'
|
||||||
|
|
||||||
const { account } = defineProps<{
|
const { account } = defineProps<{
|
||||||
account: mastodon.v1.Account
|
account: mastodon.v1.Account
|
||||||
command?: boolean
|
command?: boolean
|
||||||
}>()
|
}>()
|
||||||
let relationship = $(useRelationship(account))
|
const emit = defineEmits<{
|
||||||
|
(evt: 'addNote'): void
|
||||||
|
(evt: 'removeNote'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
const isSelf = $(useSelfAccount(() => account))
|
const relationship = useRelationship(account)
|
||||||
|
|
||||||
|
const isSelf = useSelfAccount(() => account)
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { client } = $(useMasto())
|
const { client } = useMasto()
|
||||||
|
const useStarFavoriteIcon = usePreferences('useStarFavoriteIcon')
|
||||||
|
const { share, isSupported: isShareSupported } = useShare()
|
||||||
|
|
||||||
const toggleMute = async () => {
|
function shareAccount() {
|
||||||
if (!relationship!.muting && await openConfirmDialog({
|
share({ url: location.href })
|
||||||
title: t('confirm.mute_account.title', [account.acct]),
|
}
|
||||||
confirm: t('confirm.mute_account.confirm'),
|
|
||||||
cancel: t('confirm.mute_account.cancel'),
|
|
||||||
}) !== 'confirm')
|
|
||||||
return
|
|
||||||
|
|
||||||
relationship!.muting = !relationship!.muting
|
async function toggleReblogs() {
|
||||||
relationship = relationship!.muting
|
if (!relationship.value!.showingReblogs) {
|
||||||
? await client.v1.accounts.mute(account.id, {
|
const dialogChoice = await openConfirmDialog({
|
||||||
// TODO support more options
|
title: t('confirm.show_reblogs.title'),
|
||||||
|
description: t('confirm.show_reblogs.description', [account.acct]),
|
||||||
|
confirm: t('confirm.show_reblogs.confirm'),
|
||||||
|
cancel: t('confirm.show_reblogs.cancel'),
|
||||||
})
|
})
|
||||||
: await client.v1.accounts.unmute(account.id)
|
if (dialogChoice.choice !== 'confirm')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const showingReblogs = !relationship.value?.showingReblogs
|
||||||
|
relationship.value = await client.value.v1.accounts.$select(account.id).follow({ reblogs: showingReblogs })
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleBlockUser = async () => {
|
async function addUserNote() {
|
||||||
if (!relationship!.blocking && await openConfirmDialog({
|
emit('addNote')
|
||||||
title: t('confirm.block_account.title', [account.acct]),
|
|
||||||
confirm: t('confirm.block_account.confirm'),
|
|
||||||
cancel: t('confirm.block_account.cancel'),
|
|
||||||
}) !== 'confirm')
|
|
||||||
return
|
|
||||||
|
|
||||||
relationship!.blocking = !relationship!.blocking
|
|
||||||
relationship = await client.v1.accounts[relationship!.blocking ? 'block' : 'unblock'](account.id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleBlockDomain = async () => {
|
async function removeUserNote() {
|
||||||
if (!relationship!.domainBlocking && await openConfirmDialog({
|
if (!relationship.value!.note || relationship.value!.note.length === 0)
|
||||||
title: t('confirm.block_domain.title', [getServerName(account)]),
|
|
||||||
confirm: t('confirm.block_domain.confirm'),
|
|
||||||
cancel: t('confirm.block_domain.cancel'),
|
|
||||||
}) !== 'confirm')
|
|
||||||
return
|
return
|
||||||
|
|
||||||
relationship!.domainBlocking = !relationship!.domainBlocking
|
const newNote = await client.value.v1.accounts.$select(account.id).note.create({ comment: '' })
|
||||||
await client.v1.domainBlocks[relationship!.domainBlocking ? 'block' : 'unblock'](getServerName(account))
|
relationship.value!.note = newNote.note
|
||||||
}
|
emit('removeNote')
|
||||||
|
|
||||||
const toggleReblogs = async () => {
|
|
||||||
if (!relationship!.showingReblogs && await openConfirmDialog({
|
|
||||||
title: t('confirm.show_reblogs.title', [account.acct]),
|
|
||||||
confirm: t('confirm.show_reblogs.confirm'),
|
|
||||||
cancel: t('confirm.show_reblogs.cancel'),
|
|
||||||
}) !== 'confirm')
|
|
||||||
return
|
|
||||||
|
|
||||||
const showingReblogs = !relationship?.showingReblogs
|
|
||||||
relationship = await client.v1.accounts.follow(account.id, { reblogs: showingReblogs })
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -81,6 +70,13 @@ const toggleReblogs = async () => {
|
||||||
:command="command"
|
:command="command"
|
||||||
/>
|
/>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
<CommonDropdownItem
|
||||||
|
v-if="isShareSupported"
|
||||||
|
:text="$t('menu.share_account', [`@${account.acct}`])"
|
||||||
|
icon="i-ri:share-line"
|
||||||
|
:command="command"
|
||||||
|
@click="shareAccount()"
|
||||||
|
/>
|
||||||
|
|
||||||
<template v-if="currentUser">
|
<template v-if="currentUser">
|
||||||
<template v-if="!isSelf">
|
<template v-if="!isSelf">
|
||||||
|
@ -112,19 +108,34 @@ const toggleReblogs = async () => {
|
||||||
@click="toggleReblogs()"
|
@click="toggleReblogs()"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<CommonDropdownItem
|
||||||
|
v-if="!relationship?.note || relationship?.note?.length === 0"
|
||||||
|
:text="$t('menu.add_personal_note', [`@${account.acct}`])"
|
||||||
|
icon="i-ri-edit-2-line"
|
||||||
|
:command="command"
|
||||||
|
@click="addUserNote()"
|
||||||
|
/>
|
||||||
|
<CommonDropdownItem
|
||||||
|
v-else
|
||||||
|
:text="$t('menu.remove_personal_note', [`@${account.acct}`])"
|
||||||
|
icon="i-ri-edit-2-line"
|
||||||
|
:command="command"
|
||||||
|
@click="removeUserNote()"
|
||||||
|
/>
|
||||||
|
|
||||||
<CommonDropdownItem
|
<CommonDropdownItem
|
||||||
v-if="!relationship?.muting"
|
v-if="!relationship?.muting"
|
||||||
:text="$t('menu.mute_account', [`@${account.acct}`])"
|
:text="$t('menu.mute_account', [`@${account.acct}`])"
|
||||||
icon="i-ri:volume-up-fill"
|
icon="i-ri:volume-mute-line"
|
||||||
:command="command"
|
:command="command"
|
||||||
@click="toggleMute()"
|
@click="toggleMuteAccount (relationship!, account)"
|
||||||
/>
|
/>
|
||||||
<CommonDropdownItem
|
<CommonDropdownItem
|
||||||
v-else
|
v-else
|
||||||
:text="$t('menu.unmute_account', [`@${account.acct}`])"
|
:text="$t('menu.unmute_account', [`@${account.acct}`])"
|
||||||
icon="i-ri:volume-mute-line"
|
icon="i-ri:volume-up-fill"
|
||||||
:command="command"
|
:command="command"
|
||||||
@click="toggleMute()"
|
@click="toggleMuteAccount (relationship!, account)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CommonDropdownItem
|
<CommonDropdownItem
|
||||||
|
@ -132,14 +143,14 @@ const toggleReblogs = async () => {
|
||||||
:text="$t('menu.block_account', [`@${account.acct}`])"
|
:text="$t('menu.block_account', [`@${account.acct}`])"
|
||||||
icon="i-ri:forbid-2-line"
|
icon="i-ri:forbid-2-line"
|
||||||
:command="command"
|
:command="command"
|
||||||
@click="toggleBlockUser()"
|
@click="toggleBlockAccount (relationship!, account)"
|
||||||
/>
|
/>
|
||||||
<CommonDropdownItem
|
<CommonDropdownItem
|
||||||
v-else
|
v-else
|
||||||
:text="$t('menu.unblock_account', [`@${account.acct}`])"
|
:text="$t('menu.unblock_account', [`@${account.acct}`])"
|
||||||
icon="i-ri:checkbox-circle-line"
|
icon="i-ri:checkbox-circle-line"
|
||||||
:command="command"
|
:command="command"
|
||||||
@click="toggleBlockUser()"
|
@click="toggleBlockAccount (relationship!, account)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<template v-if="getServerName(account) !== currentServer">
|
<template v-if="getServerName(account) !== currentServer">
|
||||||
|
@ -148,16 +159,23 @@ const toggleReblogs = async () => {
|
||||||
:text="$t('menu.block_domain', [getServerName(account)])"
|
:text="$t('menu.block_domain', [getServerName(account)])"
|
||||||
icon="i-ri:shut-down-line"
|
icon="i-ri:shut-down-line"
|
||||||
:command="command"
|
:command="command"
|
||||||
@click="toggleBlockDomain()"
|
@click="toggleBlockDomain(relationship!, account)"
|
||||||
/>
|
/>
|
||||||
<CommonDropdownItem
|
<CommonDropdownItem
|
||||||
v-else
|
v-else
|
||||||
:text="$t('menu.unblock_domain', [getServerName(account)])"
|
:text="$t('menu.unblock_domain', [getServerName(account)])"
|
||||||
icon="i-ri:restart-line"
|
icon="i-ri:restart-line"
|
||||||
:command="command"
|
:command="command"
|
||||||
@click="toggleBlockDomain()"
|
@click="toggleBlockDomain(relationship!, account)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<CommonDropdownItem
|
||||||
|
:text="$t('menu.report_account', [`@${account.acct}`])"
|
||||||
|
icon="i-ri:flag-2-line"
|
||||||
|
:command="command"
|
||||||
|
@click="openReportDialog(account)"
|
||||||
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
@ -165,7 +183,7 @@ const toggleReblogs = async () => {
|
||||||
<CommonDropdownItem :text="$t('account.pinned')" icon="i-ri:pushpin-line" :command="command" />
|
<CommonDropdownItem :text="$t('account.pinned')" icon="i-ri:pushpin-line" :command="command" />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink to="/favourites">
|
<NuxtLink to="/favourites">
|
||||||
<CommonDropdownItem :text="$t('account.favourites')" icon="i-ri:heart-3-line" :command="command" />
|
<CommonDropdownItem :text="$t('account.favourites')" :icon="useStarFavoriteIcon ? 'i-ri:star-line' : 'i-ri:heart-3-line'" :command="command" />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink to="/mutes">
|
<NuxtLink to="/mutes">
|
||||||
<CommonDropdownItem :text="$t('account.muted_users')" icon="i-ri:volume-mute-line" :command="command" />
|
<CommonDropdownItem :text="$t('account.muted_users')" icon="i-ri:volume-mute-line" :command="command" />
|
||||||
|
|
|
@ -1,17 +1,17 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Paginator, mastodon } from 'masto'
|
import type { mastodon } from 'masto'
|
||||||
|
|
||||||
const { paginator, account, context } = defineProps<{
|
const { paginator, account, context } = defineProps<{
|
||||||
paginator: Paginator<mastodon.v1.Account[], mastodon.DefaultPaginationParams>
|
paginator: mastodon.Paginator<mastodon.v1.Account[], mastodon.DefaultPaginationParams | undefined>
|
||||||
context?: 'following' | 'followers'
|
context?: 'following' | 'followers'
|
||||||
account?: mastodon.v1.Account
|
account?: mastodon.v1.Account
|
||||||
relationshipContext?: 'followedBy' | 'following'
|
relationshipContext?: 'followedBy' | 'following'
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const fallbackContext = $computed(() => {
|
const fallbackContext = computed(() => {
|
||||||
return ['following', 'followers'].includes(context!)
|
return ['following', 'followers'].includes(context!)
|
||||||
})
|
})
|
||||||
const showOriginSite = $computed(() =>
|
const showOriginSite = computed(() =>
|
||||||
account && account.id !== currentUser.value?.account.id && getServerName(account) !== currentServer.value,
|
account && account.id !== currentUser.value?.account.id && getServerName(account) !== currentServer.value,
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -3,6 +3,7 @@ import type { mastodon } from 'masto'
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
account: mastodon.v1.Account
|
account: mastodon.v1.Account
|
||||||
|
isHoverCard?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const userSettings = useUserSettings()
|
const userSettings = useUserSettings()
|
||||||
|
@ -26,33 +27,51 @@ const userSettings = useUserSettings()
|
||||||
</template>
|
</template>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-if="!getPreferences(userSettings, 'hideFollowerCount')"
|
v-if="!(isHoverCard && getPreferences(userSettings, 'hideFollowerCount'))"
|
||||||
:to="getAccountFollowingRoute(account)"
|
:to="getAccountFollowingRoute(account)"
|
||||||
replace
|
replace
|
||||||
text-secondary exact-active-class="text-primary"
|
text-secondary exact-active-class="text-primary"
|
||||||
>
|
>
|
||||||
<template #default="{ isExactActive }">
|
<template #default="{ isExactActive }">
|
||||||
<CommonLocalizedNumber
|
<template
|
||||||
keypath="account.following_count"
|
v-if="!getPreferences(userSettings, 'hideFollowerCount')"
|
||||||
:count="account.followingCount"
|
>
|
||||||
font-bold
|
<CommonLocalizedNumber
|
||||||
:class="isExactActive ? 'text-primary' : 'text-base'"
|
v-if="account.followingCount >= 0"
|
||||||
/>
|
keypath="account.following_count"
|
||||||
|
:count="account.followingCount"
|
||||||
|
font-bold
|
||||||
|
:class="isExactActive ? 'text-primary' : 'text-base'"
|
||||||
|
/>
|
||||||
|
<div v-else flex gap-x-1>
|
||||||
|
<span font-bold text-base>Hidden</span>
|
||||||
|
<span>{{ $t('account.following') }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<span v-else>{{ $t('account.following') }}</span>
|
||||||
</template>
|
</template>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-if="!getPreferences(userSettings, 'hideFollowerCount')"
|
v-if="!(isHoverCard && getPreferences(userSettings, 'hideFollowerCount'))"
|
||||||
:to="getAccountFollowersRoute(account)"
|
:to="getAccountFollowersRoute(account)"
|
||||||
replace text-secondary
|
replace text-secondary
|
||||||
exact-active-class="text-primary"
|
exact-active-class="text-primary"
|
||||||
>
|
>
|
||||||
<template #default="{ isExactActive }">
|
<template #default="{ isExactActive }">
|
||||||
<CommonLocalizedNumber
|
<template v-if="!getPreferences(userSettings, 'hideFollowerCount')">
|
||||||
keypath="account.followers_count"
|
<CommonLocalizedNumber
|
||||||
:count="account.followersCount"
|
v-if="account.followersCount >= 0"
|
||||||
font-bold
|
keypath="account.followers_count"
|
||||||
:class="isExactActive ? 'text-primary' : 'text-base'"
|
:count="account.followersCount"
|
||||||
/>
|
font-bold
|
||||||
|
:class="isExactActive ? 'text-primary' : 'text-base'"
|
||||||
|
/>
|
||||||
|
<div v-else flex gap-x-1>
|
||||||
|
<span font-bold text-base>Hidden</span>
|
||||||
|
<span>{{ $t('account.followers') }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<span v-else>{{ $t('account.followers') }}</span>
|
||||||
</template>
|
</template>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
31
components/account/AccountRolesIndicator.vue
Normal file
31
components/account/AccountRolesIndicator.vue
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { mastodon } from 'masto'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
account: mastodon.v1.Account
|
||||||
|
limit?: number
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
flex="~ gap1" items-center
|
||||||
|
class="border border-base rounded-md px-1"
|
||||||
|
text-secondary-light
|
||||||
|
>
|
||||||
|
<slot name="prepend" />
|
||||||
|
<div v-for="role in account.roles?.slice(0, limit)" :key="role.id" flex>
|
||||||
|
<div :style="`color: ${role.color}; border-color: ${role.color}`">
|
||||||
|
{{ role.name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="limit && account.roles?.length > limit"
|
||||||
|
flex="~ gap1" items-center
|
||||||
|
class="border border-base rounded-md px-1"
|
||||||
|
text-secondary-light
|
||||||
|
>
|
||||||
|
+{{ account.roles?.length - limit }}
|
||||||
|
</div>
|
||||||
|
</template>
|
|
@ -1,18 +1,18 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { CommonRouteTabOption } from '../common/CommonRouteTabs.vue'
|
import type { CommonRouteTabOption } from '~/types'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
const server = $(computedEager(() => route.params.server as string))
|
const server = computed(() => route.params.server as string)
|
||||||
const account = $(computedEager(() => route.params.account as string))
|
const account = computed(() => route.params.account as string)
|
||||||
|
|
||||||
const tabs = $computed<CommonRouteTabOption[]>(() => [
|
const tabs = computed<CommonRouteTabOption[]>(() => [
|
||||||
{
|
{
|
||||||
name: 'account-index',
|
name: 'account-index',
|
||||||
to: {
|
to: {
|
||||||
name: 'account-index',
|
name: 'account-index',
|
||||||
params: { server, account },
|
params: { server: server.value, account: account.value },
|
||||||
},
|
},
|
||||||
display: t('tab.posts'),
|
display: t('tab.posts'),
|
||||||
icon: 'i-ri:file-list-2-line',
|
icon: 'i-ri:file-list-2-line',
|
||||||
|
@ -21,7 +21,7 @@ const tabs = $computed<CommonRouteTabOption[]>(() => [
|
||||||
name: 'account-replies',
|
name: 'account-replies',
|
||||||
to: {
|
to: {
|
||||||
name: 'account-replies',
|
name: 'account-replies',
|
||||||
params: { server, account },
|
params: { server: server.value, account: account.value },
|
||||||
},
|
},
|
||||||
display: t('tab.posts_with_replies'),
|
display: t('tab.posts_with_replies'),
|
||||||
icon: 'i-ri:chat-1-line',
|
icon: 'i-ri:chat-1-line',
|
||||||
|
@ -30,7 +30,7 @@ const tabs = $computed<CommonRouteTabOption[]>(() => [
|
||||||
name: 'account-media',
|
name: 'account-media',
|
||||||
to: {
|
to: {
|
||||||
name: 'account-media',
|
name: 'account-media',
|
||||||
params: { server, account },
|
params: { server: server.value, account: account.value },
|
||||||
},
|
},
|
||||||
display: t('tab.media'),
|
display: t('tab.media'),
|
||||||
icon: 'i-ri:camera-2-line',
|
icon: 'i-ri:camera-2-line',
|
||||||
|
|
45
components/account/TagHoverWrapper.vue
Normal file
45
components/account/TagHoverWrapper.vue
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { mastodon } from 'masto'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { tagName, disabled } = defineProps<{
|
||||||
|
tagName?: string
|
||||||
|
disabled?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const tag = ref<mastodon.v1.Tag>()
|
||||||
|
const tagHover = ref()
|
||||||
|
const hovered = useElementHover(tagHover)
|
||||||
|
|
||||||
|
watch(hovered, (newHovered) => {
|
||||||
|
if (newHovered && tagName) {
|
||||||
|
fetchTag(tagName).then((t) => {
|
||||||
|
tag.value = t
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const userSettings = useUserSettings()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span ref="tagHover">
|
||||||
|
<VMenu
|
||||||
|
v-if="!disabled && !getPreferences(userSettings, 'hideTagHoverCard')"
|
||||||
|
placement="bottom-start"
|
||||||
|
:delay="{ show: 500, hide: 100 }"
|
||||||
|
v-bind="$attrs"
|
||||||
|
:close-on-content-click="false"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
<template #popper>
|
||||||
|
<TagCardSkeleton v-if="!tag" />
|
||||||
|
<TagCard v-else :tag="tag" />
|
||||||
|
</template>
|
||||||
|
</VMenu>
|
||||||
|
<slot v-else />
|
||||||
|
</span>
|
||||||
|
</template>
|
|
@ -1,6 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { LocaleObject } from '@nuxtjs/i18n'
|
||||||
import type { AriaAnnounceType, AriaLive } from '~/composables/aria'
|
import type { AriaAnnounceType, AriaLive } from '~/composables/aria'
|
||||||
import type { LocaleObject } from '#i18n'
|
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { t, locale, locales } = useI18n()
|
const { t, locale, locales } = useI18n()
|
||||||
|
@ -11,16 +11,16 @@ const localeMap = (locales.value as LocaleObject[]).reduce((acc, l) => {
|
||||||
return acc
|
return acc
|
||||||
}, {} as Record<string, string>)
|
}, {} as Record<string, string>)
|
||||||
|
|
||||||
let ariaLive = $ref<AriaLive>('polite')
|
const ariaLive = ref<AriaLive>('polite')
|
||||||
let ariaMessage = $ref<string>('')
|
const ariaMessage = ref<string>('')
|
||||||
|
|
||||||
const onMessage = (event: AriaAnnounceType, message?: string) => {
|
function onMessage(event: AriaAnnounceType, message?: string) {
|
||||||
if (event === 'announce')
|
if (event === 'announce')
|
||||||
ariaMessage = message!
|
ariaMessage.value = message!
|
||||||
else if (event === 'mute')
|
else if (event === 'mute')
|
||||||
ariaLive = 'off'
|
ariaLive.value = 'off'
|
||||||
else
|
else
|
||||||
ariaLive = 'polite'
|
ariaLive.value = 'polite'
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(locale, (l, ol) => {
|
watch(locale, (l, ol) => {
|
||||||
|
|
|
@ -1,19 +1,19 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { ResolvedCommand } from '~/composables/command'
|
import type { ResolvedCommand } from '~/composables/command'
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(event: 'activate'): void
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
cmd,
|
cmd,
|
||||||
index,
|
index,
|
||||||
active = false,
|
active = false,
|
||||||
} = $defineProps<{
|
} = defineProps<{
|
||||||
cmd: ResolvedCommand
|
cmd: ResolvedCommand
|
||||||
index: number
|
index: number
|
||||||
active?: boolean
|
active?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'activate'): void
|
||||||
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
|
@ -5,7 +5,7 @@ const props = defineProps<{
|
||||||
|
|
||||||
const isMac = useIsMac()
|
const isMac = useIsMac()
|
||||||
|
|
||||||
const keys = $computed(() => props.name.toLowerCase().split('+'))
|
const keys = computed(() => props.name.toLowerCase().split('+'))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
|
@ -10,31 +10,33 @@ const registry = useCommandRegistry()
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const inputEl = $ref<HTMLInputElement>()
|
const inputEl = ref<HTMLInputElement>()
|
||||||
const resultEl = $ref<HTMLDivElement>()
|
const resultEl = ref<HTMLDivElement>()
|
||||||
|
|
||||||
const scopes = $ref<CommandScope[]>([])
|
const scopes = ref<CommandScope[]>([])
|
||||||
let input = $(commandPanelInput)
|
const input = commandPanelInput
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
inputEl?.focus()
|
inputEl.value?.focus()
|
||||||
})
|
})
|
||||||
|
|
||||||
const commandMode = $computed(() => input.startsWith('>'))
|
const commandMode = computed(() => input.value.startsWith('>'))
|
||||||
|
|
||||||
const query = $computed(() => commandMode ? '' : input.trim())
|
const query = computed(() => commandMode.value ? '' : input.value.trim())
|
||||||
|
|
||||||
const { accounts, hashtags, loading } = useSearch($$(query))
|
const { accounts, hashtags, loading } = useSearch(query)
|
||||||
|
|
||||||
const toSearchQueryResultItem = (search: SearchResultType): QueryResultItem => ({
|
function toSearchQueryResultItem(search: SearchResultType): QueryResultItem {
|
||||||
index: 0,
|
return {
|
||||||
type: 'search',
|
index: 0,
|
||||||
search,
|
type: 'search',
|
||||||
onActivate: () => router.push(search.to),
|
search,
|
||||||
})
|
onActivate: () => router.push(search.to),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const searchResult = $computed<QueryResult>(() => {
|
const searchResult = computed<QueryResult>(() => {
|
||||||
if (query.length === 0 || loading.value)
|
if (query.value.length === 0 || loading.value)
|
||||||
return { length: 0, items: [], grouped: {} as any }
|
return { length: 0, items: [], grouped: {} as any }
|
||||||
|
|
||||||
// TODO extract this scope
|
// TODO extract this scope
|
||||||
|
@ -59,55 +61,56 @@ const searchResult = $computed<QueryResult>(() => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = $computed<QueryResult>(() => commandMode
|
const result = computed<QueryResult>(() => commandMode.value
|
||||||
? registry.query(scopes.map(s => s.id).join('.'), input.slice(1).trim())
|
? registry.query(scopes.value.map(s => s.id).join('.'), input.value.slice(1).trim())
|
||||||
: searchResult,
|
: searchResult.value,
|
||||||
)
|
)
|
||||||
|
|
||||||
const isMac = useIsMac()
|
const isMac = useIsMac()
|
||||||
const modifierKeyName = $computed(() => isMac.value ? '⌘' : 'Ctrl')
|
const modifierKeyName = computed(() => isMac.value ? '⌘' : 'Ctrl')
|
||||||
|
|
||||||
let active = $ref(0)
|
const active = ref(0)
|
||||||
watch($$(result), (n, o) => {
|
watch(result, (n, o) => {
|
||||||
if (n.length !== o.length || !n.items.every((i, idx) => i === o.items[idx]))
|
if (n.length !== o.length || !n.items.every((i, idx) => i === o.items[idx]))
|
||||||
active = 0
|
active.value = 0
|
||||||
})
|
})
|
||||||
|
|
||||||
const findItemEl = (index: number) =>
|
function findItemEl(index: number) {
|
||||||
resultEl?.querySelector(`[data-index="${index}"]`) as HTMLDivElement | null
|
return resultEl.value?.querySelector(`[data-index="${index}"]`) as HTMLDivElement | null
|
||||||
const onCommandActivate = (item: QueryResultItem) => {
|
}
|
||||||
|
function onCommandActivate(item: QueryResultItem) {
|
||||||
if (item.onActivate) {
|
if (item.onActivate) {
|
||||||
item.onActivate()
|
item.onActivate()
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
else if (item.onComplete) {
|
else if (item.onComplete) {
|
||||||
scopes.push(item.onComplete())
|
scopes.value.push(item.onComplete())
|
||||||
input = '> '
|
input.value = '> '
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const onCommandComplete = (item: QueryResultItem) => {
|
function onCommandComplete(item: QueryResultItem) {
|
||||||
if (item.onComplete) {
|
if (item.onComplete) {
|
||||||
scopes.push(item.onComplete())
|
scopes.value.push(item.onComplete())
|
||||||
input = '> '
|
input.value = '> '
|
||||||
}
|
}
|
||||||
else if (item.onActivate) {
|
else if (item.onActivate) {
|
||||||
item.onActivate()
|
item.onActivate()
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const intoView = (index: number) => {
|
function intoView(index: number) {
|
||||||
const el = findItemEl(index)
|
const el = findItemEl(index)
|
||||||
if (el)
|
if (el)
|
||||||
el.scrollIntoView({ block: 'nearest' })
|
el.scrollIntoView({ block: 'nearest' })
|
||||||
}
|
}
|
||||||
|
|
||||||
function setActive(index: number) {
|
function setActive(index: number) {
|
||||||
const len = result.length
|
const len = result.value.length
|
||||||
active = (index + len) % len
|
active.value = (index + len) % len
|
||||||
intoView(active)
|
intoView(active.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onKeyDown = (e: KeyboardEvent) => {
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case 'p':
|
case 'p':
|
||||||
case 'ArrowUp': {
|
case 'ArrowUp': {
|
||||||
|
@ -115,7 +118,7 @@ const onKeyDown = (e: KeyboardEvent) => {
|
||||||
break
|
break
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
setActive(active - 1)
|
setActive(active.value - 1)
|
||||||
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
@ -125,7 +128,7 @@ const onKeyDown = (e: KeyboardEvent) => {
|
||||||
break
|
break
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
setActive(active + 1)
|
setActive(active.value + 1)
|
||||||
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
@ -133,9 +136,9 @@ const onKeyDown = (e: KeyboardEvent) => {
|
||||||
case 'Home': {
|
case 'Home': {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
active = 0
|
active.value = 0
|
||||||
|
|
||||||
intoView(active)
|
intoView(active.value)
|
||||||
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
@ -143,7 +146,7 @@ const onKeyDown = (e: KeyboardEvent) => {
|
||||||
case 'End': {
|
case 'End': {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
setActive(result.length - 1)
|
setActive(result.value.length - 1)
|
||||||
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
@ -151,7 +154,7 @@ const onKeyDown = (e: KeyboardEvent) => {
|
||||||
case 'Enter': {
|
case 'Enter': {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
const cmd = result.items[active]
|
const cmd = result.value.items[active.value]
|
||||||
if (cmd)
|
if (cmd)
|
||||||
onCommandActivate(cmd)
|
onCommandActivate(cmd)
|
||||||
|
|
||||||
|
@ -161,7 +164,7 @@ const onKeyDown = (e: KeyboardEvent) => {
|
||||||
case 'Tab': {
|
case 'Tab': {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
const cmd = result.items[active]
|
const cmd = result.value.items[active.value]
|
||||||
if (cmd)
|
if (cmd)
|
||||||
onCommandComplete(cmd)
|
onCommandComplete(cmd)
|
||||||
|
|
||||||
|
@ -169,9 +172,9 @@ const onKeyDown = (e: KeyboardEvent) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'Backspace': {
|
case 'Backspace': {
|
||||||
if (input === '>' && scopes.length) {
|
if (input.value === '>' && scopes.value.length) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
scopes.pop()
|
scopes.value.pop()
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,9 +2,7 @@
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(event: 'close'): void
|
(event: 'close'): void
|
||||||
}>()
|
}>()
|
||||||
const { modelValue: visible } = defineModel<{
|
const visible = defineModel<boolean>()
|
||||||
modelValue?: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
emit('close')
|
emit('close')
|
||||||
|
|
|
@ -1,54 +1,16 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { decode } from 'blurhash'
|
|
||||||
|
|
||||||
const { blurhash, src, srcset, shouldLoadImage = true } = defineProps<{
|
|
||||||
blurhash?: string | null | undefined
|
|
||||||
src: string
|
|
||||||
srcset?: string
|
|
||||||
shouldLoadImage?: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
inheritAttrs: false,
|
inheritAttrs: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
const isLoaded = ref(false)
|
const { blurhash = '', src, srcset, shouldLoadImage = true } = defineProps<{
|
||||||
const placeholderSrc = $computed(() => {
|
blurhash?: string
|
||||||
if (!blurhash)
|
src: string
|
||||||
return ''
|
srcset?: string
|
||||||
const pixels = decode(blurhash, 32, 32)
|
shouldLoadImage?: boolean
|
||||||
return getDataUrlFromArr(pixels, 32, 32)
|
}>()
|
||||||
})
|
|
||||||
|
|
||||||
function loadImage() {
|
|
||||||
const img = document.createElement('img')
|
|
||||||
|
|
||||||
img.onload = () => {
|
|
||||||
isLoaded.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
img.src = src
|
|
||||||
|
|
||||||
if (srcset)
|
|
||||||
img.srcset = srcset
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
isLoaded.value = true
|
|
||||||
}, 3_000)
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (shouldLoadImage)
|
|
||||||
loadImage()
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(() => shouldLoadImage, () => {
|
|
||||||
if (shouldLoadImage)
|
|
||||||
loadImage()
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<img v-if="isLoaded || !placeholderSrc" v-bind="$attrs" :src="src" :srcset="srcset">
|
<UnLazyImage v-bind="$attrs" :blurhash="blurhash" :src="src" :src-set="srcset" :lazy-load="shouldLoadImage" auto-sizes />
|
||||||
<img v-else v-bind="$attrs" :src="placeholderSrc">
|
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -1,22 +1,28 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
defineProps<{
|
defineProps<{
|
||||||
label: string
|
label?: string
|
||||||
hover?: boolean
|
hover?: boolean
|
||||||
|
iconChecked?: string
|
||||||
|
iconUnchecked?: string
|
||||||
|
checkedIconColor?: string
|
||||||
|
prependCheckbox?: boolean
|
||||||
}>()
|
}>()
|
||||||
const { modelValue } = defineModel<{
|
const modelValue = defineModel<boolean | null>()
|
||||||
modelValue?: boolean
|
|
||||||
}>()
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<label
|
<label
|
||||||
class="common-checkbox flex items-center cursor-pointer py-1 text-md w-full gap-y-1"
|
class="common-checkbox flex items-center cursor-pointer py-1 text-md w-full gap-y-1"
|
||||||
:class="hover ? 'hover:bg-active ms--2 px-4 py-2' : null"
|
:class="hover ? 'hover:bg-active ms--2 px-4 py-2' : null"
|
||||||
|
v-bind="$attrs"
|
||||||
@click.prevent="modelValue = !modelValue"
|
@click.prevent="modelValue = !modelValue"
|
||||||
>
|
>
|
||||||
<span flex-1 ms-2 pointer-events-none>{{ label }}</span>
|
<span v-if="label && !prependCheckbox" flex-1 ms-2 pointer-events-none>{{ label }}</span>
|
||||||
<span
|
<span
|
||||||
:class="modelValue ? 'i-ri:checkbox-line' : 'i-ri:checkbox-blank-line'"
|
:class="[
|
||||||
|
modelValue ? (iconChecked ?? 'i-ri:checkbox-line') : (iconUnchecked ?? 'i-ri:checkbox-blank-line'),
|
||||||
|
modelValue && checkedIconColor,
|
||||||
|
]"
|
||||||
text-lg
|
text-lg
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
|
@ -25,6 +31,7 @@ const { modelValue } = defineModel<{
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
sr-only
|
sr-only
|
||||||
>
|
>
|
||||||
|
<span v-if="label && prependCheckbox" flex-1 ms-2 pointer-events-none>{{ label }}</span>
|
||||||
</label>
|
</label>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
@ -14,10 +14,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||||
stencilSizePercentage: 0.9,
|
stencilSizePercentage: 0.9,
|
||||||
})
|
})
|
||||||
|
|
||||||
const { modelValue: file } = defineModel<{
|
const file = defineModel<File | null>()
|
||||||
/** Images to be cropped */
|
|
||||||
modelValue: File | null
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const cropperDialog = ref(false)
|
const cropperDialog = ref(false)
|
||||||
|
|
||||||
|
@ -30,7 +27,7 @@ const cropperImage = reactive({
|
||||||
type: 'image/jpg',
|
type: 'image/jpg',
|
||||||
})
|
})
|
||||||
|
|
||||||
const stencilSize = ({ boundaries }: { boundaries: Boundaries }) => {
|
function stencilSize({ boundaries }: { boundaries: Boundaries }) {
|
||||||
return {
|
return {
|
||||||
width: boundaries.width * props.stencilSizePercentage,
|
width: boundaries.width * props.stencilSizePercentage,
|
||||||
height: boundaries.height * props.stencilSizePercentage,
|
height: boundaries.height * props.stencilSizePercentage,
|
||||||
|
@ -55,7 +52,7 @@ watch(file, (file, _, onCleanup) => {
|
||||||
cropperFlag.value = false
|
cropperFlag.value = false
|
||||||
})
|
})
|
||||||
|
|
||||||
const cropImage = () => {
|
function cropImage() {
|
||||||
if (cropper.value && file.value) {
|
if (cropper.value && file.value) {
|
||||||
cropperFlag.value = true
|
cropperFlag.value = true
|
||||||
cropperDialog.value = false
|
cropperDialog.value = false
|
||||||
|
|
|
@ -22,9 +22,7 @@ const emit = defineEmits<{
|
||||||
(event: 'error', code: number, message: string): void
|
(event: 'error', code: number, message: string): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { modelValue: file } = defineModel<{
|
const file = defineModel<FileWithHandle | null>()
|
||||||
modelValue: FileWithHandle | null
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
@ -34,8 +32,8 @@ const previewImage = ref('')
|
||||||
/** The current images on display */
|
/** The current images on display */
|
||||||
const imageSrc = computed<string>(() => previewImage.value || defaultImage.value)
|
const imageSrc = computed<string>(() => previewImage.value || defaultImage.value)
|
||||||
|
|
||||||
const pickImage = async () => {
|
async function pickImage() {
|
||||||
if (process.server)
|
if (import.meta.server)
|
||||||
return
|
return
|
||||||
const image = await fileOpen({
|
const image = await fileOpen({
|
||||||
description: 'Image',
|
description: 'Image',
|
||||||
|
|
|
@ -2,50 +2,51 @@
|
||||||
// @ts-expect-error missing types
|
// @ts-expect-error missing types
|
||||||
import { DynamicScroller } from 'vue-virtual-scroller'
|
import { DynamicScroller } from 'vue-virtual-scroller'
|
||||||
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
|
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
|
||||||
import type { Paginator, WsEvents } from 'masto'
|
import type { mastodon } from 'masto'
|
||||||
|
import type { UnwrapRef } from 'vue'
|
||||||
|
|
||||||
const {
|
const {
|
||||||
paginator,
|
paginator,
|
||||||
stream,
|
stream,
|
||||||
|
eventType,
|
||||||
keyProp = 'id',
|
keyProp = 'id',
|
||||||
virtualScroller = false,
|
virtualScroller = false,
|
||||||
eventType = 'update',
|
|
||||||
preprocess,
|
preprocess,
|
||||||
noEndMessage = false,
|
endMessage = true,
|
||||||
} = defineProps<{
|
} = defineProps<{
|
||||||
paginator: Paginator<T[], O>
|
paginator: mastodon.Paginator<T[], O>
|
||||||
keyProp?: keyof T
|
keyProp?: keyof T
|
||||||
virtualScroller?: boolean
|
virtualScroller?: boolean
|
||||||
stream?: Promise<WsEvents>
|
stream?: mastodon.streaming.Subscription
|
||||||
eventType?: 'notification' | 'update'
|
eventType?: 'update' | 'notification'
|
||||||
preprocess?: (items: (U | T)[]) => U[]
|
preprocess?: (items: (U | T)[]) => U[]
|
||||||
noEndMessage?: boolean
|
endMessage?: boolean | string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
defineSlots<{
|
defineSlots<{
|
||||||
default: {
|
default: (props: {
|
||||||
items: U[]
|
items: U[]
|
||||||
item: U
|
item: U
|
||||||
index: number
|
index: number
|
||||||
active?: boolean
|
active?: boolean
|
||||||
older?: U
|
older: U
|
||||||
newer?: U // newer is undefined when index === 0
|
newer: U // newer is undefined when index === 0
|
||||||
}
|
}) => void
|
||||||
items: {
|
items: (props: {
|
||||||
items: U[]
|
items: UnwrapRef<U[]>
|
||||||
}
|
}) => void
|
||||||
updater: {
|
updater: (props: {
|
||||||
number: number
|
number: number
|
||||||
update: () => void
|
update: () => void
|
||||||
}
|
}) => void
|
||||||
loading: {}
|
loading: (props: object) => void
|
||||||
done: {}
|
done: (props: { items: U[] }) => void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const nuxtApp = useNuxtApp()
|
const nuxtApp = useNuxtApp()
|
||||||
|
|
||||||
const { items, prevItems, update, state, endAnchor, error } = usePaginator(paginator, $$(stream), eventType, preprocess)
|
const { items, prevItems, update, state, endAnchor, error } = usePaginator(paginator, toRef(() => stream), eventType, preprocess)
|
||||||
|
|
||||||
nuxtApp.hook('elk-logo:click', () => {
|
nuxtApp.hook('elk-logo:click', () => {
|
||||||
update()
|
update()
|
||||||
|
@ -83,25 +84,25 @@ defineExpose({ createEntry, removeEntry, updateEntry })
|
||||||
page-mode
|
page-mode
|
||||||
>
|
>
|
||||||
<slot
|
<slot
|
||||||
:key="item[keyProp]"
|
v-bind="{ key: item[keyProp] }"
|
||||||
:item="item"
|
:item="item"
|
||||||
:active="active"
|
:active="active"
|
||||||
:older="items[index + 1]"
|
:older="items[index + 1] as U"
|
||||||
:newer="items[index - 1]"
|
:newer="items[index - 1] as U"
|
||||||
:index="index"
|
:index="index"
|
||||||
:items="items"
|
:items="items as U[]"
|
||||||
/>
|
/>
|
||||||
</DynamicScroller>
|
</DynamicScroller>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<slot
|
<slot
|
||||||
v-for="item, index of items"
|
v-for="(item, index) of items"
|
||||||
:key="(item as any)[keyProp]"
|
v-bind="{ key: (item as U)[keyProp as keyof U] }"
|
||||||
:item="item"
|
:item="item as U"
|
||||||
:older="items[index + 1]"
|
:older="items[index + 1] as U"
|
||||||
:newer="items[index - 1]"
|
:newer="items[index - 1] as U"
|
||||||
:index="index"
|
:index="index"
|
||||||
:items="items"
|
:items="items as U[]"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</slot>
|
</slot>
|
||||||
|
@ -109,9 +110,9 @@ defineExpose({ createEntry, removeEntry, updateEntry })
|
||||||
<slot v-if="state === 'loading'" name="loading">
|
<slot v-if="state === 'loading'" name="loading">
|
||||||
<TimelineSkeleton />
|
<TimelineSkeleton />
|
||||||
</slot>
|
</slot>
|
||||||
<slot v-else-if="state === 'done' && !noEndMessage" name="done">
|
<slot v-else-if="state === 'done' && endMessage !== false" name="done" :items="items as U[]">
|
||||||
<div p5 text-secondary italic text-center>
|
<div p5 text-secondary italic text-center>
|
||||||
{{ t('common.end_of_list') }}
|
{{ t(typeof endMessage === 'string' && items.length <= 0 ? endMessage : 'common.end_of_list') }}
|
||||||
</div>
|
</div>
|
||||||
</slot>
|
</slot>
|
||||||
<div v-else-if="state === 'error'" p5 text-secondary>
|
<div v-else-if="state === 'error'" p5 text-secondary>
|
||||||
|
|
|
@ -4,9 +4,7 @@ defineProps<{
|
||||||
value: any
|
value: any
|
||||||
hover?: boolean
|
hover?: boolean
|
||||||
}>()
|
}>()
|
||||||
const { modelValue } = defineModel<{
|
const modelValue = defineModel()
|
||||||
modelValue: any
|
|
||||||
}>()
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
|
@ -1,26 +1,20 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { RouteLocationRaw } from 'vue-router'
|
import type { CommonRouteTabMoreOption, CommonRouteTabOption } from '~/types'
|
||||||
|
|
||||||
export interface CommonRouteTabOption {
|
const { options, command, replace, preventScrollTop = false, moreOptions } = defineProps<{
|
||||||
to: RouteLocationRaw
|
|
||||||
display: string
|
|
||||||
disabled?: boolean
|
|
||||||
name?: string
|
|
||||||
icon?: string
|
|
||||||
}
|
|
||||||
const { options, command, replace, preventScrollTop = false } = $defineProps<{
|
|
||||||
options: CommonRouteTabOption[]
|
options: CommonRouteTabOption[]
|
||||||
|
moreOptions?: CommonRouteTabMoreOption
|
||||||
command?: boolean
|
command?: boolean
|
||||||
replace?: boolean
|
replace?: boolean
|
||||||
preventScrollTop?: boolean
|
preventScrollTop?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
useCommands(() => command
|
useCommands(() => command
|
||||||
? options.map(tab => ({
|
? options.map(tab => ({
|
||||||
scope: 'Tabs',
|
scope: 'Tabs',
|
||||||
|
|
||||||
name: tab.display,
|
name: tab.display,
|
||||||
icon: tab.icon ?? 'i-ri:file-list-2-line',
|
icon: tab.icon ?? 'i-ri:file-list-2-line',
|
||||||
onActivate: () => router.replace(tab.to),
|
onActivate: () => router.replace(tab.to),
|
||||||
|
@ -31,7 +25,7 @@ useCommands(() => command
|
||||||
<template>
|
<template>
|
||||||
<div flex w-full items-center lg:text-lg of-x-auto scrollbar-hide border="b base">
|
<div flex w-full items-center lg:text-lg of-x-auto scrollbar-hide border="b base">
|
||||||
<template
|
<template
|
||||||
v-for="(option, index) in options"
|
v-for="(option, index) in options.filter(item => !item.hide)"
|
||||||
:key="option?.name || index"
|
:key="option?.name || index"
|
||||||
>
|
>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
|
@ -39,7 +33,7 @@ useCommands(() => command
|
||||||
:to="option.to"
|
:to="option.to"
|
||||||
:replace="replace"
|
:replace="replace"
|
||||||
relative flex flex-auto cursor-pointer sm:px6 px2 rounded transition-all
|
relative flex flex-auto cursor-pointer sm:px6 px2 rounded transition-all
|
||||||
tabindex="1"
|
tabindex="0"
|
||||||
hover:bg-active transition-100
|
hover:bg-active transition-100
|
||||||
exact-active-class="children:(text-secondary !border-primary !op100 !text-base)"
|
exact-active-class="children:(text-secondary !border-primary !op100 !text-base)"
|
||||||
@click="!preventScrollTop && $scrollToTop()"
|
@click="!preventScrollTop && $scrollToTop()"
|
||||||
|
@ -50,5 +44,43 @@ useCommands(() => command
|
||||||
<span ws-nowrap mxa sm:px2 sm:py3 py2 text-center text-secondary-light op50>{{ option.display }}</span>
|
<span ws-nowrap mxa sm:px2 sm:py3 py2 text-center text-secondary-light op50>{{ option.display }}</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-if="isHydrated && moreOptions?.options?.length">
|
||||||
|
<CommonDropdown placement="bottom" flex cursor-pointer mx-1.25rem>
|
||||||
|
<CommonTooltip placement="top" no-auto-focus :content="moreOptions.tooltip || t('action.more')">
|
||||||
|
<button
|
||||||
|
cursor-pointer
|
||||||
|
flex
|
||||||
|
gap-1
|
||||||
|
w-12
|
||||||
|
rounded
|
||||||
|
hover:bg-active
|
||||||
|
btn-action-icon
|
||||||
|
op75
|
||||||
|
px4
|
||||||
|
group
|
||||||
|
:aria-label="t('action.more')"
|
||||||
|
:class="moreOptions.match ? 'text-primary' : 'text-secondary'"
|
||||||
|
>
|
||||||
|
<span v-if="moreOptions.icon" :class="moreOptions.icon" text-sm me--1 block />
|
||||||
|
<span i-ri:arrow-down-s-line text-sm me--1 block />
|
||||||
|
</button>
|
||||||
|
</CommonTooltip>
|
||||||
|
<template #popper>
|
||||||
|
<NuxtLink
|
||||||
|
v-for="(option, index) in moreOptions.options.filter(item => !item.hide)"
|
||||||
|
:key="option?.name || index"
|
||||||
|
:to="option.to"
|
||||||
|
>
|
||||||
|
<CommonDropdownItem>
|
||||||
|
<span flex="~ row" gap-x-4 items-center :class="option.match ? 'text-primary' : ''">
|
||||||
|
<span v-if="option.icon" :class="[option.icon, option.match ? 'text-primary' : 'text.secondary']" text-md me--1 block />
|
||||||
|
<span v-else block> </span>
|
||||||
|
<span>{{ option.display }}</span>
|
||||||
|
</span>
|
||||||
|
</CommonDropdownItem>
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
|
</commondropdown>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -1,5 +1,9 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const { as = 'div', active } = defineProps<{ as: any; active: boolean }>()
|
const { as = 'div', active } = defineProps<{
|
||||||
|
as: any
|
||||||
|
active: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
const el = ref()
|
const el = ref()
|
||||||
|
|
||||||
watch(() => active, (active) => {
|
watch(() => active, (active) => {
|
||||||
|
|
|
@ -8,11 +8,9 @@ const { options, command } = defineProps<{
|
||||||
command?: boolean
|
command?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { modelValue } = defineModel<{
|
const modelValue = defineModel<string>({ required: true })
|
||||||
modelValue: string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const tabs = $computed(() => {
|
const tabs = computed(() => {
|
||||||
return options.map((option) => {
|
return options.map((option) => {
|
||||||
if (typeof option === 'string')
|
if (typeof option === 'string')
|
||||||
return { name: option, display: option }
|
return { name: option, display: option }
|
||||||
|
@ -21,12 +19,12 @@ const tabs = $computed(() => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
function toValidName(otpion: string) {
|
function toValidName(option: string) {
|
||||||
return otpion.toLowerCase().replace(/[^a-zA-Z0-9]/g, '-')
|
return option.toLowerCase().replace(/[^a-zA-Z0-9]/g, '-')
|
||||||
}
|
}
|
||||||
|
|
||||||
useCommands(() => command
|
useCommands(() => command
|
||||||
? tabs.map(tab => ({
|
? tabs.value.map(tab => ({
|
||||||
scope: 'Tabs',
|
scope: 'Tabs',
|
||||||
|
|
||||||
name: tab.display,
|
name: tab.display,
|
||||||
|
@ -51,7 +49,7 @@ useCommands(() => command
|
||||||
><label
|
><label
|
||||||
flex flex-auto cursor-pointer px3 m1 rounded transition-all
|
flex flex-auto cursor-pointer px3 m1 rounded transition-all
|
||||||
:for="`tab-${toValidName(option.name)}`"
|
:for="`tab-${toValidName(option.name)}`"
|
||||||
tabindex="1"
|
tabindex="0"
|
||||||
hover:bg-active transition-100
|
hover:bg-active transition-100
|
||||||
@keypress.enter="modelValue = option.name"
|
@keypress.enter="modelValue = option.name"
|
||||||
><span
|
><span
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Popper as VTooltipType } from 'floating-vue/dist'
|
import type { Popper as VTooltipType } from 'floating-vue'
|
||||||
|
|
||||||
export interface Props extends Partial<typeof VTooltipType> {
|
export interface Props extends Partial<typeof VTooltipType> {
|
||||||
content?: string
|
content?: string
|
||||||
|
@ -10,6 +10,7 @@ defineProps<Props>()
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VTooltip
|
<VTooltip
|
||||||
|
v-if="isHydrated"
|
||||||
v-bind="$attrs"
|
v-bind="$attrs"
|
||||||
auto-hide
|
auto-hide
|
||||||
>
|
>
|
||||||
|
|
|
@ -4,20 +4,20 @@ import type { mastodon } from 'masto'
|
||||||
const {
|
const {
|
||||||
history,
|
history,
|
||||||
maxDay = 2,
|
maxDay = 2,
|
||||||
} = $defineProps<{
|
} = defineProps<{
|
||||||
history: mastodon.v1.TagHistory[]
|
history: mastodon.v1.TagHistory[]
|
||||||
maxDay?: number
|
maxDay?: number
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const ongoingHot = $computed(() => history.slice(0, maxDay))
|
const ongoingHot = computed(() => history.slice(0, maxDay))
|
||||||
|
|
||||||
const people = $computed(() =>
|
const people = computed(() =>
|
||||||
ongoingHot.reduce((total: number, item) => total + (Number(item.accounts) || 0), 0),
|
ongoingHot.value.reduce((total: number, item) => total + (Number(item.accounts) || 0), 0),
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<p>
|
<p>
|
||||||
{{ $t('command.n-people-in-the-past-n-days', [people, maxDay]) }}
|
{{ $t('command.n_people_in_the_past_n_days', [people, maxDay]) }}
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -6,22 +6,22 @@ const {
|
||||||
history,
|
history,
|
||||||
width = 60,
|
width = 60,
|
||||||
height = 40,
|
height = 40,
|
||||||
} = $defineProps<{
|
} = defineProps<{
|
||||||
history?: mastodon.v1.TagHistory[]
|
history?: mastodon.v1.TagHistory[]
|
||||||
width?: number
|
width?: number
|
||||||
height?: number
|
height?: number
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const historyNum = $computed(() => {
|
const historyNum = computed(() => {
|
||||||
if (!history)
|
if (!history)
|
||||||
return [1, 1, 1, 1, 1, 1, 1]
|
return [1, 1, 1, 1, 1, 1, 1]
|
||||||
return [...history].reverse().map(item => Number(item.accounts) || 0)
|
return [...history].reverse().map(item => Number(item.accounts) || 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
const sparklineEl = $ref<SVGSVGElement>()
|
const sparklineEl = ref<SVGSVGElement>()
|
||||||
const sparklineFn = typeof sparkline !== 'function' ? (sparkline as any).default : sparkline
|
const sparklineFn = typeof sparkline !== 'function' ? (sparkline as any).default : sparkline
|
||||||
|
|
||||||
watch([$$(historyNum), $$(sparklineEl)], ([historyNum, sparklineEl]) => {
|
watch([historyNum, sparklineEl], ([historyNum, sparklineEl]) => {
|
||||||
if (!sparklineEl)
|
if (!sparklineEl)
|
||||||
return
|
return
|
||||||
sparklineFn(sparklineEl, historyNum)
|
sparklineFn(sparklineEl, historyNum)
|
||||||
|
|
|
@ -1,18 +1,18 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
count: number
|
count: number
|
||||||
keypath: string
|
keypath: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
inheritAttrs: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const { formatHumanReadableNumber, formatNumber, forSR } = useHumanReadableNumber()
|
const { formatHumanReadableNumber, formatNumber, forSR } = useHumanReadableNumber()
|
||||||
|
|
||||||
const useSR = $computed(() => forSR(props.count))
|
const useSR = computed(() => forSR(props.count))
|
||||||
const rawNumber = $computed(() => formatNumber(props.count))
|
const rawNumber = computed(() => formatNumber(props.count))
|
||||||
const humanReadableNumber = $computed(() => formatHumanReadableNumber(props.count))
|
const humanReadableNumber = computed(() => formatHumanReadableNumber(props.count))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
|
@ -6,10 +6,12 @@ defineProps<{
|
||||||
autoBoundaryMaxSize?: boolean
|
autoBoundaryMaxSize?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const dropdown = $ref<any>()
|
const dropdown = ref<any>()
|
||||||
const colorMode = useColorMode()
|
const colorMode = useColorMode()
|
||||||
|
|
||||||
const hide = () => dropdown.hide()
|
function hide() {
|
||||||
|
return dropdown.value.hide()
|
||||||
|
}
|
||||||
provide(InjectionKeyDropdownContext, {
|
provide(InjectionKeyDropdownContext, {
|
||||||
hide,
|
hide,
|
||||||
})
|
})
|
||||||
|
|
|
@ -15,7 +15,7 @@ const { hide } = useDropdownContext() || {}
|
||||||
|
|
||||||
const el = ref<HTMLDivElement>()
|
const el = ref<HTMLDivElement>()
|
||||||
|
|
||||||
const handleClick = (evt: MouseEvent) => {
|
function handleClick(evt: MouseEvent) {
|
||||||
hide?.()
|
hide?.()
|
||||||
emit('click', evt)
|
emit('click', evt)
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,7 +4,7 @@ const props = defineProps<{
|
||||||
lang?: string
|
lang?: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const raw = $computed(() => decodeURIComponent(props.code).replace(/'/g, '\''))
|
const raw = computed(() => decodeURIComponent(props.code).replace(/'/g, '\''))
|
||||||
|
|
||||||
const langMap: Record<string, string> = {
|
const langMap: Record<string, string> = {
|
||||||
js: 'javascript',
|
js: 'javascript',
|
||||||
|
@ -13,7 +13,7 @@ const langMap: Record<string, string> = {
|
||||||
}
|
}
|
||||||
|
|
||||||
const highlighted = computed(() => {
|
const highlighted = computed(() => {
|
||||||
return props.lang ? highlightCode(raw, (langMap[props.lang] || props.lang) as any) : raw
|
return props.lang ? highlightCode(raw.value, (langMap[props.lang] || props.lang) as any) : raw
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,7 @@ const { conversation } = defineProps<{
|
||||||
conversation: mastodon.v1.Conversation
|
conversation: mastodon.v1.Conversation
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const withAccounts = $computed(() =>
|
const withAccounts = computed(() =>
|
||||||
conversation.accounts.filter(account => account.id !== conversation.lastStatus?.account.id),
|
conversation.accounts.filter(account => account.id !== conversation.lastStatus?.account.id),
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Paginator, mastodon } from 'masto'
|
import type { mastodon } from 'masto'
|
||||||
|
|
||||||
const { paginator } = defineProps<{
|
const { paginator } = defineProps<{
|
||||||
paginator: Paginator<mastodon.v1.Conversation[], mastodon.DefaultPaginationParams>
|
paginator: mastodon.Paginator<mastodon.v1.Conversation[], mastodon.DefaultPaginationParams>
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
function preprocess(items: mastodon.v1.Conversation[]): mastodon.v1.Conversation[] {
|
function preprocess(items: mastodon.v1.Conversation[]): mastodon.v1.Conversation[] {
|
||||||
|
|
30
components/emoji/Emoji.vue
Normal file
30
components/emoji/Emoji.vue
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { as, alt, dataEmojiId } = defineProps<{
|
||||||
|
as: string
|
||||||
|
alt?: string
|
||||||
|
dataEmojiId?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const title = ref<string | undefined>()
|
||||||
|
|
||||||
|
if (alt) {
|
||||||
|
if (alt.startsWith(':')) {
|
||||||
|
title.value = alt.replace(/:/g, '')
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
import('node-emoji').then(({ find }) => {
|
||||||
|
title.value = find(alt)?.key.replace(/_/g, ' ')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// if it has a data-emoji-id, use that as the title instead
|
||||||
|
if (dataEmojiId)
|
||||||
|
title.value = dataEmojiId
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<component :is="as" v-bind="$attrs" :alt="alt" :data-emoji-id="dataEmojiId" :title="title">
|
||||||
|
<slot />
|
||||||
|
</component>
|
||||||
|
</template>
|
|
@ -2,12 +2,14 @@
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(event: 'close'): void
|
(event: 'close'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const vAutoFocus = (el: HTMLElement) => el.focus()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div my-8 px-3 sm:px-8 md:max-w-200 flex="~ col gap-4" relative>
|
<div my-8 px-3 sm:px-8 md:max-w-200 flex="~ col gap-4" relative>
|
||||||
<button btn-action-icon absolute top--8 right-0 m1 aria-label="Close" @click="emit('close')">
|
<button v-auto-focus type="button" btn-action-icon absolute top--8 right-0 m1 aria-label="Close" @click="emit('close')">
|
||||||
<div i-ri:close-line />
|
<span i-ri:close-line />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<img :alt="$t('app_logo')" :src="`/${''}logo.svg`" w-20 h-20 height="80" width="80" mxa class="rtl-flip">
|
<img :alt="$t('app_logo')" :src="`/${''}logo.svg`" w-20 h-20 height="80" width="80" mxa class="rtl-flip">
|
||||||
|
@ -28,10 +30,12 @@ const emit = defineEmits<{
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
{{ $t('help.desc_para6') }}
|
{{ $t('help.desc_para6') }}
|
||||||
</p>
|
</p>
|
||||||
{{ $t('help.desc_para3') }}
|
<NuxtLink hover:text-primary href="https://github.com/sponsors/elk-zone" target="_blank">
|
||||||
<p flex="~ gap-2 wrap" mxa>
|
{{ $t('help.desc_para3') }}
|
||||||
|
</NuxtLink>
|
||||||
|
<p flex="~ gap-2 wrap justify-center" mxa>
|
||||||
<template v-for="team of elkTeamMembers" :key="team.github">
|
<template v-for="team of elkTeamMembers" :key="team.github">
|
||||||
<NuxtLink :href="`https://github.com/sponsors/${team.github}`" target="_blank" external rounded-full transition duration-300 border="~ transparent" hover="scale-105 border-primary">
|
<NuxtLink :href="team.link" target="_blank" external rounded-full transition duration-300 border="~ transparent" hover="scale-105 border-primary">
|
||||||
<img :src="`/avatars/${team.github}-100x100.png`" :alt="team.display" rounded-full w-15 h-15 height="60" width="60">
|
<img :src="`/avatars/${team.github}-100x100.png`" :alt="team.display" rounded-full w-15 h-15 height="60" width="60">
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</template>
|
</template>
|
||||||
|
@ -42,7 +46,7 @@ const emit = defineEmits<{
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<button btn-solid mxa tabindex="2" @click="emit('close')">
|
<button type="button" btn-solid mxa @click="emit('close')">
|
||||||
{{ $t('action.enter_app') }}
|
{{ $t('action.enter_app') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -16,8 +16,8 @@ const isRemoved = ref(false)
|
||||||
async function edit() {
|
async function edit() {
|
||||||
try {
|
try {
|
||||||
isRemoved.value
|
isRemoved.value
|
||||||
? await client.v1.lists.addAccount(list, { accountIds: [account.id] })
|
? await client.v1.lists.$select(list).accounts.create({ accountIds: [account.id] })
|
||||||
: await client.v1.lists.removeAccount(list, { accountIds: [account.id] })
|
: await client.v1.lists.$select(list).accounts.remove({ accountIds: [account.id] })
|
||||||
isRemoved.value = !isRemoved.value
|
isRemoved.value = !isRemoved.value
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
|
|
|
@ -6,9 +6,7 @@ const emit = defineEmits<{
|
||||||
(e: 'listUpdated', list: mastodon.v1.List): void
|
(e: 'listUpdated', list: mastodon.v1.List): void
|
||||||
(e: 'listRemoved', id: string): void
|
(e: 'listRemoved', id: string): void
|
||||||
}>()
|
}>()
|
||||||
const { list } = defineModel<{
|
const list = defineModel<mastodon.v1.List>({ required: true })
|
||||||
list: mastodon.v1.List
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const client = useMastoClient()
|
const client = useMastoClient()
|
||||||
|
@ -17,23 +15,23 @@ const { form, isDirty, submitter, reset } = useForm({
|
||||||
form: () => ({ ...list.value }),
|
form: () => ({ ...list.value }),
|
||||||
})
|
})
|
||||||
|
|
||||||
let isEditing = $ref<boolean>(false)
|
const isEditing = ref<boolean>(false)
|
||||||
let deleting = $ref<boolean>(false)
|
const deleting = ref<boolean>(false)
|
||||||
let actionError = $ref<string | undefined>(undefined)
|
const actionError = ref<string | undefined>(undefined)
|
||||||
|
|
||||||
const input = ref<HTMLInputElement>()
|
const input = ref<HTMLInputElement>()
|
||||||
const editBtn = ref<HTMLButtonElement>()
|
const editBtn = ref<HTMLButtonElement>()
|
||||||
const deleteBtn = ref<HTMLButtonElement>()
|
const deleteBtn = ref<HTMLButtonElement>()
|
||||||
|
|
||||||
const prepareEdit = async () => {
|
async function prepareEdit() {
|
||||||
isEditing = true
|
isEditing.value = true
|
||||||
actionError = undefined
|
actionError.value = undefined
|
||||||
await nextTick()
|
await nextTick()
|
||||||
input.value?.focus()
|
input.value?.focus()
|
||||||
}
|
}
|
||||||
const cancelEdit = async () => {
|
async function cancelEdit() {
|
||||||
isEditing = false
|
isEditing.value = false
|
||||||
actionError = undefined
|
actionError.value = undefined
|
||||||
reset()
|
reset()
|
||||||
|
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
@ -42,58 +40,59 @@ const cancelEdit = async () => {
|
||||||
|
|
||||||
const { submit, submitting } = submitter(async () => {
|
const { submit, submitting } = submitter(async () => {
|
||||||
try {
|
try {
|
||||||
list.value = await client.v1.lists.update(form.id, {
|
list.value = await client.v1.lists.$select(form.id).update({
|
||||||
title: form.title,
|
title: form.title,
|
||||||
})
|
})
|
||||||
cancelEdit()
|
cancelEdit()
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
actionError = (err as Error).message
|
actionError.value = (err as Error).message
|
||||||
await nextTick()
|
await nextTick()
|
||||||
input.value?.focus()
|
input.value?.focus()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
async function removeList() {
|
async function removeList() {
|
||||||
if (deleting)
|
if (deleting.value)
|
||||||
return
|
return
|
||||||
|
|
||||||
const confirmDelete = await openConfirmDialog({
|
const confirmDelete = await openConfirmDialog({
|
||||||
title: t('confirm.delete_list.title', [list.value.title]),
|
title: t('confirm.delete_list.title'),
|
||||||
|
description: t('confirm.delete_list.description', [list.value.title]),
|
||||||
confirm: t('confirm.delete_list.confirm'),
|
confirm: t('confirm.delete_list.confirm'),
|
||||||
cancel: t('confirm.delete_list.cancel'),
|
cancel: t('confirm.delete_list.cancel'),
|
||||||
})
|
})
|
||||||
|
|
||||||
deleting = true
|
deleting.value = true
|
||||||
actionError = undefined
|
actionError.value = undefined
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
if (confirmDelete === 'confirm') {
|
if (confirmDelete.choice === 'confirm') {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
try {
|
try {
|
||||||
await client.v1.lists.remove(list.value.id)
|
await client.v1.lists.$select(list.value.id).remove()
|
||||||
emit('listRemoved', list.value.id)
|
emit('listRemoved', list.value.id)
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
actionError = (err as Error).message
|
actionError.value = (err as Error).message
|
||||||
await nextTick()
|
await nextTick()
|
||||||
deleteBtn.value?.focus()
|
deleteBtn.value?.focus()
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
deleting = false
|
deleting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
deleting = false
|
deleting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function clearError() {
|
async function clearError() {
|
||||||
actionError = undefined
|
actionError.value = undefined
|
||||||
await nextTick()
|
await nextTick()
|
||||||
if (isEditing)
|
if (isEditing.value)
|
||||||
input.value?.focus()
|
input.value?.focus()
|
||||||
else
|
else
|
||||||
deleteBtn.value?.focus()
|
deleteBtn.value?.focus()
|
||||||
|
|
|
@ -3,9 +3,9 @@ const { userId } = defineProps<{
|
||||||
userId: string
|
userId: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { client } = $(useMasto())
|
const { client } = useMasto()
|
||||||
const paginator = client.v1.lists.list()
|
const paginator = client.value.v1.lists.list()
|
||||||
const listsWithUser = ref((await client.v1.accounts.listLists(userId)).map(list => list.id))
|
const listsWithUser = ref((await client.value.v1.accounts.$select(userId).lists.list()).map(list => list.id))
|
||||||
|
|
||||||
function indexOfUserInList(listId: string) {
|
function indexOfUserInList(listId: string) {
|
||||||
return listsWithUser.value.indexOf(listId)
|
return listsWithUser.value.indexOf(listId)
|
||||||
|
@ -15,11 +15,11 @@ async function edit(listId: string) {
|
||||||
try {
|
try {
|
||||||
const index = indexOfUserInList(listId)
|
const index = indexOfUserInList(listId)
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
await client.v1.lists.addAccount(listId, { accountIds: [userId] })
|
await client.value.v1.lists.$select(listId).accounts.create({ accountIds: [userId] })
|
||||||
listsWithUser.value.push(listId)
|
listsWithUser.value.push(listId)
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
await client.v1.lists.removeAccount(listId, { accountIds: [userId] })
|
await client.value.v1.lists.$select(listId).accounts.remove({ accountIds: [userId] })
|
||||||
listsWithUser.value = listsWithUser.value.filter(id => id !== listId)
|
listsWithUser.value = listsWithUser.value.filter(id => id !== listId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -30,7 +30,7 @@ async function edit(listId: string) {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<CommonPaginator no-end-message :paginator="paginator">
|
<CommonPaginator :end-message="false" :paginator="paginator">
|
||||||
<template #default="{ item }">
|
<template #default="{ item }">
|
||||||
<div p4 hover:bg-active block w="100%" flex justify-between items-center gap-4>
|
<div p4 hover:bg-active block w="100%" flex justify-between items-center gap-4>
|
||||||
<p>{{ item.title }}</p>
|
<p>{{ item.title }}</p>
|
||||||
|
|
|
@ -21,7 +21,10 @@ interface ShortcutItemGroup {
|
||||||
items: ShortcutItem[]
|
items: ShortcutItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const shortcutItemGroups: ShortcutItemGroup[] = [
|
const isMac = useIsMac()
|
||||||
|
const modifierKeyName = computed(() => isMac.value ? '⌘' : 'Ctrl')
|
||||||
|
|
||||||
|
const shortcutItemGroups = computed<ShortcutItemGroup[]>(() => [
|
||||||
{
|
{
|
||||||
name: t('magic_keys.groups.navigation.title'),
|
name: t('magic_keys.groups.navigation.title'),
|
||||||
items: [
|
items: [
|
||||||
|
@ -37,6 +40,10 @@ const shortcutItemGroups: ShortcutItemGroup[] = [
|
||||||
// description: t('magic_keys.groups.navigation.previous_status'),
|
// description: t('magic_keys.groups.navigation.previous_status'),
|
||||||
// shortcut: { keys: ['k'], isSequence: false },
|
// shortcut: { keys: ['k'], isSequence: false },
|
||||||
// },
|
// },
|
||||||
|
{
|
||||||
|
description: t('magic_keys.groups.navigation.go_to_search'),
|
||||||
|
shortcut: { keys: ['/'], isSequence: false },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
description: t('magic_keys.groups.navigation.go_to_home'),
|
description: t('magic_keys.groups.navigation.go_to_home'),
|
||||||
shortcut: { keys: ['g', 'h'], isSequence: true },
|
shortcut: { keys: ['g', 'h'], isSequence: true },
|
||||||
|
@ -45,19 +52,63 @@ const shortcutItemGroups: ShortcutItemGroup[] = [
|
||||||
description: t('magic_keys.groups.navigation.go_to_notifications'),
|
description: t('magic_keys.groups.navigation.go_to_notifications'),
|
||||||
shortcut: { keys: ['g', 'n'], isSequence: true },
|
shortcut: { keys: ['g', 'n'], isSequence: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
description: t('magic_keys.groups.navigation.go_to_conversations'),
|
||||||
|
shortcut: { keys: ['g', 'c'], isSequence: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: t('magic_keys.groups.navigation.go_to_favourites'),
|
||||||
|
shortcut: { keys: ['g', 'f'], isSequence: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: t('magic_keys.groups.navigation.go_to_bookmarks'),
|
||||||
|
shortcut: { keys: ['g', 'b'], isSequence: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: t('magic_keys.groups.navigation.go_to_explore'),
|
||||||
|
shortcut: { keys: ['g', 'e'], isSequence: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: t('magic_keys.groups.navigation.go_to_local'),
|
||||||
|
shortcut: { keys: ['g', 'l'], isSequence: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: t('magic_keys.groups.navigation.go_to_federated'),
|
||||||
|
shortcut: { keys: ['g', 't'], isSequence: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: t('magic_keys.groups.navigation.go_to_lists'),
|
||||||
|
shortcut: { keys: ['g', 'i'], isSequence: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: t('magic_keys.groups.navigation.go_to_settings'),
|
||||||
|
shortcut: { keys: ['g', 's'], isSequence: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: t('magic_keys.groups.navigation.go_to_profile'),
|
||||||
|
shortcut: { keys: ['g', 'p'], isSequence: true },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: t('magic_keys.groups.actions.title'),
|
name: t('magic_keys.groups.actions.title'),
|
||||||
items: [
|
items: [
|
||||||
|
{
|
||||||
|
description: t('magic_keys.groups.actions.search'),
|
||||||
|
shortcut: { keys: [modifierKeyName.value, 'k'], isSequence: false },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
description: t('magic_keys.groups.actions.command_mode'),
|
description: t('magic_keys.groups.actions.command_mode'),
|
||||||
shortcut: { keys: ['cmd', '/'], isSequence: false },
|
shortcut: { keys: [modifierKeyName.value, '/'], isSequence: false },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
description: t('magic_keys.groups.actions.compose'),
|
description: t('magic_keys.groups.actions.compose'),
|
||||||
shortcut: { keys: ['c'], isSequence: false },
|
shortcut: { keys: ['c'], isSequence: false },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
description: t('magic_keys.groups.actions.show_new_items'),
|
||||||
|
shortcut: { keys: ['.'], isSequence: false },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
description: t('magic_keys.groups.actions.favourite'),
|
description: t('magic_keys.groups.actions.favourite'),
|
||||||
shortcut: { keys: ['f'], isSequence: false },
|
shortcut: { keys: ['f'], isSequence: false },
|
||||||
|
@ -66,17 +117,13 @@ const shortcutItemGroups: ShortcutItemGroup[] = [
|
||||||
description: t('magic_keys.groups.actions.boost'),
|
description: t('magic_keys.groups.actions.boost'),
|
||||||
shortcut: { keys: ['b'], isSequence: false },
|
shortcut: { keys: ['b'], isSequence: false },
|
||||||
},
|
},
|
||||||
{
|
|
||||||
description: t('magic_keys.groups.actions.zen_mode'),
|
|
||||||
shortcut: { keys: ['z'], isSequence: false },
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: t('magic_keys.groups.media.title'),
|
name: t('magic_keys.groups.media.title'),
|
||||||
items: [],
|
items: [],
|
||||||
},
|
},
|
||||||
]
|
])
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
|
@ -8,17 +8,32 @@ defineProps<{
|
||||||
noOverflowHidden?: boolean
|
noOverflowHidden?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const container = ref()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const userSettings = useUserSettings()
|
||||||
|
const { height: windowHeight } = useWindowSize()
|
||||||
|
const { height: containerHeight } = useElementBounding(container)
|
||||||
const wideLayout = computed(() => route.meta.wideLayout ?? false)
|
const wideLayout = computed(() => route.meta.wideLayout ?? false)
|
||||||
|
const sticky = computed(() => route.path?.startsWith('/settings/'))
|
||||||
|
const containerClass = computed(() => {
|
||||||
|
// we keep original behavior when not in settings page and when the window height is smaller than the container height
|
||||||
|
if (!isHydrated.value || !sticky.value || (windowHeight.value < containerHeight.value))
|
||||||
|
return null
|
||||||
|
|
||||||
|
return 'lg:sticky lg:top-0'
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div ref="container" :class="containerClass">
|
||||||
<div
|
<div
|
||||||
sticky top-0 z10 backdrop-blur
|
sticky top-0 z10
|
||||||
pt="[env(safe-area-inset-top,0)]"
|
pt="[env(safe-area-inset-top,0)]"
|
||||||
bg="[rgba(var(--rgb-bg-base),0.7)]"
|
bg="[rgba(var(--rgb-bg-base),0.7)]"
|
||||||
class="native:lg:w-[calc(100vw-5rem)] native:xl:w-[calc(135%+(100vw-1200px)/2)]"
|
class="native:lg:w-[calc(100vw-5rem)] native:xl:w-[calc(135%+(100vw-1200px)/2)]"
|
||||||
|
:class="{
|
||||||
|
'backdrop-blur': !getPreferences(userSettings, 'optimizeForLowPerformanceDevice'),
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
<div flex justify-between px5 py2 :class="{ 'xl:hidden': $route.name !== 'tag' }" class="native:xl:flex" border="b base">
|
<div flex justify-between px5 py2 :class="{ 'xl:hidden': $route.name !== 'tag' }" class="native:xl:flex" border="b base">
|
||||||
<div flex gap-3 items-center :overflow-hidden="!noOverflowHidden ? '' : false" py2 w-full>
|
<div flex gap-3 items-center :overflow-hidden="!noOverflowHidden ? '' : false" py2 w-full>
|
||||||
|
@ -29,24 +44,25 @@ const wideLayout = computed(() => route.meta.wideLayout ?? false)
|
||||||
>
|
>
|
||||||
<div i-ri:arrow-left-line class="rtl-flip" />
|
<div i-ri:arrow-left-line class="rtl-flip" />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<div :truncate="!noOverflowHidden ? '' : false" flex w-full data-tauri-drag-region class="native-mac:justify-center native-mac:text-center native-mac:sm:justify-start">
|
<div :truncate="!noOverflowHidden ? '' : false" flex w-full data-tauri-drag-region class="native-mac:justify-start native-mac:text-center">
|
||||||
<slot name="title" />
|
<slot name="title" />
|
||||||
</div>
|
</div>
|
||||||
<div sm:hidden h-7 w-1px />
|
<div sm:hidden h-7 w-1px />
|
||||||
</div>
|
</div>
|
||||||
<div flex items-center flex-shrink-0 gap-x-2>
|
<div flex items-center flex-shrink-0 gap-x-2>
|
||||||
<slot name="actions" />
|
<slot name="actions" />
|
||||||
<PwaBadge lg:hidden />
|
<PwaBadge xl:hidden />
|
||||||
<NavUser v-if="isHydrated" />
|
<NavUser v-if="isHydrated" />
|
||||||
<NavUserSkeleton v-else />
|
<NavUserSkeleton v-else />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<slot name="header">
|
<slot name="header">
|
||||||
<div hidden :class="{ 'xl:block': $route.name !== 'tag' }" h-6 />
|
<div hidden />
|
||||||
</slot>
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
<PwaInstallPrompt lg:hidden />
|
<PwaInstallPrompt xl:hidden />
|
||||||
<div :class="isHydrated && wideLayout ? 'xl:w-full sm:max-w-600px' : 'sm:max-w-600px md:shrink-0'" m-auto>
|
<div :class="isHydrated && wideLayout ? 'xl:w-full sm:max-w-600px' : 'sm:max-w-600px md:shrink-0'" m-auto>
|
||||||
|
<div hidden :class="{ 'xl:block': $route.name !== 'tag' && !$slots.header }" h-6 />
|
||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
45
components/modal/DurationPicker.vue
Normal file
45
components/modal/DurationPicker.vue
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
const model = defineModel<number>()
|
||||||
|
const isValid = defineModel<boolean>('isValid')
|
||||||
|
|
||||||
|
const days = ref<number | ''>(0)
|
||||||
|
const hours = ref<number | ''>(1)
|
||||||
|
const minutes = ref<number | ''>(0)
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
if (days.value === '' || hours.value === '' || minutes.value === '') {
|
||||||
|
isValid.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const duration
|
||||||
|
= days.value * 24 * 60 * 60
|
||||||
|
+ hours.value * 60 * 60
|
||||||
|
+ minutes.value * 60
|
||||||
|
|
||||||
|
if (duration <= 0) {
|
||||||
|
isValid.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isValid.value = true
|
||||||
|
model.value = duration
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div flex flex-grow-0 gap-2>
|
||||||
|
<label flex items-center gap-2>
|
||||||
|
<input v-model="days" type="number" min="0" max="1999" input-base :class="!isValid ? 'input-error' : null">
|
||||||
|
{{ $t('confirm.mute_account.days', days === '' ? 0 : days) }}
|
||||||
|
</label>
|
||||||
|
<label flex items-center gap-2>
|
||||||
|
<input v-model="hours" type="number" min="0" max="24" input-base :class="!isValid ? 'input-error' : null">
|
||||||
|
{{ $t('confirm.mute_account.hours', hours === '' ? 0 : hours) }}
|
||||||
|
</label>
|
||||||
|
<label flex items-center gap-2>
|
||||||
|
<input v-model="minutes" type="number" min="0" max="59" step="5" input-base :class="!isValid ? 'input-error' : null">
|
||||||
|
{{ $t('confirm.mute_account.minute', minutes === '' ? 0 : minutes) }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</template>
|
|
@ -1,26 +1,55 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { ConfirmDialogChoice, ConfirmDialogLabel } from '~/types'
|
import type { ConfirmDialogChoice, ConfirmDialogOptions } from '~/types'
|
||||||
|
import DurationPicker from '~/components/modal/DurationPicker.vue'
|
||||||
|
|
||||||
defineProps<ConfirmDialogLabel>()
|
const props = defineProps<ConfirmDialogOptions>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(evt: 'choice', choice: ConfirmDialogChoice): void
|
(evt: 'choice', choice: ConfirmDialogChoice): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const hasDuration = ref(false)
|
||||||
|
const isValidDuration = ref(true)
|
||||||
|
const duration = ref(60 * 60) // default to 1 hour
|
||||||
|
const shouldMuteNotifications = ref(true)
|
||||||
|
const isMute = computed(() => props.extraOptionType === 'mute')
|
||||||
|
|
||||||
|
function handleChoice(choice: ConfirmDialogChoice['choice']) {
|
||||||
|
const dialogChoice = {
|
||||||
|
choice,
|
||||||
|
...isMute.value && {
|
||||||
|
extraOptions: {
|
||||||
|
mute: {
|
||||||
|
duration: hasDuration.value ? duration.value : 0,
|
||||||
|
notifications: shouldMuteNotifications.value,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
emit('choice', dialogChoice)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div flex="~ col" gap-6>
|
<div flex="~ col" gap-6>
|
||||||
<div font-bold text-lg text-center>
|
<div font-bold text-lg>
|
||||||
{{ title }}
|
{{ title }}
|
||||||
</div>
|
</div>
|
||||||
<div v-if="description">
|
<div v-if="description">
|
||||||
{{ description }}
|
{{ description }}
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="isMute" flex-col flex gap-4>
|
||||||
|
<CommonCheckbox v-model="hasDuration" :label="$t('confirm.mute_account.specify_duration')" prepend-checkbox checked-icon-color="text-primary" />
|
||||||
|
<DurationPicker v-if="hasDuration" v-model="duration" v-model:is-valid="isValidDuration" />
|
||||||
|
<CommonCheckbox v-model="shouldMuteNotifications" :label="$t('confirm.mute_account.notifications')" prepend-checkbox checked-icon-color="text-primary" />
|
||||||
|
</div>
|
||||||
|
|
||||||
<div flex justify-end gap-2>
|
<div flex justify-end gap-2>
|
||||||
<button btn-text @click="emit('choice', 'cancel')">
|
<button btn-text @click="handleChoice('cancel')">
|
||||||
{{ cancel || $t('confirm.common.cancel') }}
|
{{ cancel || $t('confirm.common.cancel') }}
|
||||||
</button>
|
</button>
|
||||||
<button btn-solid @click="emit('choice', 'confirm')">
|
<button btn-solid :disabled="!isValidDuration" @click="handleChoice('confirm')">
|
||||||
{{ confirm || $t('confirm.common.confirm') }}
|
{{ confirm || $t('confirm.common.confirm') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -11,6 +11,7 @@ import {
|
||||||
isMediaPreviewOpen,
|
isMediaPreviewOpen,
|
||||||
isPreviewHelpOpen,
|
isPreviewHelpOpen,
|
||||||
isPublishDialogOpen,
|
isPublishDialogOpen,
|
||||||
|
isReportDialogOpen,
|
||||||
isSigninDialogOpen,
|
isSigninDialogOpen,
|
||||||
} from '~/composables/dialog'
|
} from '~/composables/dialog'
|
||||||
|
|
||||||
|
@ -33,21 +34,21 @@ useEventListener('keydown', (e: KeyboardEvent) => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const handlePublished = (status: mastodon.v1.Status) => {
|
function handlePublished(status: mastodon.v1.Status) {
|
||||||
lastPublishDialogStatus.value = status
|
lastPublishDialogStatus.value = status
|
||||||
isPublishDialogOpen.value = false
|
isPublishDialogOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePublishClose = () => {
|
function handlePublishClose() {
|
||||||
lastPublishDialogStatus.value = null
|
lastPublishDialogStatus.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleConfirmChoice = (choice: ConfirmDialogChoice) => {
|
function handleConfirmChoice(choice: ConfirmDialogChoice) {
|
||||||
confirmDialogChoice.value = choice
|
confirmDialogChoice.value = choice
|
||||||
isConfirmDialogOpen.value = false
|
isConfirmDialogOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleFavouritedBoostedByClose = () => {
|
function handleFavouritedBoostedByClose() {
|
||||||
isFavouritedBoostedByDialogOpen.value = false
|
isFavouritedBoostedByDialogOpen.value = false
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
@ -102,5 +103,8 @@ const handleFavouritedBoostedByClose = () => {
|
||||||
<ModalDialog v-model="isKeyboardShortcutsDialogOpen" max-w-full sm:max-w-140 md:max-w-170 lg:max-w-220 md:min-w-160>
|
<ModalDialog v-model="isKeyboardShortcutsDialogOpen" max-w-full sm:max-w-140 md:max-w-170 lg:max-w-220 md:min-w-160>
|
||||||
<MagickeysKeyboardShortcuts @close="closeKeyboardShortcuts()" />
|
<MagickeysKeyboardShortcuts @close="closeKeyboardShortcuts()" />
|
||||||
</ModalDialog>
|
</ModalDialog>
|
||||||
|
<ModalDialog v-model="isReportDialogOpen" keep-alive max-w-175>
|
||||||
|
<ReportModal v-if="reportAccount" :account="reportAccount" :status="reportStatus" @close="closeReportDialog()" />
|
||||||
|
</ModalDialog>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -36,6 +36,10 @@ export interface Props {
|
||||||
dialogLabelledBy?: string
|
dialogLabelledBy?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
zIndex: 100,
|
zIndex: 100,
|
||||||
closeByMask: true,
|
closeByMask: true,
|
||||||
|
@ -45,20 +49,14 @@ const props = withDefaults(defineProps<Props>(), {
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
/** v-model dialog visibility */
|
/** v-model dialog visibility */
|
||||||
(event: 'close',): void
|
(event: 'close'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { modelValue: visible } = defineModel<{
|
const visible = defineModel<boolean>({ required: true })
|
||||||
/** v-model dislog visibility */
|
|
||||||
modelValue: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
defineOptions({
|
|
||||||
inheritAttrs: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const deactivated = useDeactivated()
|
const deactivated = useDeactivated()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const userSettings = useUserSettings()
|
||||||
|
|
||||||
/** scrollable HTML element */
|
/** scrollable HTML element */
|
||||||
const elDialogMain = ref<HTMLDivElement>()
|
const elDialogMain = ref<HTMLDivElement>()
|
||||||
|
@ -80,6 +78,8 @@ defineExpose({
|
||||||
|
|
||||||
/** close the dialog */
|
/** close the dialog */
|
||||||
function close() {
|
function close() {
|
||||||
|
if (!visible.value)
|
||||||
|
return
|
||||||
visible.value = false
|
visible.value = false
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
|
@ -119,9 +119,11 @@ const isVShow = computed(() => {
|
||||||
: true
|
: true
|
||||||
})
|
})
|
||||||
|
|
||||||
const bindTypeToAny = ($attrs: any) => $attrs as any
|
function bindTypeToAny($attrs: any) {
|
||||||
|
return $attrs as any
|
||||||
|
}
|
||||||
|
|
||||||
const trapFocusDialog = () => {
|
function trapFocusDialog() {
|
||||||
if (isVShow.value)
|
if (isVShow.value)
|
||||||
nextTick().then(() => activate())
|
nextTick().then(() => activate())
|
||||||
}
|
}
|
||||||
|
@ -155,7 +157,13 @@ useEventListener('keydown', (e: KeyboardEvent) => {
|
||||||
<!-- corresponding to issue: #106, so please don't remove it. -->
|
<!-- corresponding to issue: #106, so please don't remove it. -->
|
||||||
|
|
||||||
<!-- Mask layer: blur -->
|
<!-- Mask layer: blur -->
|
||||||
<div class="dialog-mask" absolute inset-0 z-0 bg-transparent opacity-100 backdrop-filter backdrop-blur-sm touch-none />
|
<div
|
||||||
|
class="dialog-mask"
|
||||||
|
:class="{
|
||||||
|
'backdrop-blur-sm': !getPreferences(userSettings, 'optimizeForLowPerformanceDevice'),
|
||||||
|
}"
|
||||||
|
absolute inset-0 z-0 bg-transparent opacity-100 backdrop-filter touch-none
|
||||||
|
/>
|
||||||
<!-- Mask layer: dimming -->
|
<!-- Mask layer: dimming -->
|
||||||
<div class="dialog-mask" absolute inset-0 z-0 bg-black opacity-48 touch-none h="[calc(100%+0.5px)]" @click="clickMask" />
|
<div class="dialog-mask" absolute inset-0 z-0 bg-black opacity-48 touch-none h="[calc(100%+0.5px)]" @click="clickMask" />
|
||||||
<!-- Dialog container -->
|
<!-- Dialog container -->
|
||||||
|
|
|
@ -37,7 +37,7 @@ onUnmounted(() => locked.value = false)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div relative h-full w-full flex pt-12 w-100vh @click="onClick">
|
<div relative h-full w-full flex pt-12 @click="onClick">
|
||||||
<button
|
<button
|
||||||
v-if="hasNext" pointer-events-auto btn-action-icon bg="black/20" :aria-label="$t('action.previous')"
|
v-if="hasNext" pointer-events-auto btn-action-icon bg="black/20" :aria-label="$t('action.previous')"
|
||||||
hover:bg="black/40" dark:bg="white/30" dark-hover:bg="white/20" absolute top="1/2" right-1 z5
|
hover:bg="black/40" dark:bg="white/30" dark-hover:bg="white/20" absolute top="1/2" right-1 z5
|
||||||
|
@ -53,21 +53,19 @@ onUnmounted(() => locked.value = false)
|
||||||
<div i-ri:arrow-left-s-line text-white />
|
<div i-ri:arrow-left-s-line text-white />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div flex flex-row items-center mxa>
|
<div flex="~ col center" h-full w-full>
|
||||||
<div flex="~ col center" max-h-full max-w-full>
|
<ModalMediaPreviewCarousel v-model="index" :media="mediaPreviewList" @close="emit('close')" />
|
||||||
<ModalMediaPreviewCarousel v-model="index" :media="mediaPreviewList" @close="emit('close')" />
|
|
||||||
|
|
||||||
<div bg="black/30" dark:bg="white/10" ms-4 mb-6 mt-4 text-white rounded-full flex="~ center shrink-0" overflow-hidden>
|
<div bg="black/30" dark:bg="white/10" mb-6 mt-4 text-white rounded-full flex="~ center shrink-0" overflow-hidden>
|
||||||
<div v-if="mediaPreviewList.length > 1" p="y-1 x-3" rounded-r-0 shrink-0>
|
<div v-if="mediaPreviewList.length > 1" p="y-1 x-3" rounded-r-0 shrink-0>
|
||||||
{{ index + 1 }} / {{ mediaPreviewList.length }}
|
{{ index + 1 }} / {{ mediaPreviewList.length }}
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
v-if="current.description" bg="dark/30" dark:bg="white/10" p="y-1 x-3" rounded-ie-full line-clamp-1
|
|
||||||
ws-pre-wrap break-all :title="current.description" w-full
|
|
||||||
>
|
|
||||||
{{ current.description }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p
|
||||||
|
v-if="current.description" bg="dark/30" dark:bg="white/10" p="y-1 x-3" rounded-ie-full line-clamp-1
|
||||||
|
ws-pre-wrap break-all :title="current.description" w-full
|
||||||
|
>
|
||||||
|
{{ current.description }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
@ -1,105 +1,285 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { SwipeDirection } from '@vueuse/core'
|
import type { Vector2 } from '@vueuse/gesture'
|
||||||
import { useGesture } from '@vueuse/gesture'
|
import { useGesture } from '@vueuse/gesture'
|
||||||
import type { PermissiveMotionProperties } from '@vueuse/motion'
|
|
||||||
import { useReducedMotion } from '@vueuse/motion'
|
import { useReducedMotion } from '@vueuse/motion'
|
||||||
import type { mastodon } from 'masto'
|
import type { mastodon } from 'masto'
|
||||||
|
|
||||||
const { media = [], threshold = 20 } = defineProps<{
|
const { media = [] } = defineProps<{
|
||||||
media?: mastodon.v1.MediaAttachment[]
|
media?: mastodon.v1.MediaAttachment[]
|
||||||
threshold?: number
|
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(event: 'close'): void
|
(event: 'close'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { modelValue } = defineModel<{
|
const modelValue = defineModel<number>({ required: true })
|
||||||
modelValue: number
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const target = ref()
|
const slideGap = 20
|
||||||
|
const doubleTapThreshold = 250
|
||||||
|
|
||||||
const animateTimeout = useTimeout(10)
|
const view = ref()
|
||||||
const reduceMotion = process.server ? ref(false) : useReducedMotion()
|
const slider = ref()
|
||||||
|
const slide = ref()
|
||||||
|
const image = ref()
|
||||||
|
|
||||||
const canAnimate = computed(() => !reduceMotion.value && animateTimeout.value)
|
const reduceMotion = import.meta.server ? ref(false) : useReducedMotion()
|
||||||
|
const isInitialScrollDone = useTimeout(350)
|
||||||
|
const canAnimate = computed(() => isInitialScrollDone.value && !reduceMotion.value)
|
||||||
|
|
||||||
const { motionProperties } = useMotionProperties(target, {
|
const scale = ref(1)
|
||||||
cursor: 'grab',
|
const x = ref(0)
|
||||||
scale: 1,
|
const y = ref(0)
|
||||||
x: 0,
|
|
||||||
y: 0,
|
|
||||||
})
|
|
||||||
const { set } = useSpring(motionProperties as Partial<PermissiveMotionProperties>)
|
|
||||||
|
|
||||||
function resetZoom() {
|
const isDragging = ref(false)
|
||||||
set({ scale: 1 })
|
const isPinching = ref(false)
|
||||||
|
|
||||||
|
const maxZoomOut = ref(1)
|
||||||
|
const isZoomedIn = computed(() => scale.value > 1)
|
||||||
|
|
||||||
|
const enableAutoplay = usePreferences('enableAutoplay')
|
||||||
|
|
||||||
|
function goToFocusedSlide() {
|
||||||
|
scale.value = 1
|
||||||
|
x.value = slide.value[modelValue.value].offsetLeft * scale.value
|
||||||
|
y.value = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(modelValue, resetZoom)
|
onMounted(() => {
|
||||||
|
const slideGapAsScale = slideGap / view.value.clientWidth
|
||||||
|
maxZoomOut.value = 1 - slideGapAsScale
|
||||||
|
|
||||||
const { width, height } = useElementSize(target)
|
goToFocusedSlide()
|
||||||
const { isSwiping, lengthX, lengthY, direction } = useSwipe(target, {
|
|
||||||
threshold: 5,
|
|
||||||
passive: false,
|
|
||||||
onSwipeEnd(e, direction) {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
|
||||||
if (direction === SwipeDirection.RIGHT && Math.abs(distanceX.value) > threshold) {
|
|
||||||
modelValue.value = Math.max(0, modelValue.value - 1)
|
|
||||||
resetZoom()
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
|
||||||
if (direction === SwipeDirection.LEFT && Math.abs(distanceX.value) > threshold) {
|
|
||||||
modelValue.value = Math.min(media.length - 1, modelValue.value + 1)
|
|
||||||
resetZoom()
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
|
||||||
if (direction === SwipeDirection.UP && Math.abs(distanceY.value) > threshold)
|
|
||||||
emit('close')
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
watch(modelValue, goToFocusedSlide)
|
||||||
|
|
||||||
|
let lastOrigin = [0, 0]
|
||||||
|
let initialScale = 0
|
||||||
useGesture({
|
useGesture({
|
||||||
onPinch({ offset: [distance, _angle] }) {
|
onPinch({ first, initial: [initialDistance], movement: [deltaDistance], da: [distance], origin, touches }) {
|
||||||
set({ scale: Math.max(0.5, 1 + distance / 200) })
|
isPinching.value = true
|
||||||
|
|
||||||
|
if (first) {
|
||||||
|
initialScale = scale.value
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
if (touches === 0)
|
||||||
|
handleMouseWheelZoom(initialScale, deltaDistance, origin)
|
||||||
|
else
|
||||||
|
handlePinchZoom(initialScale, initialDistance, distance, origin)
|
||||||
|
}
|
||||||
|
|
||||||
|
lastOrigin = origin
|
||||||
},
|
},
|
||||||
onMove({ movement: [x, y], dragging, pinching }) {
|
onPinchEnd() {
|
||||||
if (dragging && !pinching)
|
isPinching.value = false
|
||||||
set({ x, y })
|
isDragging.value = false
|
||||||
|
|
||||||
|
if (!isZoomedIn.value)
|
||||||
|
goToFocusedSlide()
|
||||||
|
},
|
||||||
|
onDrag({ movement, delta, pinching, tap, last, swipe, event, xy }) {
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
if (pinching)
|
||||||
|
return
|
||||||
|
|
||||||
|
if (last)
|
||||||
|
handleLastDrag(tap, swipe, movement, xy)
|
||||||
|
else
|
||||||
|
handleDrag(delta, movement)
|
||||||
},
|
},
|
||||||
}, {
|
}, {
|
||||||
domTarget: target,
|
domTarget: view,
|
||||||
eventOptions: {
|
eventOptions: {
|
||||||
passive: true,
|
passive: false,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const distanceX = computed(() => {
|
const shiftRestrictions = computed(() => {
|
||||||
if (width.value === 0)
|
const focusedImage = image.value[modelValue.value]
|
||||||
return 0
|
const focusedSlide = slide.value[modelValue.value]
|
||||||
|
|
||||||
if (!isSwiping.value || (direction.value !== SwipeDirection.LEFT && direction.value !== SwipeDirection.RIGHT))
|
const scaledImageWidth = focusedImage.offsetWidth * scale.value
|
||||||
return modelValue.value * 100 * -1
|
const scaledHorizontalOverflow = scaledImageWidth / 2 - view.value.clientWidth / 2 + slideGap
|
||||||
|
const horizontalOverflow = Math.max(0, scaledHorizontalOverflow / scale.value)
|
||||||
|
|
||||||
return (lengthX.value / width.value) * 100 * -1 + (modelValue.value * 100) * -1
|
const scaledImageHeight = focusedImage.offsetHeight * scale.value
|
||||||
|
const scaledVerticalOverflow = scaledImageHeight / 2 - view.value.clientHeight / 2 + slideGap
|
||||||
|
const verticalOverflow = Math.max(0, scaledVerticalOverflow / scale.value)
|
||||||
|
|
||||||
|
return {
|
||||||
|
left: focusedSlide.offsetLeft - horizontalOverflow,
|
||||||
|
right: focusedSlide.offsetLeft + horizontalOverflow,
|
||||||
|
top: focusedSlide.offsetTop - verticalOverflow,
|
||||||
|
bottom: focusedSlide.offsetTop + verticalOverflow,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const distanceY = computed(() => {
|
function handlePinchZoom(initialScale: number, initialDistance: number, distance: number, [originX, originY]: Vector2) {
|
||||||
if (height.value === 0 || !isSwiping.value || direction.value !== SwipeDirection.UP)
|
scale.value = initialScale * (distance / initialDistance)
|
||||||
return 0
|
scale.value = Math.max(maxZoomOut.value, scale.value)
|
||||||
|
|
||||||
return (lengthY.value / height.value) * 100 * -1
|
const deltaCenterX = originX - lastOrigin[0]
|
||||||
|
const deltaCenterY = originY - lastOrigin[1]
|
||||||
|
|
||||||
|
handleZoomDrag([deltaCenterX, deltaCenterY])
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseWheelZoom(initialScale: number, deltaDistance: number, [originX, originY]: Vector2) {
|
||||||
|
scale.value = initialScale + (deltaDistance / 1000)
|
||||||
|
scale.value = Math.max(maxZoomOut.value, scale.value)
|
||||||
|
|
||||||
|
const deltaCenterX = lastOrigin[0] - originX
|
||||||
|
const deltaCenterY = lastOrigin[1] - originY
|
||||||
|
|
||||||
|
handleZoomDrag([deltaCenterX, deltaCenterY])
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLastDrag(tap: boolean, swipe: Vector2, movement: Vector2, position: Vector2) {
|
||||||
|
isDragging.value = false
|
||||||
|
|
||||||
|
if (tap)
|
||||||
|
handleTap(position)
|
||||||
|
else if (swipe[0] || swipe[1])
|
||||||
|
handleSwipe(swipe, movement)
|
||||||
|
else if (!isZoomedIn.value)
|
||||||
|
slideToClosestSlide()
|
||||||
|
}
|
||||||
|
|
||||||
|
let lastTapAt = 0
|
||||||
|
function handleTap([positionX, positionY]: Vector2) {
|
||||||
|
const now = Date.now()
|
||||||
|
const isDoubleTap = now - lastTapAt < doubleTapThreshold
|
||||||
|
lastTapAt = now
|
||||||
|
|
||||||
|
if (!isDoubleTap)
|
||||||
|
return
|
||||||
|
|
||||||
|
if (isZoomedIn.value) {
|
||||||
|
goToFocusedSlide()
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const focusedSlideBounding = slide.value[modelValue.value].getBoundingClientRect()
|
||||||
|
const slideCenterX = focusedSlideBounding.left + focusedSlideBounding.width / 2
|
||||||
|
const slideCenterY = focusedSlideBounding.top + focusedSlideBounding.height / 2
|
||||||
|
|
||||||
|
scale.value = 3
|
||||||
|
x.value += positionX - slideCenterX
|
||||||
|
y.value += positionY - slideCenterY
|
||||||
|
restrictShiftToInsideSlide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSwipe([horiz, vert]: Vector2, [movementX, movementY]: Vector2) {
|
||||||
|
if (isZoomedIn.value || isPinching.value)
|
||||||
|
return
|
||||||
|
|
||||||
|
const isHorizontalDrag = Math.abs(movementX) >= Math.abs(movementY)
|
||||||
|
|
||||||
|
if (isHorizontalDrag) {
|
||||||
|
if (horiz === 1) // left
|
||||||
|
modelValue.value = Math.max(0, modelValue.value - 1)
|
||||||
|
if (horiz === -1) // right
|
||||||
|
modelValue.value = Math.min(media.length - 1, modelValue.value + 1)
|
||||||
|
}
|
||||||
|
else if (vert === 1 || vert === -1) {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
goToFocusedSlide()
|
||||||
|
}
|
||||||
|
|
||||||
|
function slideToClosestSlide() {
|
||||||
|
const startOfFocusedSlide = slide.value[modelValue.value].offsetLeft * scale.value
|
||||||
|
const slideWidth = slide.value[modelValue.value].offsetWidth * scale.value
|
||||||
|
|
||||||
|
if (x.value > startOfFocusedSlide + slideWidth / 2)
|
||||||
|
modelValue.value = Math.min(media.length - 1, modelValue.value + 1)
|
||||||
|
else if (x.value < startOfFocusedSlide - slideWidth / 2)
|
||||||
|
modelValue.value = Math.max(0, modelValue.value - 1)
|
||||||
|
|
||||||
|
goToFocusedSlide()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrag(delta: Vector2, movement: Vector2) {
|
||||||
|
isDragging.value = true
|
||||||
|
|
||||||
|
if (isZoomedIn.value)
|
||||||
|
handleZoomDrag(delta)
|
||||||
|
else
|
||||||
|
handleSlideDrag(movement)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleZoomDrag([deltaX, deltaY]: Vector2) {
|
||||||
|
x.value -= deltaX / scale.value
|
||||||
|
y.value -= deltaY / scale.value
|
||||||
|
|
||||||
|
restrictShiftToInsideSlide()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSlideDrag([movementX, movementY]: Vector2) {
|
||||||
|
goToFocusedSlide()
|
||||||
|
|
||||||
|
if (Math.abs(movementY) > Math.abs(movementX)) // vertical movement is more than horizontal
|
||||||
|
y.value -= movementY / scale.value
|
||||||
|
else
|
||||||
|
x.value -= movementX / scale.value
|
||||||
|
|
||||||
|
if (media.length === 1)
|
||||||
|
x.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function restrictShiftToInsideSlide() {
|
||||||
|
x.value = Math.min(shiftRestrictions.value.right, Math.max(shiftRestrictions.value.left, x.value))
|
||||||
|
y.value = Math.min(shiftRestrictions.value.bottom, Math.max(shiftRestrictions.value.top, y.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
const sliderStyle = computed(() => {
|
||||||
|
const style = {
|
||||||
|
transform: `scale(${scale.value}) translate(${-x.value}px, ${-y.value}px)`,
|
||||||
|
transition: 'none',
|
||||||
|
gap: `${slideGap}px`,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canAnimate.value && !isDragging.value && !isPinching.value)
|
||||||
|
style.transition = 'all 0.3s ease'
|
||||||
|
|
||||||
|
return style
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const imageStyle = computed(() => ({
|
||||||
|
cursor: isDragging.value ? 'grabbing' : 'grab',
|
||||||
|
}))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="target" flex flex-row max-h-full max-w-full overflow-hidden>
|
<div ref="view" flex flex-row h-full w-full overflow-hidden>
|
||||||
<div flex :style="{ transform: `translateX(${distanceX}%) translateY(${distanceY}%)`, transition: isSwiping ? 'none' : canAnimate ? 'all 0.5s ease' : 'none' }">
|
<div ref="slider" :style="sliderStyle" w-full h-full flex items-center>
|
||||||
<div v-for="item in media" :key="item.id" p4 select-none w-full flex-shrink-0 flex flex-col place-items-center>
|
<div
|
||||||
<img max-h-full max-w-full :draggable="false" select-none :src="item.url || item.previewUrl" :alt="item.description || ''">
|
v-for="item in media"
|
||||||
|
:key="item.id"
|
||||||
|
ref="slide"
|
||||||
|
flex-shrink-0
|
||||||
|
w-full
|
||||||
|
h-full
|
||||||
|
flex
|
||||||
|
items-center
|
||||||
|
justify-center
|
||||||
|
>
|
||||||
|
<component
|
||||||
|
:is="item.type === 'gifv' ? 'video' : 'img'"
|
||||||
|
ref="image"
|
||||||
|
:autoplay="enableAutoplay"
|
||||||
|
controls
|
||||||
|
loop
|
||||||
|
select-none
|
||||||
|
max-w-full
|
||||||
|
max-h-full
|
||||||
|
:style="imageStyle"
|
||||||
|
:draggable="false"
|
||||||
|
:src="item.url || item.previewUrl"
|
||||||
|
:alt="item.description || ''"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,8 +1,12 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// only one icon can be lit up at the same time
|
// only one icon can be lit up at the same time
|
||||||
|
import { STORAGE_KEY_LAST_ACCESSED_EXPLORE_ROUTE, STORAGE_KEY_LAST_ACCESSED_NOTIFICATION_ROUTE } from '~/constants'
|
||||||
|
|
||||||
const moreMenuVisible = ref(false)
|
const moreMenuVisible = ref(false)
|
||||||
|
|
||||||
const { notifications } = useNotifications()
|
const { notifications } = useNotifications()
|
||||||
|
const lastAccessedNotificationRoute = useLocalStorage(STORAGE_KEY_LAST_ACCESSED_NOTIFICATION_ROUTE, '')
|
||||||
|
const lastAccessedExploreRoute = useLocalStorage(STORAGE_KEY_LAST_ACCESSED_EXPLORE_ROUTE, '')
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -16,10 +20,10 @@ const { notifications } = useNotifications()
|
||||||
<NuxtLink to="/home" :aria-label="$t('nav.home')" :active-class="moreMenuVisible ? '' : 'text-primary'" flex flex-row items-center place-content-center h-full flex-1 class="coarse-pointer:select-none" @click="$scrollToTop">
|
<NuxtLink to="/home" :aria-label="$t('nav.home')" :active-class="moreMenuVisible ? '' : 'text-primary'" flex flex-row items-center place-content-center h-full flex-1 class="coarse-pointer:select-none" @click="$scrollToTop">
|
||||||
<div i-ri:home-5-line />
|
<div i-ri:home-5-line />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink :to="isHydrated ? `/${currentServer}/explore` : '/explore'" :aria-label="$t('nav.explore')" :active-class="moreMenuVisible ? '' : 'text-primary'" flex flex-row items-center place-content-center h-full flex-1 class="coarse-pointer:select-none" @click="$scrollToTop">
|
<NuxtLink to="/search" :aria-label="$t('nav.search')" :active-class="moreMenuVisible ? '' : 'text-primary'" flex flex-row items-center place-content-center h-full flex-1 class="coarse-pointer:select-none" @click="$scrollToTop">
|
||||||
<div i-ri:search-line />
|
<div i-ri:search-line />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink to="/notifications" :aria-label="$t('nav.notifications')" :active-class="moreMenuVisible ? '' : 'text-primary'" flex flex-row items-center place-content-center h-full flex-1 class="coarse-pointer:select-none" @click="$scrollToTop">
|
<NuxtLink :to="`/notifications/${lastAccessedNotificationRoute}`" :aria-label="$t('nav.notifications')" :active-class="moreMenuVisible ? '' : 'text-primary'" flex flex-row items-center place-content-center h-full flex-1 class="coarse-pointer:select-none" @click="$scrollToTop">
|
||||||
<div flex relative>
|
<div flex relative>
|
||||||
<div class="i-ri:notification-4-line" text-xl />
|
<div class="i-ri:notification-4-line" text-xl />
|
||||||
<div v-if="notifications" class="top-[-0.3rem] right-[-0.3rem]" absolute font-bold rounded-full h-4 w-4 text-xs bg-primary text-inverted flex items-center justify-center>
|
<div v-if="notifications" class="top-[-0.3rem] right-[-0.3rem]" absolute font-bold rounded-full h-4 w-4 text-xs bg-primary text-inverted flex items-center justify-center>
|
||||||
|
@ -32,8 +36,8 @@ const { notifications } = useNotifications()
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<NuxtLink :to="`/${currentServer}/explore`" :aria-label="$t('nav.explore')" :active-class="moreMenuVisible ? '' : 'text-primary'" flex flex-row items-center place-content-center h-full flex-1 class="coarse-pointer:select-none" @click="$scrollToTop">
|
<NuxtLink :to="`/${currentServer}/explore/${lastAccessedExploreRoute}`" :aria-label="$t('nav.explore')" :active-class="moreMenuVisible ? '' : 'text-primary'" flex flex-row items-center place-content-center h-full flex-1 class="coarse-pointer:select-none" @click="$scrollToTop">
|
||||||
<div i-ri:hashtag />
|
<div i-ri:compass-3-line />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink group :to="`/${currentServer}/public/local`" :aria-label="$t('nav.local')" :active-class="moreMenuVisible ? '' : 'text-primary'" flex flex-row items-center place-content-center h-full flex-1 class="coarse-pointer:select-none" @click="$scrollToTop">
|
<NuxtLink group :to="`/${currentServer}/public/local`" :aria-label="$t('nav.local')" :active-class="moreMenuVisible ? '' : 'text-primary'" flex flex-row items-center place-content-center h-full flex-1 class="coarse-pointer:select-none" @click="$scrollToTop">
|
||||||
<div i-ri:group-2-line />
|
<div i-ri:group-2-line />
|
||||||
|
@ -43,14 +47,14 @@ const { notifications } = useNotifications()
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</template>
|
</template>
|
||||||
<NavBottomMoreMenu v-slot="{ toggleVisible, show }" v-model="moreMenuVisible" flex flex-row items-center place-content-center h-full flex-1 cursor-pointer>
|
<NavBottomMoreMenu v-slot="{ toggleVisible, show }" v-model="moreMenuVisible" flex flex-row items-center place-content-center h-full flex-1 cursor-pointer>
|
||||||
<label
|
<button
|
||||||
flex items-center place-content-center h-full flex-1 class="select-none"
|
flex items-center place-content-center h-full flex-1 class="select-none"
|
||||||
:class="show ? '!text-primary' : ''"
|
:class="show ? '!text-primary' : ''"
|
||||||
|
aria-label="More menu"
|
||||||
|
@click="toggleVisible"
|
||||||
>
|
>
|
||||||
<input type="checkbox" z="-1" absolute inset-0 opacity-0 @click="toggleVisible">
|
<span :class="show ? 'i-ri:close-fill' : 'i-ri:more-fill'" />
|
||||||
<span v-show="show" i-ri:close-fill />
|
</button>
|
||||||
<span v-show="!show" i-ri:more-fill />
|
|
||||||
</label>
|
|
||||||
</NavBottomMoreMenu>
|
</NavBottomMoreMenu>
|
||||||
</nav>
|
</nav>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -1,22 +1,27 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
let { modelValue } = $defineModel<{
|
import { invoke } from '@vueuse/core'
|
||||||
modelValue: boolean
|
|
||||||
}>()
|
const modelValue = defineModel<boolean>({ required: true })
|
||||||
const colorMode = useColorMode()
|
const colorMode = useColorMode()
|
||||||
|
|
||||||
const userSettings = useUserSettings()
|
const userSettings = useUserSettings()
|
||||||
|
|
||||||
|
const drawerEl = ref<HTMLDivElement>()
|
||||||
|
|
||||||
function toggleVisible() {
|
function toggleVisible() {
|
||||||
modelValue = !modelValue
|
modelValue.value = !modelValue.value
|
||||||
}
|
}
|
||||||
|
|
||||||
const buttonEl = ref<HTMLDivElement>()
|
const buttonEl = ref<HTMLDivElement>()
|
||||||
/** Close the drop-down menu if the mouse click is not on the drop-down menu button when the drop-down menu is opened */
|
/**
|
||||||
|
* Close the drop-down menu if the mouse click is not on the drop-down menu button when the drop-down menu is opened
|
||||||
|
* @param mouse
|
||||||
|
*/
|
||||||
function clickEvent(mouse: MouseEvent) {
|
function clickEvent(mouse: MouseEvent) {
|
||||||
if (mouse.target && !buttonEl.value?.children[0].contains(mouse.target as any)) {
|
if (mouse.target && !buttonEl.value?.children[0].contains(mouse.target as any)) {
|
||||||
if (modelValue) {
|
if (modelValue.value) {
|
||||||
document.removeEventListener('click', clickEvent)
|
document.removeEventListener('click', clickEvent)
|
||||||
modelValue = false
|
modelValue.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -25,7 +30,7 @@ function toggleDark() {
|
||||||
colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
|
colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
|
||||||
}
|
}
|
||||||
|
|
||||||
watch($$(modelValue), (val) => {
|
watch(modelValue, (val) => {
|
||||||
if (val && typeof document !== 'undefined')
|
if (val && typeof document !== 'undefined')
|
||||||
document.addEventListener('click', clickEvent)
|
document.addEventListener('click', clickEvent)
|
||||||
})
|
})
|
||||||
|
@ -33,6 +38,80 @@ watch($$(modelValue), (val) => {
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
document.removeEventListener('click', clickEvent)
|
document.removeEventListener('click', clickEvent)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Pull down to close
|
||||||
|
const { dragging, dragDistance } = invoke(() => {
|
||||||
|
const triggerDistance = 120
|
||||||
|
|
||||||
|
let scrollTop = 0
|
||||||
|
let beforeTouchPointY = 0
|
||||||
|
|
||||||
|
const dragDistance = ref(0)
|
||||||
|
const dragging = ref(false)
|
||||||
|
|
||||||
|
useEventListener(drawerEl, 'scroll', (e: Event) => {
|
||||||
|
scrollTop = (e.target as HTMLDivElement).scrollTop
|
||||||
|
|
||||||
|
// Prevent the page from scrolling when the drawer is being dragged.
|
||||||
|
if (dragDistance.value > 0)
|
||||||
|
(e.target as HTMLDivElement).scrollTop = 0
|
||||||
|
}, { passive: true })
|
||||||
|
|
||||||
|
useEventListener(drawerEl, 'touchstart', (e: TouchEvent) => {
|
||||||
|
if (!modelValue.value)
|
||||||
|
return
|
||||||
|
|
||||||
|
beforeTouchPointY = e.touches[0].pageY
|
||||||
|
dragDistance.value = 0
|
||||||
|
}, { passive: true })
|
||||||
|
|
||||||
|
useEventListener(drawerEl, 'touchmove', (e: TouchEvent) => {
|
||||||
|
if (!modelValue.value)
|
||||||
|
return
|
||||||
|
|
||||||
|
// Do not move the entire drawer when its contents are not scrolled to the top.
|
||||||
|
if (scrollTop > 0 && dragDistance.value <= 0) {
|
||||||
|
dragging.value = false
|
||||||
|
beforeTouchPointY = e.touches[0].pageY
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { pageY } = e.touches[0]
|
||||||
|
|
||||||
|
// Calculate the drag distance.
|
||||||
|
dragDistance.value += pageY - beforeTouchPointY
|
||||||
|
if (dragDistance.value < 0)
|
||||||
|
dragDistance.value = 0
|
||||||
|
beforeTouchPointY = pageY
|
||||||
|
|
||||||
|
// Marked as dragging.
|
||||||
|
if (dragDistance.value > 1)
|
||||||
|
dragging.value = true
|
||||||
|
|
||||||
|
// Prevent the page from scrolling when the drawer is being dragged.
|
||||||
|
if (dragDistance.value > 0) {
|
||||||
|
if (e?.cancelable && e?.preventDefault)
|
||||||
|
e.preventDefault()
|
||||||
|
e?.stopPropagation()
|
||||||
|
}
|
||||||
|
}, { passive: true })
|
||||||
|
|
||||||
|
useEventListener(drawerEl, 'touchend', () => {
|
||||||
|
if (!modelValue.value)
|
||||||
|
return
|
||||||
|
|
||||||
|
if (dragDistance.value >= triggerDistance)
|
||||||
|
modelValue.value = false
|
||||||
|
|
||||||
|
dragging.value = false
|
||||||
|
// code
|
||||||
|
}, { passive: true })
|
||||||
|
|
||||||
|
return {
|
||||||
|
dragDistance,
|
||||||
|
dragging,
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -41,12 +120,12 @@ onBeforeUnmount(() => {
|
||||||
|
|
||||||
<!-- Drawer -->
|
<!-- Drawer -->
|
||||||
<Transition
|
<Transition
|
||||||
enter-active-class="transition duration-250 ease-out children:(transition duration-250 ease-out)"
|
enter-active-class="transition duration-250 ease-out"
|
||||||
enter-from-class="opacity-0 children:(transform translate-y-full)"
|
enter-from-class="opacity-0 children:(translate-y-full)"
|
||||||
enter-to-class="opacity-100 children:(transform translate-y-0)"
|
enter-to-class="opacity-100 children:(translate-y-0)"
|
||||||
leave-active-class="transition duration-250 ease-in children:(transition duration-250 ease-in)"
|
leave-active-class="transition duration-250 ease-in"
|
||||||
leave-from-class="opacity-100 children:(transform translate-y-0)"
|
leave-from-class="opacity-100 children:(translate-y-0)"
|
||||||
leave-to-class="opacity-0 children:(transform translate-y-full)"
|
leave-to-class="opacity-0 children:(translate-y-full)"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-show="modelValue"
|
v-show="modelValue"
|
||||||
|
@ -58,10 +137,19 @@ onBeforeUnmount(() => {
|
||||||
<!-- corresponding to issue: #106, so please don't remove it. -->
|
<!-- corresponding to issue: #106, so please don't remove it. -->
|
||||||
<div absolute inset-0 opacity-0 h="[calc(100vh+0.5px)]" />
|
<div absolute inset-0 opacity-0 h="[calc(100vh+0.5px)]" />
|
||||||
<div
|
<div
|
||||||
|
ref="drawerEl"
|
||||||
|
:style="{
|
||||||
|
transform: dragging ? `translateY(${dragDistance}px)` : '',
|
||||||
|
}"
|
||||||
|
:class="{
|
||||||
|
'duration-0': dragging,
|
||||||
|
'duration-250': !dragging,
|
||||||
|
'backdrop-blur-md': !getPreferences(userSettings, 'optimizeForLowPerformanceDevice'),
|
||||||
|
}"
|
||||||
|
transition="transform ease-in"
|
||||||
flex-1 min-w-48 py-6 mb="-1px"
|
flex-1 min-w-48 py-6 mb="-1px"
|
||||||
of-y-auto scrollbar-hide overscroll-none max-h="[calc(100vh-200px)]"
|
of-y-auto scrollbar-hide overscroll-none max-h="[calc(100vh-200px)]"
|
||||||
rounded-t-lg bg="white/85 dark:neutral-900/85" backdrop-filter backdrop-blur-md
|
rounded-t-lg bg="white/85 dark:neutral-900/85" backdrop-filter
|
||||||
border-t-1 border-base
|
border-t-1 border-base
|
||||||
>
|
>
|
||||||
<!-- Nav -->
|
<!-- Nav -->
|
||||||
|
@ -93,9 +181,9 @@ onBeforeUnmount(() => {
|
||||||
transition-colors duration-200 transform
|
transition-colors duration-200 transform
|
||||||
hover="bg-gray-100 dark:(bg-gray-700 text-white)"
|
hover="bg-gray-100 dark:(bg-gray-700 text-white)"
|
||||||
:aria-label="$t('nav.zen_mode')"
|
:aria-label="$t('nav.zen_mode')"
|
||||||
@click="userSettings.zenMode = !userSettings.zenMode"
|
@click="togglePreferences('zenMode')"
|
||||||
>
|
>
|
||||||
<span :class="userSettings.zenMode ? 'i-ri:layout-right-2-line' : 'i-ri:layout-right-line'" class="flex-shrink-0 text-xl me-4 !align-middle" />
|
<span :class="getPreferences(userSettings, 'zenMode') ? 'i-ri:layout-right-2-line' : 'i-ri:layout-right-line'" class="flex-shrink-0 text-xl me-4 !align-middle" />
|
||||||
{{ $t('nav.zen_mode') }}
|
{{ $t('nav.zen_mode') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -23,11 +23,14 @@ function toggleDark() {
|
||||||
<button
|
<button
|
||||||
flex
|
flex
|
||||||
text-lg
|
text-lg
|
||||||
:class="userSettings.zenMode ? 'i-ri:layout-right-2-line' : 'i-ri:layout-right-line'"
|
:class="getPreferences(userSettings, 'zenMode') ? 'i-ri:layout-right-2-line' : 'i-ri:layout-right-line'"
|
||||||
:aria-label="$t('nav.zen_mode')"
|
:aria-label="$t('nav.zen_mode')"
|
||||||
@click="userSettings.zenMode = !userSettings.zenMode"
|
@click="togglePreferences('zenMode')"
|
||||||
/>
|
/>
|
||||||
</CommonTooltip>
|
</CommonTooltip>
|
||||||
|
<CommonTooltip :content="$t('magic_keys.dialog_header')">
|
||||||
|
<button flex i-ri:keyboard-box-line dark-i-ri:keyboard-box-line text-lg :aria-label="$t('magic_keys.dialog_header')" @click="toggleKeyboardShortcuts" />
|
||||||
|
</CommonTooltip>
|
||||||
<CommonTooltip :content="$t('settings.about.sponsor_action')">
|
<CommonTooltip :content="$t('settings.about.sponsor_action')">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
flex
|
flex
|
||||||
|
|
|
@ -3,37 +3,43 @@
|
||||||
xmlns="http://www.w3.org/2000/svg" w-full
|
xmlns="http://www.w3.org/2000/svg" w-full
|
||||||
aspect="1/1" sm:h-8 xl:h-10 sm:w-8 xl:w-10 viewBox="0 0 250 250" fill="none"
|
aspect="1/1" sm:h-8 xl:h-10 sm:w-8 xl:w-10 viewBox="0 0 250 250" fill="none"
|
||||||
>
|
>
|
||||||
<mask
|
<mask
|
||||||
id="a"
|
id="a"
|
||||||
width="240"
|
width="240"
|
||||||
height="234"
|
height="234"
|
||||||
x="4"
|
x="4"
|
||||||
y="1"
|
y="1"
|
||||||
maskUnits="userSpaceOnUse"
|
maskUnits="userSpaceOnUse"
|
||||||
style="mask-type:alpha">
|
style="mask-type:alpha"
|
||||||
<path
|
>
|
||||||
fill="#D9D9D9"
|
<path
|
||||||
d="M244 123c0 64.617-38.383 112-103 112-64.617 0-103-30.883-103-95.5C38 111.194-8.729 36.236 8 16 29.46-9.959 88.689 6 125 6c64.617 0 119 52.383 119 117Z"
|
id="path19"
|
||||||
id="path19" />
|
fill="#D9D9D9"
|
||||||
</mask>
|
d="M244 123c0 64.617-38.383 112-103 112-64.617 0-103-30.883-103-95.5C38 111.194-8.729 36.236 8 16 29.46-9.959 88.689 6 125 6c64.617 0 119 52.383 119 117Z"
|
||||||
<g
|
/>
|
||||||
mask="url(#a)"
|
</mask>
|
||||||
id="g28"
|
<g
|
||||||
transform="matrix(0.90923731,0,0,1.0049564,13.520015,-3.1040835)">
|
id="g28"
|
||||||
<path
|
mask="url(#a)"
|
||||||
class="body"
|
transform="matrix(0.90923731,0,0,1.0049564,13.520015,-3.1040835)"
|
||||||
d="m 116.94,88.1 c -13.344,1.552 -20.436,-2.019 -24.706,10.71 0,0 14.336,21.655 52.54,21.112 -2.135,8.848 -1.144,15.368 -1.144,23.207 0,26.079 -20.589,48.821 -65.961,48.821 -23.03,0 -51.015,4.191 -72.367,15.911 -15.175,8.305 -27.048,20.336 -32.302,37.023 l 5.956,8.461 11.4,0.155 v 47.889 l -13.91,21.966 3.998,63.645 H -6.364 L -5.22,335.773 C 1.338,331.892 16.36,321.802 29.171,306.279 46.557,285.4 59.902,255.052 44.193,217.486 l 11.744,-5.045 c 12.887,30.814 8.388,57.514 -2.898,79.013 21.58,-0.698 40.11,-2.095 55.819,-4.734 l -3.584,-43.698 12.659,-1.087 L 129.98,387 h 13.116 l 2.212,-94.459 c 10.447,-4.502 34.239,-21.034 45.372,-78.47 1.372,-6.986 2.135,-12.885 2.516,-17.93 1.754,-12.806 2.745,-27.243 3.051,-43.698 l -18.683,-5.976 h 57.42 l 5.567,-12.807 c -5.414,0.233 -11.896,-2.639 -11.896,-2.639 l 1.297,-6.209 H 242 L 176.801,90.428 c -7.244,2.794 -14.87,6.442 -20.208,10.866 -4.27,-3.105 -19.063,-12.807 -39.653,-13.195 z"
|
>
|
||||||
id="path22" />
|
<path
|
||||||
<path
|
id="path22"
|
||||||
class="wood"
|
class="body"
|
||||||
d="M 6.217,24.493 18.494,21 c 5.948,21.577 13.345,33.375 22.648,39.352 8.388,5.099 19.75,5.239 31.799,4.579 C 69.433,63.767 66.154,62.137 63.104,59.886 56.317,54.841 50.522,46.458 46.175,31.246 l 12.201,-3.649 c 3.279,11.488 7.092,18.085 12.201,21.888 5.11,3.726 11.286,4.657 18.606,5.433 13.726,1.553 30.884,2.174 52.312,12.264 2.898,1.086 5.872,2.483 8.769,4.036 -0.381,-0.776 -0.762,-1.553 -1.296,-2.406 -3.66,-5.822 -10.828,-11.953 -24.097,-16.92 l 4.27,-12.109 c 21.581,7.917 30.121,19.171 33.553,28.097 3.965,10.168 1.525,18.124 1.525,18.124 -3.05,1.009 -6.1,2.406 -9.608,3.492 -6.634,-4.579 -12.887,-8.033 -18.835,-10.75 C 113.814,70.442 92.31,76.108 73.246,77.893 58.91,79.213 45.794,78.591 34.432,71.295 23.222,64.155 13.385,50.495 6.217,24.493 Z"
|
d="m 116.94,88.1 c -13.344,1.552 -20.436,-2.019 -24.706,10.71 0,0 14.336,21.655 52.54,21.112 -2.135,8.848 -1.144,15.368 -1.144,23.207 0,26.079 -20.589,48.821 -65.961,48.821 -23.03,0 -51.015,4.191 -72.367,15.911 -15.175,8.305 -27.048,20.336 -32.302,37.023 l 5.956,8.461 11.4,0.155 v 47.889 l -13.91,21.966 3.998,63.645 H -6.364 L -5.22,335.773 C 1.338,331.892 16.36,321.802 29.171,306.279 46.557,285.4 59.902,255.052 44.193,217.486 l 11.744,-5.045 c 12.887,30.814 8.388,57.514 -2.898,79.013 21.58,-0.698 40.11,-2.095 55.819,-4.734 l -3.584,-43.698 12.659,-1.087 L 129.98,387 h 13.116 l 2.212,-94.459 c 10.447,-4.502 34.239,-21.034 45.372,-78.47 1.372,-6.986 2.135,-12.885 2.516,-17.93 1.754,-12.806 2.745,-27.243 3.051,-43.698 l -18.683,-5.976 h 57.42 l 5.567,-12.807 c -5.414,0.233 -11.896,-2.639 -11.896,-2.639 l 1.297,-6.209 H 242 L 176.801,90.428 c -7.244,2.794 -14.87,6.442 -20.208,10.866 -4.27,-3.105 -19.063,-12.807 -39.653,-13.195 z"
|
||||||
id="path24" />
|
/>
|
||||||
<path
|
<path
|
||||||
class="wood"
|
id="path24"
|
||||||
d="M 90.098,45.294 C 87.582,39.55 86.057,32.487 86.743,23.794 l 12.659,0.932 c -0.763,10.555 2.897,17.696 7.015,22.353 -5.338,-0.931 -10.447,-1.04 -16.319,-1.785 z m 80.069,-1.32 8.312,-9.702 c 21.58,19.094 8.159,46.415 8.159,46.415 l -11.819,-1.32 c -0.382,-6.24 -1.144,-17.836 -6.635,-24.371 3.584,1.84 6.635,3.865 9.99,6.908 0,-5.666 -1.754,-12.341 -8.007,-17.93 z"
|
class="wood"
|
||||||
id="path26" />
|
d="M 6.217,24.493 18.494,21 c 5.948,21.577 13.345,33.375 22.648,39.352 8.388,5.099 19.75,5.239 31.799,4.579 C 69.433,63.767 66.154,62.137 63.104,59.886 56.317,54.841 50.522,46.458 46.175,31.246 l 12.201,-3.649 c 3.279,11.488 7.092,18.085 12.201,21.888 5.11,3.726 11.286,4.657 18.606,5.433 13.726,1.553 30.884,2.174 52.312,12.264 2.898,1.086 5.872,2.483 8.769,4.036 -0.381,-0.776 -0.762,-1.553 -1.296,-2.406 -3.66,-5.822 -10.828,-11.953 -24.097,-16.92 l 4.27,-12.109 c 21.581,7.917 30.121,19.171 33.553,28.097 3.965,10.168 1.525,18.124 1.525,18.124 -3.05,1.009 -6.1,2.406 -9.608,3.492 -6.634,-4.579 -12.887,-8.033 -18.835,-10.75 C 113.814,70.442 92.31,76.108 73.246,77.893 58.91,79.213 45.794,78.591 34.432,71.295 23.222,64.155 13.385,50.495 6.217,24.493 Z"
|
||||||
</g>
|
/>
|
||||||
</svg>
|
<path
|
||||||
|
id="path26"
|
||||||
|
class="wood"
|
||||||
|
d="M 90.098,45.294 C 87.582,39.55 86.057,32.487 86.743,23.794 l 12.659,0.932 c -0.763,10.555 2.897,17.696 7.015,22.353 -5.338,-0.931 -10.447,-1.04 -16.319,-1.785 z m 80.069,-1.32 8.312,-9.702 c 21.58,19.094 8.159,46.415 8.159,46.415 l -11.819,-1.32 c -0.382,-6.24 -1.144,-17.836 -6.635,-24.371 3.584,1.84 6.635,3.865 9.99,6.908 0,-5.666 -1.754,-12.341 -8.007,-17.93 z"
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -1,18 +1,22 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { STORAGE_KEY_LAST_ACCESSED_EXPLORE_ROUTE, STORAGE_KEY_LAST_ACCESSED_NOTIFICATION_ROUTE } from '~/constants'
|
||||||
|
|
||||||
const { command } = defineProps<{
|
const { command } = defineProps<{
|
||||||
command?: boolean
|
command?: boolean
|
||||||
}>()
|
}>()
|
||||||
const { notifications } = useNotifications()
|
const { notifications } = useNotifications()
|
||||||
|
const useStarFavoriteIcon = usePreferences('useStarFavoriteIcon')
|
||||||
|
const lastAccessedNotificationRoute = useLocalStorage(STORAGE_KEY_LAST_ACCESSED_NOTIFICATION_ROUTE, '')
|
||||||
|
const lastAccessedExploreRoute = useLocalStorage(STORAGE_KEY_LAST_ACCESSED_EXPLORE_ROUTE, '')
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<nav sm:px3 flex="~ col gap2" shrink text-size-base leading-normal md:text-lg h-full mt-1>
|
<nav sm:px3 flex="~ col gap2" shrink text-size-base leading-normal md:text-lg h-full mt-1 overflow-y-auto>
|
||||||
<SearchWidget lg:ms-1 lg:me-5 hidden xl:block />
|
<NavSideItem :text="$t('nav.search')" to="/search" icon="i-ri:search-line" xl:hidden :command="command" />
|
||||||
<NavSideItem :text="$t('nav.search')" :to="isHydrated ? `/${currentServer}/explore` : '/explore'" icon="i-ri:search-line" hidden sm:block xl:hidden :command="command" />
|
|
||||||
|
|
||||||
<div shrink hidden sm:block mt-2 />
|
<div class="spacer" shrink xl:hidden />
|
||||||
<NavSideItem :text="$t('nav.home')" to="/home" icon="i-ri:home-5-line" user-only :command="command" />
|
<NavSideItem :text="$t('nav.home')" to="/home" icon="i-ri:home-5-line" user-only :command="command" />
|
||||||
<NavSideItem :text="$t('nav.notifications')" to="/notifications" icon="i-ri:notification-4-line" user-only :command="command">
|
<NavSideItem :text="$t('nav.notifications')" :to="`/notifications/${lastAccessedNotificationRoute}`" icon="i-ri:notification-4-line" user-only :command="command">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<div flex relative>
|
<div flex relative>
|
||||||
<div class="i-ri:notification-4-line" text-xl />
|
<div class="i-ri:notification-4-line" text-xl />
|
||||||
|
@ -23,17 +27,31 @@ const { notifications } = useNotifications()
|
||||||
</template>
|
</template>
|
||||||
</NavSideItem>
|
</NavSideItem>
|
||||||
<NavSideItem :text="$t('nav.conversations')" to="/conversations" icon="i-ri:at-line" user-only :command="command" />
|
<NavSideItem :text="$t('nav.conversations')" to="/conversations" icon="i-ri:at-line" user-only :command="command" />
|
||||||
<NavSideItem :text="$t('nav.favourites')" to="/favourites" icon="i-ri:heart-3-line" user-only :command="command" />
|
<NavSideItem :text="$t('nav.favourites')" to="/favourites" :icon="useStarFavoriteIcon ? 'i-ri:star-line' : 'i-ri:heart-3-line'" user-only :command="command" />
|
||||||
<NavSideItem :text="$t('nav.bookmarks')" to="/bookmarks" icon="i-ri:bookmark-line" user-only :command="command" />
|
<NavSideItem :text="$t('nav.bookmarks')" to="/bookmarks" icon="i-ri:bookmark-line" user-only :command="command" />
|
||||||
|
|
||||||
|
<div class="spacer" shrink hidden sm:block />
|
||||||
<NavSideItem :text="$t('action.compose')" to="/compose" icon="i-ri:quill-pen-line" user-only :command="command" />
|
<NavSideItem :text="$t('action.compose')" to="/compose" icon="i-ri:quill-pen-line" user-only :command="command" />
|
||||||
|
|
||||||
<div shrink hidden sm:block mt-4 />
|
<div class="spacer" shrink hidden sm:block />
|
||||||
<NavSideItem :text="$t('nav.explore')" :to="isHydrated ? `/${currentServer}/explore` : '/explore'" icon="i-ri:hashtag" :command="command" xs:hidden sm:hidden xl:block />
|
<NavSideItem :text="$t('nav.explore')" :to="isHydrated ? `/${currentServer}/explore/${lastAccessedExploreRoute}` : `/explore/${lastAccessedExploreRoute}`" icon="i-ri:compass-3-line" :command="command" />
|
||||||
<NavSideItem :text="$t('nav.local')" :to="isHydrated ? `/${currentServer}/public/local` : '/public/local'" icon="i-ri:group-2-line " :command="command" />
|
<NavSideItem :text="$t('nav.local')" :to="isHydrated ? `/${currentServer}/public/local` : '/public/local'" icon="i-ri:group-2-line " :command="command" />
|
||||||
<NavSideItem :text="$t('nav.federated')" :to="isHydrated ? `/${currentServer}/public` : '/public'" icon="i-ri:earth-line" :command="command" />
|
<NavSideItem :text="$t('nav.federated')" :to="isHydrated ? `/${currentServer}/public` : '/public'" icon="i-ri:earth-line" :command="command" />
|
||||||
<NavSideItem :text="$t('nav.lists')" :to="isHydrated ? `/${currentServer}/lists` : '/lists'" icon="i-ri:list-check" user-only :command="command" />
|
<NavSideItem :text="$t('nav.lists')" :to="isHydrated ? `/${currentServer}/lists` : '/lists'" icon="i-ri:list-check" user-only :command="command" />
|
||||||
|
<NavSideItem :text="$t('nav.hashtags')" to="/hashtags" icon="i-ri:hashtag" user-only :command="command" />
|
||||||
|
|
||||||
<div shrink hidden sm:block mt-4 />
|
<div class="spacer" shrink hidden sm:block />
|
||||||
<NavSideItem :text="$t('nav.settings')" to="/settings" icon="i-ri:settings-3-line" :command="command" />
|
<NavSideItem :text="$t('nav.settings')" to="/settings" icon="i-ri:settings-3-line" :command="command" />
|
||||||
</nav>
|
</nav>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.spacer {
|
||||||
|
margin-top: 0.5em;
|
||||||
|
}
|
||||||
|
@media screen and ( max-height: 920px ) and ( min-width: 640px ) {
|
||||||
|
.spacer {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
@ -10,8 +10,8 @@ const props = withDefaults(defineProps<{
|
||||||
})
|
})
|
||||||
|
|
||||||
defineSlots<{
|
defineSlots<{
|
||||||
icon: {}
|
icon: (props: object) => void
|
||||||
default: {}
|
default: (props: object) => void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
@ -28,13 +28,13 @@ useCommand({
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
let activeClass = $ref('text-primary')
|
const activeClass = ref('text-primary')
|
||||||
onHydrated(async () => {
|
onHydrated(async () => {
|
||||||
// TODO: force NuxtLink to reevaluate, we now we are in this route though, so we should force it to active
|
// TODO: force NuxtLink to reevaluate, we now we are in this route though, so we should force it to active
|
||||||
// we don't have currentServer defined until later
|
// we don't have currentServer defined until later
|
||||||
activeClass = ''
|
activeClass.value = ''
|
||||||
await nextTick()
|
await nextTick()
|
||||||
activeClass = 'text-primary'
|
activeClass.value = 'text-primary'
|
||||||
})
|
})
|
||||||
|
|
||||||
// Optimize rendering for the common case of being logged in, only show visual feedback for disabled user-only items
|
// Optimize rendering for the common case of being logged in, only show visual feedback for disabled user-only items
|
||||||
|
@ -55,12 +55,23 @@ const noUserVisual = computed(() => isHydrated.value && props.userOnly && !curre
|
||||||
>
|
>
|
||||||
<CommonTooltip :disabled="!isMediumOrLargeScreen" :content="text" placement="right">
|
<CommonTooltip :disabled="!isMediumOrLargeScreen" :content="text" placement="right">
|
||||||
<div
|
<div
|
||||||
|
class="item"
|
||||||
flex items-center gap4
|
flex items-center gap4
|
||||||
w-fit rounded-3
|
|
||||||
px2 py2 mx3 sm:mxa
|
|
||||||
xl="ml0 mr5 px5 w-auto"
|
xl="ml0 mr5 px5 w-auto"
|
||||||
transition-100
|
:class="isSmallScreen
|
||||||
elk-group-hover="bg-active" group-focus-visible:ring="2 current"
|
? `
|
||||||
|
w-full
|
||||||
|
px5 sm:mxa
|
||||||
|
transition-colors duration-200 transform
|
||||||
|
hover-bg-gray-100 hover-dark:(bg-gray-700 text-white)
|
||||||
|
` : `
|
||||||
|
w-fit rounded-3
|
||||||
|
px2 mx3 sm:mxa
|
||||||
|
transition-100
|
||||||
|
elk-group-hover-bg-active
|
||||||
|
group-focus-visible:ring-2
|
||||||
|
group-focus-visible:ring-current
|
||||||
|
`"
|
||||||
>
|
>
|
||||||
<slot name="icon">
|
<slot name="icon">
|
||||||
<div :class="icon" text-xl />
|
<div :class="icon" text-xl />
|
||||||
|
@ -72,3 +83,28 @@ const noUserVisual = computed(() => isHydrated.value && props.userOnly && !curre
|
||||||
</CommonTooltip>
|
</CommonTooltip>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.item {
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
@media screen and ( max-height: 820px ) and ( min-width: 1280px ) {
|
||||||
|
.item {
|
||||||
|
padding-top: 0.25rem;
|
||||||
|
padding-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media screen and ( max-height: 780px ) and ( min-width: 640px ) {
|
||||||
|
.item {
|
||||||
|
padding-top: 0.35rem;
|
||||||
|
padding-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media screen and ( max-height: 780px ) and ( min-width: 1280px ) {
|
||||||
|
.item {
|
||||||
|
padding-top: 0.05rem;
|
||||||
|
padding-bottom: 0.05rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
@ -5,7 +5,7 @@ const back = ref<any>('')
|
||||||
|
|
||||||
const nuxtApp = useNuxtApp()
|
const nuxtApp = useNuxtApp()
|
||||||
|
|
||||||
const onClickLogo = () => {
|
function onClickLogo() {
|
||||||
nuxtApp.hooks.callHook('elk-logo:click')
|
nuxtApp.hooks.callHook('elk-logo:click')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -29,20 +29,22 @@ router.afterEach(() => {
|
||||||
@click.prevent="onClickLogo"
|
@click.prevent="onClickLogo"
|
||||||
>
|
>
|
||||||
<NavLogo shrink-0 aspect="1/1" sm:h-8 xl:h-10 class="rtl-flip" />
|
<NavLogo shrink-0 aspect="1/1" sm:h-8 xl:h-10 class="rtl-flip" />
|
||||||
<div hidden xl:block text-secondary>
|
<div v-show="isHydrated" hidden xl:block text-secondary>
|
||||||
{{ $t('app_name') }} <sup text-sm italic mt-1>{{ env === 'release' ? 'alpha' : env }}</sup>
|
{{ $t('app_name') }} <sup text-sm italic mt-1>{{ env === 'release' ? 'alpha' : env }}</sup>
|
||||||
</div>
|
</div>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<div
|
<div
|
||||||
hidden xl:flex items-center me-8 mt-2
|
hidden xl:flex items-center me-8 mt-2 gap-1
|
||||||
:class="{ 'pointer-events-none op0': !back || back === '/', 'xl:flex': $route.name !== 'tag' }"
|
|
||||||
>
|
>
|
||||||
<NuxtLink
|
<CommonTooltip :content="$t('nav.back')">
|
||||||
:aria-label="$t('nav.back')"
|
<NuxtLink
|
||||||
@click="$router.go(-1)"
|
:aria-label="$t('nav.back')"
|
||||||
>
|
:class="{ 'pointer-events-none op0': !back || back === '/', 'xl:flex': $route.name !== 'tag' }"
|
||||||
<div i-ri:arrow-left-line class="rtl-flip" btn-text />
|
@click="$router.go(-1)"
|
||||||
</NuxtLink>
|
>
|
||||||
|
<div text-xl i-ri:arrow-left-line class="rtl-flip" btn-text />
|
||||||
|
</NuxtLink>
|
||||||
|
</CommonTooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -34,7 +34,13 @@ const { busy, oauth, singleInstanceServer } = useSignIn()
|
||||||
<strong>{{ currentServer }}</strong>
|
<strong>{{ currentServer }}</strong>
|
||||||
</i18n-t>
|
</i18n-t>
|
||||||
</button>
|
</button>
|
||||||
<button v-else btn-solid text-sm px-2 py-1 text-center xl:hidden @click="openSigninDialog()">
|
<button
|
||||||
|
v-else
|
||||||
|
flex="~ row"
|
||||||
|
gap-x-1 items-center justify-center btn-solid text-sm px-2 py-1 xl:hidden
|
||||||
|
@click="openSigninDialog()"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true" block i-ri:login-circle-line class="rtl-flip" />
|
||||||
{{ $t('action.sign_in') }}
|
{{ $t('action.sign_in') }}
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -4,6 +4,13 @@ import type { mastodon } from 'masto'
|
||||||
const { notification } = defineProps<{
|
const { notification } = defineProps<{
|
||||||
notification: mastodon.v1.Notification
|
notification: mastodon.v1.Notification
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
// well-known emoji reactions types Elk does not support yet
|
||||||
|
const unsupportedEmojiReactionTypes = ['pleroma:emoji_reaction', 'reaction']
|
||||||
|
if (unsupportedEmojiReactionTypes.includes(notification.type))
|
||||||
|
console.warn(`[DEV] ${t('notification.missing_type')} '${notification.type}' (notification.id: ${notification.id})`)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
@ -15,37 +22,62 @@ const { notification } = defineProps<{
|
||||||
ps-3 pe-4 inset-is-0
|
ps-3 pe-4 inset-is-0
|
||||||
rounded-ie-be-3
|
rounded-ie-be-3
|
||||||
py-3 bg-base top-0
|
py-3 bg-base top-0
|
||||||
:lang="notification.status?.language ?? undefined"
|
|
||||||
>
|
>
|
||||||
<div i-ri:user-follow-fill me-1 color-primary />
|
<div i-ri-user-3-line text-xl me-3 color-blue />
|
||||||
<AccountDisplayName :account="notification.account" text-primary me-1 font-bold line-clamp-1 ws-pre-wrap break-all />
|
<AccountDisplayName :account="notification.account" text-primary me-1 font-bold line-clamp-1 ws-pre-wrap break-all />
|
||||||
<span ws-nowrap>
|
<span ws-nowrap>
|
||||||
{{ $t('notification.followed_you') }}
|
{{ $t('notification.followed_you') }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<AccountBigCard
|
<AccountBigCard
|
||||||
|
ms10
|
||||||
:account="notification.account"
|
:account="notification.account"
|
||||||
:lang="notification.status?.language ?? undefined"
|
|
||||||
/>
|
/>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="notification.type === 'admin.sign_up'">
|
<template v-else-if="notification.type === 'admin.sign_up'">
|
||||||
<div flex p3 items-center bg-shaded>
|
<NuxtLink :to="getAccountRoute(notification.account)">
|
||||||
<div i-ri:admin-fill me-1 color-purple />
|
<div flex p4 items-center bg-shaded>
|
||||||
<AccountDisplayName
|
<div i-ri:user-add-line text-xl me-2 color-purple />
|
||||||
:account="notification.account"
|
<AccountDisplayName
|
||||||
text-purple me-1 font-bold line-clamp-1 ws-pre-wrap break-all
|
:account="notification.account"
|
||||||
/>
|
text-purple me-1 font-bold line-clamp-1 ws-pre-wrap break-all
|
||||||
<span>{{ $t("notification.signed_up") }}</span>
|
/>
|
||||||
</div>
|
<span>{{ $t("notification.signed_up") }}</span>
|
||||||
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="notification.type === 'admin.report'">
|
||||||
|
<NuxtLink :to="getReportRoute(notification.report?.id!)">
|
||||||
|
<div flex p4 items-center bg-shaded>
|
||||||
|
<div i-ri:flag-line text-xl me-2 color-purple />
|
||||||
|
<i18n-t keypath="notification.reported">
|
||||||
|
<AccountDisplayName
|
||||||
|
:account="notification.account"
|
||||||
|
text-purple me-1 font-bold line-clamp-1 ws-pre-wrap break-all
|
||||||
|
/>
|
||||||
|
<AccountDisplayName
|
||||||
|
:account="notification.report?.targetAccount!"
|
||||||
|
text-purple ms-1 font-bold line-clamp-1 ws-pre-wrap break-all
|
||||||
|
/>
|
||||||
|
</i18n-t>
|
||||||
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="notification.type === 'follow_request'">
|
<template v-else-if="notification.type === 'follow_request'">
|
||||||
<div flex ms-4 items-center class="-top-2.5" absolute inset-ie-2 px-2>
|
<div flex px-3 py-2>
|
||||||
<div i-ri:user-follow-fill text-xl me-1 />
|
<div i-ri-user-shared-line text-xl me-3 color-blue />
|
||||||
<AccountInlineInfo :account="notification.account" me1 />
|
<AccountDisplayName
|
||||||
|
:account="notification.account"
|
||||||
|
text-primary me-1 font-bold line-clamp-1 ws-pre-wrap break-all
|
||||||
|
/>
|
||||||
|
<span me-1 ws-nowrap>
|
||||||
|
{{ $t('notification.request_to_follow') }}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- TODO: accept request -->
|
<AccountCard p="s-2 e-4 b-2" hover-card :account="notification.account">
|
||||||
<AccountCard :account="notification.account" />
|
<AccountFollowRequestButton :account="notification.account" />
|
||||||
|
</AccountCard>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="notification.type === 'update'">
|
<template v-else-if="notification.type === 'update'">
|
||||||
<StatusCard :status="notification.status!" :in-notification="true" :actions="false">
|
<StatusCard :status="notification.status!" :in-notification="true" :actions="false">
|
||||||
|
@ -63,7 +95,8 @@ const { notification } = defineProps<{
|
||||||
<template v-else-if="notification.type === 'mention' || notification.type === 'poll' || notification.type === 'status'">
|
<template v-else-if="notification.type === 'mention' || notification.type === 'poll' || notification.type === 'status'">
|
||||||
<StatusCard :status="notification.status!" />
|
<StatusCard :status="notification.status!" />
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else-if="!unsupportedEmojiReactionTypes.includes(notification.type)">
|
||||||
|
<!-- prevent showing errors for dev for known emoji reaction types -->
|
||||||
<!-- type 'favourite' and 'reblog' should always rendered by NotificationGroupedLikes -->
|
<!-- type 'favourite' and 'reblog' should always rendered by NotificationGroupedLikes -->
|
||||||
<div text-red font-bold>
|
<div text-red font-bold>
|
||||||
[DEV] {{ $t('notification.missing_type') }} '{{ notification.type }}'
|
[DEV] {{ $t('notification.missing_type') }} '{{ notification.type }}'
|
||||||
|
|
|
@ -10,7 +10,7 @@ defineProps<{
|
||||||
defineEmits(['hide', 'subscribe'])
|
defineEmits(['hide', 'subscribe'])
|
||||||
|
|
||||||
defineSlots<{
|
defineSlots<{
|
||||||
error: {}
|
error: (props: object) => void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const xl = useMediaQuery('(min-width: 1280px)')
|
const xl = useMediaQuery('(min-width: 1280px)')
|
||||||
|
|
|
@ -5,17 +5,17 @@ const { items } = defineProps<{
|
||||||
items: GroupedNotifications
|
items: GroupedNotifications
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const count = $computed(() => items.items.length)
|
const count = computed(() => items.items.length)
|
||||||
const isExpanded = ref(false)
|
const isExpanded = ref(false)
|
||||||
const lang = $computed(() => {
|
const lang = computed(() => {
|
||||||
return (count > 1 || count === 0) ? undefined : items.items[0].status?.language
|
return (count.value > 1 || count.value === 0) ? undefined : items.items[0].status?.language
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<article flex flex-col relative :lang="lang ?? undefined">
|
<article flex flex-col relative :lang="lang ?? undefined">
|
||||||
<div flex items-center top-0 left-2 pt-2 px-3>
|
<div flex items-center top-0 left-2 pt-2 px-3>
|
||||||
<div i-ri:user-follow-fill me-3 color-primary aria-hidden="true" />
|
<div :class="count > 1 ? 'i-ri-group-line' : 'i-ri-user-3-line'" me-3 color-blue text-xl aria-hidden="true" />
|
||||||
<template v-if="count > 1">
|
<template v-if="count > 1">
|
||||||
<CommonLocalizedNumber
|
<CommonLocalizedNumber
|
||||||
keypath="notification.followed_you_count"
|
keypath="notification.followed_you_count"
|
||||||
|
@ -32,7 +32,7 @@ const lang = $computed(() => {
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div pb-2>
|
<div pb-2 ps8>
|
||||||
<div v-if="isExpanded">
|
<div v-if="isExpanded">
|
||||||
<AccountCard
|
<AccountCard
|
||||||
v-for="item in items.items"
|
v-for="item in items.items"
|
||||||
|
|
|
@ -4,17 +4,18 @@ import type { GroupedLikeNotifications } from '~/types'
|
||||||
const { group } = defineProps<{
|
const { group } = defineProps<{
|
||||||
group: GroupedLikeNotifications
|
group: GroupedLikeNotifications
|
||||||
}>()
|
}>()
|
||||||
|
const useStarFavoriteIcon = usePreferences('useStarFavoriteIcon')
|
||||||
|
|
||||||
const reblogs = $computed(() => group.likes.filter(i => i.reblog))
|
const reblogs = computed(() => group.likes.filter(i => i.reblog))
|
||||||
const likes = $computed(() => group.likes.filter(i => i.favourite && !i.reblog))
|
const likes = computed(() => group.likes.filter(i => i.favourite && !i.reblog))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<article flex flex-col relative>
|
<article flex flex-col relative>
|
||||||
<StatusLink :status="group.status!" pb2 pt3>
|
<StatusLink :status="group.status!" pb4 pt5>
|
||||||
<div flex flex-col gap-2>
|
<div flex flex-col gap-3>
|
||||||
<div v-if="reblogs.length" flex="~ gap-1">
|
<div v-if="reblogs.length" flex="~ gap-1">
|
||||||
<div i-ri:repeat-fill text-xl me-1 color-green />
|
<div i-ri:repeat-fill text-xl me-2 color-green />
|
||||||
<template v-for="i, idx of reblogs" :key="idx">
|
<template v-for="i, idx of reblogs" :key="idx">
|
||||||
<AccountHoverWrapper :account="i.account">
|
<AccountHoverWrapper :account="i.account">
|
||||||
<NuxtLink :to="getAccountRoute(i.account)">
|
<NuxtLink :to="getAccountRoute(i.account)">
|
||||||
|
@ -27,7 +28,7 @@ const likes = $computed(() => group.likes.filter(i => i.favourite && !i.reblog))
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="likes.length" flex="~ gap-1">
|
<div v-if="likes.length" flex="~ gap-1">
|
||||||
<div i-ri:heart-fill text-xl me-1 color-red />
|
<div :class="useStarFavoriteIcon ? 'i-ri:star-line color-yellow' : 'i-ri:heart-line color-red'" text-xl me-2 />
|
||||||
<template v-for="i, idx of likes" :key="idx">
|
<template v-for="i, idx of likes" :key="idx">
|
||||||
<AccountHoverWrapper :account="i.account">
|
<AccountHoverWrapper :account="i.account">
|
||||||
<NuxtLink :to="getAccountRoute(i.account)">
|
<NuxtLink :to="getAccountRoute(i.account)">
|
||||||
|
@ -35,12 +36,12 @@ const likes = $computed(() => group.likes.filter(i => i.favourite && !i.reblog))
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</AccountHoverWrapper>
|
</AccountHoverWrapper>
|
||||||
</template>
|
</template>
|
||||||
<div ml1>
|
<div ms1>
|
||||||
{{ $t('notification.favourited_post') }}
|
{{ $t('notification.favourited_post') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div pl8 mt-1>
|
<div ps9 mt-1>
|
||||||
<StatusBody :status="group.status!" text-secondary />
|
<StatusBody :status="group.status!" text-secondary />
|
||||||
<!-- When no text content is presented, we show media instead -->
|
<!-- When no text content is presented, we show media instead -->
|
||||||
<template v-if="!group.status!.content">
|
<template v-if="!group.status!.content">
|
||||||
|
|
|
@ -1,21 +1,31 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// @ts-expect-error missing types
|
// @ts-expect-error missing types
|
||||||
import { DynamicScrollerItem } from 'vue-virtual-scroller'
|
import { DynamicScrollerItem } from 'vue-virtual-scroller'
|
||||||
import type { Paginator, WsEvents, mastodon } from 'masto'
|
import type { mastodon } from 'masto'
|
||||||
import type { GroupedAccountLike, NotificationSlot } from '~/types'
|
import type { GroupedAccountLike, NotificationSlot } from '~/types'
|
||||||
|
|
||||||
const { paginator, stream } = defineProps<{
|
const { paginator, stream } = defineProps<{
|
||||||
paginator: Paginator<mastodon.v1.Notification[], mastodon.v1.ListNotificationsParams>
|
paginator: mastodon.Paginator<mastodon.v1.Notification[], mastodon.rest.v1.ListNotificationsParams>
|
||||||
stream?: Promise<WsEvents>
|
stream?: mastodon.streaming.Subscription
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const virtualScroller = false // TODO: fix flickering issue with virtual scroll
|
const virtualScroller = false // TODO: fix flickering issue with virtual scroll
|
||||||
|
|
||||||
const groupCapacity = Number.MAX_VALUE // No limit
|
const groupCapacity = Number.MAX_VALUE // No limit
|
||||||
|
|
||||||
|
const includeNotificationTypes: mastodon.v1.NotificationType[] = ['update', 'mention', 'poll', 'status']
|
||||||
|
|
||||||
|
function includeNotificationsForStatusCard({ type, status }: mastodon.v1.Notification) {
|
||||||
|
// Exclude update, mention, pool and status notifications without the status entry:
|
||||||
|
// no makes sense to include them
|
||||||
|
// Those notifications will be shown using StatusCard SFC:
|
||||||
|
// check NotificationCard SFC L68 and L81 => :status="notification.status!"
|
||||||
|
return status || !includeNotificationTypes.includes(type)
|
||||||
|
}
|
||||||
|
|
||||||
// Group by type (and status when applicable)
|
// Group by type (and status when applicable)
|
||||||
const groupId = (item: mastodon.v1.Notification): string => {
|
function groupId(item: mastodon.v1.Notification): string {
|
||||||
// If the update is related to an status, group notifications from the same account (boost + favorite the same status)
|
// If the update is related to a status, group notifications from the same account (boost + favorite the same status)
|
||||||
const id = item.status
|
const id = item.status
|
||||||
? {
|
? {
|
||||||
status: item.status?.id,
|
status: item.status?.id,
|
||||||
|
@ -75,7 +85,12 @@ function groupItems(items: mastodon.v1.Notification[]): NotificationSlot[] {
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
else if (group.length && group[0].status && (group[0].type === 'reblog' || group[0].type === 'favourite')) {
|
else if (group.length && (group[0].type === 'reblog' || group[0].type === 'favourite')) {
|
||||||
|
if (!group[0].status) {
|
||||||
|
// Ignore favourite or reblog if status is null, sometimes the API is sending these
|
||||||
|
// notifications
|
||||||
|
return
|
||||||
|
}
|
||||||
// All notifications in these group are reblogs or favourites of the same status
|
// All notifications in these group are reblogs or favourites of the same status
|
||||||
const likes: GroupedAccountLike[] = []
|
const likes: GroupedAccountLike[] = []
|
||||||
for (const notification of group) {
|
for (const notification of group) {
|
||||||
|
@ -103,9 +118,9 @@ function groupItems(items: mastodon.v1.Notification[]): NotificationSlot[] {
|
||||||
results.push(...group)
|
results.push(...group)
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of items.filter(includeNotificationsForStatusCard)) {
|
||||||
const itemId = groupId(item)
|
const itemId = groupId(item)
|
||||||
// Finalize group if it already has too many notifications
|
// Finalize the group if it already has too many notifications
|
||||||
if (currentGroupId !== itemId || currentGroup.length >= groupCapacity)
|
if (currentGroupId !== itemId || currentGroup.length >= groupCapacity)
|
||||||
processGroup()
|
processGroup()
|
||||||
|
|
||||||
|
@ -150,17 +165,17 @@ const { clearNotifications } = useNotifications()
|
||||||
const { formatNumber } = useHumanReadableNumber()
|
const { formatNumber } = useHumanReadableNumber()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- eslint-disable vue/attribute-hyphenation -->
|
||||||
<template>
|
<template>
|
||||||
<CommonPaginator
|
<CommonPaginator
|
||||||
:paginator="paginator"
|
:paginator="paginator"
|
||||||
:preprocess="preprocess"
|
:preprocess="preprocess"
|
||||||
:stream="stream"
|
:stream="stream"
|
||||||
:eager="3"
|
eventType="notification"
|
||||||
:virtual-scroller="virtualScroller"
|
:virtualScroller="virtualScroller"
|
||||||
event-type="notification"
|
|
||||||
>
|
>
|
||||||
<template #updater="{ number, update }">
|
<template #updater="{ number, update }">
|
||||||
<button py-4 border="b base" flex="~ col" p-3 w-full text-primary font-bold @click="() => { update(); clearNotifications() }">
|
<button id="elk_show_new_items" py-4 border="b base" flex="~ col" p-3 w-full text-primary font-bold @click="() => { update(); clearNotifications() }">
|
||||||
{{ $t('timeline.show_new_items', number, { named: { v: formatNumber(number) } }) }}
|
{{ $t('timeline.show_new_items', number, { named: { v: formatNumber(number) } }) }}
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -17,35 +17,35 @@ const { t } = useI18n()
|
||||||
|
|
||||||
const pwaEnabled = useAppConfig().pwaEnabled
|
const pwaEnabled = useAppConfig().pwaEnabled
|
||||||
|
|
||||||
let busy = $ref<boolean>(false)
|
const busy = ref<boolean>(false)
|
||||||
let animateSave = $ref<boolean>(false)
|
const animateSave = ref<boolean>(false)
|
||||||
let animateSubscription = $ref<boolean>(false)
|
const animateSubscription = ref<boolean>(false)
|
||||||
let animateRemoveSubscription = $ref<boolean>(false)
|
const animateRemoveSubscription = ref<boolean>(false)
|
||||||
let subscribeError = $ref<string>('')
|
const subscribeError = ref<string>('')
|
||||||
let showSubscribeError = $ref<boolean>(false)
|
const showSubscribeError = ref<boolean>(false)
|
||||||
|
|
||||||
const hideNotification = () => {
|
function hideNotification() {
|
||||||
const key = currentUser.value?.account?.acct
|
const key = currentUser.value?.account?.acct
|
||||||
if (key)
|
if (key)
|
||||||
hiddenNotification.value[key] = true
|
hiddenNotification.value[key] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const showWarning = $computed(() => {
|
const showWarning = computed(() => {
|
||||||
if (!pwaEnabled)
|
if (!pwaEnabled)
|
||||||
return false
|
return false
|
||||||
|
|
||||||
return isSupported
|
return isSupported
|
||||||
&& (!isSubscribed.value || !notificationPermission.value || notificationPermission.value === 'prompt')
|
&& (!isSubscribed.value || !notificationPermission.value || notificationPermission.value === 'prompt')
|
||||||
&& !(hiddenNotification.value[currentUser.value?.account?.acct ?? ''] === true)
|
&& !(hiddenNotification.value[currentUser.value?.account?.acct ?? ''])
|
||||||
})
|
})
|
||||||
|
|
||||||
const saveSettings = async () => {
|
async function saveSettings() {
|
||||||
if (busy)
|
if (busy.value)
|
||||||
return
|
return
|
||||||
|
|
||||||
busy = true
|
busy.value = true
|
||||||
await nextTick()
|
await nextTick()
|
||||||
animateSave = true
|
animateSave.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await updateSubscription()
|
await updateSubscription()
|
||||||
|
@ -55,48 +55,48 @@ const saveSettings = async () => {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
busy = false
|
busy.value = false
|
||||||
animateSave = false
|
animateSave.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const doSubscribe = async () => {
|
async function doSubscribe() {
|
||||||
if (busy)
|
if (busy.value)
|
||||||
return
|
return
|
||||||
|
|
||||||
busy = true
|
busy.value = true
|
||||||
await nextTick()
|
await nextTick()
|
||||||
animateSubscription = true
|
animateSubscription.value = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await subscribe()
|
const result = await subscribe()
|
||||||
if (result !== 'subscribed') {
|
if (result !== 'subscribed') {
|
||||||
subscribeError = t(`settings.notifications.push_notifications.subscription_error.${result === 'notification-denied' ? 'permission_denied' : 'request_error'}`)
|
subscribeError.value = t(`settings.notifications.push_notifications.subscription_error.${result === 'notification-denied' ? 'permission_denied' : 'request_error'}`)
|
||||||
showSubscribeError = true
|
showSubscribeError.value = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
if (err instanceof PushSubscriptionError) {
|
if (err instanceof PushSubscriptionError) {
|
||||||
subscribeError = t(`settings.notifications.push_notifications.subscription_error.${err.code}`)
|
subscribeError.value = t(`settings.notifications.push_notifications.subscription_error.${err.code}`)
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
subscribeError = t('settings.notifications.push_notifications.subscription_error.request_error')
|
subscribeError.value = t('settings.notifications.push_notifications.subscription_error.request_error')
|
||||||
}
|
}
|
||||||
showSubscribeError = true
|
showSubscribeError.value = true
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
busy = false
|
busy.value = false
|
||||||
animateSubscription = false
|
animateSubscription.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const removeSubscription = async () => {
|
async function removeSubscription() {
|
||||||
if (busy)
|
if (busy.value)
|
||||||
return
|
return
|
||||||
|
|
||||||
busy = true
|
busy.value = true
|
||||||
await nextTick()
|
await nextTick()
|
||||||
animateRemoveSubscription = true
|
animateRemoveSubscription.value = true
|
||||||
try {
|
try {
|
||||||
await unsubscribe()
|
await unsubscribe()
|
||||||
}
|
}
|
||||||
|
@ -104,11 +104,11 @@ const removeSubscription = async () => {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
busy = false
|
busy.value = false
|
||||||
animateRemoveSubscription = false
|
animateRemoveSubscription.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onActivated(() => (busy = false))
|
onActivated(() => (busy.value = false))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
|
@ -3,9 +3,7 @@ defineProps<{
|
||||||
title?: string
|
title?: string
|
||||||
message: string
|
message: string
|
||||||
}>()
|
}>()
|
||||||
const { modelValue } = defineModel<{
|
const modelValue = defineModel<boolean>({ required: true })
|
||||||
modelValue: boolean
|
|
||||||
}>()
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
|
@ -20,9 +20,10 @@ const maxDescriptionLength = 1500
|
||||||
|
|
||||||
const isEditDialogOpen = ref(false)
|
const isEditDialogOpen = ref(false)
|
||||||
const description = ref(props.attachment.description ?? '')
|
const description = ref(props.attachment.description ?? '')
|
||||||
const toggleApply = () => {
|
|
||||||
|
function toggleApply() {
|
||||||
isEditDialogOpen.value = false
|
isEditDialogOpen.value = false
|
||||||
emit('setDescription', unref(description))
|
emit('setDescription', description.value)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Editor } from '@tiptap/core'
|
import type { Editor } from '@tiptap/core'
|
||||||
|
|
||||||
const { editor } = defineProps<{
|
const { editor } = defineProps<{
|
||||||
editor: Editor
|
editor: Editor
|
||||||
}>()
|
}>()
|
||||||
|
@ -7,9 +8,10 @@ const { editor } = defineProps<{
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<CommonTooltip placement="top" :content="$t('tooltip.open_editor_tools')">
|
<CommonTooltip placement="top" :content="$t('tooltip.open_editor_tools')">
|
||||||
<VDropdown v-if="editor" placement="top">
|
<VDropdown v-if="editor" placement="bottom">
|
||||||
<button
|
<button
|
||||||
btn-action-icon
|
btn-action-icon
|
||||||
|
:aria-label="$t('tooltip.open_editor_tools')"
|
||||||
>
|
>
|
||||||
<div i-ri:font-size-2 />
|
<div i-ri:font-size-2 />
|
||||||
</button>
|
</button>
|
||||||
|
|
|
@ -9,16 +9,16 @@ const emit = defineEmits<{
|
||||||
|
|
||||||
const { locale } = useI18n()
|
const { locale } = useI18n()
|
||||||
|
|
||||||
const el = $ref<HTMLElement>()
|
const el = ref<HTMLElement>()
|
||||||
let picker = $ref<Picker>()
|
const picker = ref<Picker>()
|
||||||
const colorMode = useColorMode()
|
const colorMode = useColorMode()
|
||||||
|
|
||||||
async function openEmojiPicker() {
|
async function openEmojiPicker() {
|
||||||
await updateCustomEmojis()
|
await updateCustomEmojis()
|
||||||
|
|
||||||
if (picker) {
|
if (picker.value) {
|
||||||
picker.update({
|
picker.value.update({
|
||||||
theme: colorMode.value,
|
theme: colorMode,
|
||||||
custom: customEmojisData.value,
|
custom: customEmojisData.value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -29,7 +29,7 @@ async function openEmojiPicker() {
|
||||||
importEmojiLang(locale.value.split('-')[0]),
|
importEmojiLang(locale.value.split('-')[0]),
|
||||||
])
|
])
|
||||||
|
|
||||||
picker = new Picker({
|
picker.value = new Picker({
|
||||||
data: () => dataPromise,
|
data: () => dataPromise,
|
||||||
onEmojiSelect({ native, src, alt, name }: any) {
|
onEmojiSelect({ native, src, alt, name }: any) {
|
||||||
native
|
native
|
||||||
|
@ -37,19 +37,19 @@ async function openEmojiPicker() {
|
||||||
: emit('selectCustom', { src, alt, 'data-emoji-id': name })
|
: emit('selectCustom', { src, alt, 'data-emoji-id': name })
|
||||||
},
|
},
|
||||||
set: 'twitter',
|
set: 'twitter',
|
||||||
theme: colorMode.value,
|
theme: colorMode,
|
||||||
custom: customEmojisData.value,
|
custom: customEmojisData.value,
|
||||||
i18n,
|
i18n,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
await nextTick()
|
await nextTick()
|
||||||
// TODO: custom picker
|
// TODO: custom picker
|
||||||
el?.appendChild(picker as any as HTMLElement)
|
el.value?.appendChild(picker.value as any as HTMLElement)
|
||||||
}
|
}
|
||||||
|
|
||||||
const hideEmojiPicker = () => {
|
function hideEmojiPicker() {
|
||||||
if (picker)
|
if (picker.value)
|
||||||
el?.removeChild(picker as any as HTMLElement)
|
el.value?.removeChild(picker.value as any as HTMLElement)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -1,26 +1,28 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import Fuse from 'fuse.js'
|
import Fuse from 'fuse.js'
|
||||||
|
|
||||||
let { modelValue } = $defineModel<{
|
const modelValue = defineModel<string>({ required: true })
|
||||||
modelValue: string
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const userSettings = useUserSettings()
|
const userSettings = useUserSettings()
|
||||||
|
|
||||||
const languageKeyword = $ref('')
|
const languageKeyword = ref('')
|
||||||
|
|
||||||
const fuse = new Fuse(languagesNameList, {
|
const fuse = new Fuse(languagesNameList, {
|
||||||
keys: ['code', 'nativeName', 'name'],
|
keys: ['code', 'nativeName', 'name'],
|
||||||
shouldSort: true,
|
shouldSort: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
const languages = $computed(() =>
|
const languages = computed(() =>
|
||||||
languageKeyword.trim()
|
languageKeyword.value.trim()
|
||||||
? fuse.search(languageKeyword).map(r => r.item)
|
? fuse.search(languageKeyword.value).map(r => r.item)
|
||||||
: [...languagesNameList].filter(entry => !userSettings.value.disabledTranslationLanguages.includes(entry.code))
|
: [...languagesNameList].filter(entry => !userSettings.value.disabledTranslationLanguages.includes(entry.code))
|
||||||
.sort(({ code: a }, { code: b }) => {
|
.sort(({ code: a }, { code: b }) => {
|
||||||
return a === modelValue ? -1 : b === modelValue ? 1 : a.localeCompare(b)
|
// Put English on the top
|
||||||
|
if (a === 'en')
|
||||||
|
return -1
|
||||||
|
|
||||||
|
return a === modelValue.value ? -1 : b === modelValue.value ? 1 : a.localeCompare(b)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -37,7 +39,7 @@ const preferredLanguages = computed(() => {
|
||||||
)
|
)
|
||||||
|
|
||||||
function chooseLanguage(language: string) {
|
function chooseLanguage(language: string) {
|
||||||
modelValue = language
|
modelValue.value = language
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -3,16 +3,16 @@ const { editing } = defineProps<{
|
||||||
editing?: boolean
|
editing?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
let { modelValue } = $defineModel<{
|
const modelValue = defineModel<string>({
|
||||||
modelValue: string
|
required: true,
|
||||||
}>()
|
})
|
||||||
|
|
||||||
const currentVisibility = $computed(() =>
|
const currentVisibility = computed(() =>
|
||||||
statusVisibilities.find(v => v.value === modelValue) || statusVisibilities[0],
|
statusVisibilities.find(v => v.value === modelValue.value) || statusVisibilities[0],
|
||||||
)
|
)
|
||||||
|
|
||||||
const chooseVisibility = (visibility: string) => {
|
function chooseVisibility(visibility: string) {
|
||||||
modelValue = visibility
|
modelValue.value = visibility
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -27,43 +27,98 @@ const emit = defineEmits<{
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const draftState = useDraft(draftKey, initial)
|
const draftState = useDraft(draftKey, initial)
|
||||||
const { draft } = $(draftState)
|
const { draft } = draftState
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isExceedingAttachmentLimit, isUploading, failedAttachments, isOverDropZone,
|
isExceedingAttachmentLimit,
|
||||||
uploadAttachments, pickAttachments, setDescription, removeAttachment,
|
isUploading,
|
||||||
|
failedAttachments,
|
||||||
|
isOverDropZone,
|
||||||
|
uploadAttachments,
|
||||||
|
pickAttachments,
|
||||||
|
setDescription,
|
||||||
|
removeAttachment,
|
||||||
dropZoneRef,
|
dropZoneRef,
|
||||||
} = $(useUploadMediaAttachment($$(draft)))
|
} = useUploadMediaAttachment(draft)
|
||||||
|
|
||||||
let { shouldExpanded, isExpanded, isSending, isPublishDisabled, publishDraft, failedMessages, preferredLanguage, publishSpoilerText } = $(usePublish(
|
const { shouldExpanded, isExpanded, isSending, isPublishDisabled, publishDraft, failedMessages, preferredLanguage, publishSpoilerText } = usePublish(
|
||||||
{
|
{
|
||||||
draftState,
|
draftState,
|
||||||
...$$({ expanded, isUploading, initialDraft: initial }),
|
...{ expanded: toRef(() => expanded), isUploading, initialDraft: toRef(() => initial) },
|
||||||
},
|
},
|
||||||
))
|
)
|
||||||
|
|
||||||
const { editor } = useTiptap({
|
const { editor } = useTiptap({
|
||||||
content: computed({
|
content: computed({
|
||||||
get: () => draft.params.status,
|
get: () => draft.value.params.status,
|
||||||
set: (newVal) => {
|
set: (newVal) => {
|
||||||
draft.params.status = newVal
|
draft.value.params.status = newVal
|
||||||
draft.lastUpdated = Date.now()
|
draft.value.lastUpdated = Date.now()
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
placeholder: computed(() => placeholder ?? draft.params.inReplyToId ? t('placeholder.replying') : t('placeholder.default_1')),
|
placeholder: computed(() => placeholder ?? draft.value.params.inReplyToId ? t('placeholder.replying') : t('placeholder.default_1')),
|
||||||
autofocus: shouldExpanded,
|
autofocus: shouldExpanded.value,
|
||||||
onSubmit: publish,
|
onSubmit: publish,
|
||||||
onFocus() {
|
onFocus() {
|
||||||
if (!isExpanded && draft.initialText) {
|
if (!isExpanded && draft.value.initialText) {
|
||||||
editor.value?.chain().insertContent(`${draft.initialText} `).focus('end').run()
|
editor.value?.chain().insertContent(`${draft.value.initialText} `).focus('end').run()
|
||||||
draft.initialText = ''
|
draft.value.initialText = ''
|
||||||
}
|
}
|
||||||
isExpanded = true
|
isExpanded.value = true
|
||||||
},
|
},
|
||||||
onPaste: handlePaste,
|
onPaste: handlePaste,
|
||||||
})
|
})
|
||||||
|
|
||||||
const characterCount = $computed(() => {
|
function trimPollOptions() {
|
||||||
|
const indexLastNonEmpty = draft.value.params.poll!.options.findLastIndex(option => option.trim().length > 0)
|
||||||
|
const trimmedOptions = draft.value.params.poll!.options.slice(0, indexLastNonEmpty + 1)
|
||||||
|
|
||||||
|
if (currentInstance.value?.configuration
|
||||||
|
&& trimmedOptions.length >= currentInstance.value?.configuration?.polls.maxOptions)
|
||||||
|
draft.value.params.poll!.options = trimmedOptions
|
||||||
|
else
|
||||||
|
draft.value.params.poll!.options = [...trimmedOptions, '']
|
||||||
|
}
|
||||||
|
|
||||||
|
function editPollOptionDraft(event: Event, index: number) {
|
||||||
|
draft.value.params.poll!.options = Object.assign(draft.value.params.poll!.options.slice(), { [index]: (event.target as HTMLInputElement).value })
|
||||||
|
|
||||||
|
trimPollOptions()
|
||||||
|
}
|
||||||
|
|
||||||
|
function deletePollOption(index: number) {
|
||||||
|
const newPollOptions = draft.value.params.poll!.options.slice()
|
||||||
|
newPollOptions.splice(index, 1)
|
||||||
|
draft.value.params.poll!.options = newPollOptions
|
||||||
|
trimPollOptions()
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresInOptions = computed(() => [
|
||||||
|
{
|
||||||
|
seconds: 1 * 60 * 60,
|
||||||
|
label: t('time_ago_options.hour_future', 1),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
seconds: 2 * 60 * 60,
|
||||||
|
label: t('time_ago_options.hour_future', 2),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
seconds: 1 * 24 * 60 * 60,
|
||||||
|
label: t('time_ago_options.day_future', 1),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
seconds: 2 * 24 * 60 * 60,
|
||||||
|
label: t('time_ago_options.day_future', 2),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
seconds: 7 * 24 * 60 * 60,
|
||||||
|
label: t('time_ago_options.day_future', 7),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const expiresInDefaultOptionIndex = 2
|
||||||
|
|
||||||
|
const characterCount = computed(() => {
|
||||||
const text = htmlToText(editor.value?.getHTML() || '')
|
const text = htmlToText(editor.value?.getHTML() || '')
|
||||||
|
|
||||||
let length = stringLength(text)
|
let length = stringLength(text)
|
||||||
|
@ -84,24 +139,26 @@ const characterCount = $computed(() => {
|
||||||
for (const [fullMatch, before, _handle, username] of text.matchAll(countableMentionRegex))
|
for (const [fullMatch, before, _handle, username] of text.matchAll(countableMentionRegex))
|
||||||
length -= fullMatch.length - (before + username).length - 1 // - 1 for the @
|
length -= fullMatch.length - (before + username).length - 1 // - 1 for the @
|
||||||
|
|
||||||
if (draft.mentions) {
|
if (draft.value.mentions) {
|
||||||
// + 1 is needed as mentions always need a space seperator at the end
|
// + 1 is needed as mentions always need a space separator at the end
|
||||||
length += draft.mentions.map((mention) => {
|
length += draft.value.mentions.map((mention) => {
|
||||||
const [handle] = mention.split('@')
|
const [handle] = mention.split('@')
|
||||||
return `@${handle}`
|
return `@${handle}`
|
||||||
}).join(' ').length + 1
|
}).join(' ').length + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
length += stringLength(publishSpoilerText)
|
length += stringLength(publishSpoilerText.value)
|
||||||
|
|
||||||
return length
|
return length
|
||||||
})
|
})
|
||||||
|
|
||||||
const isExceedingCharacterLimit = $computed(() => {
|
const isExceedingCharacterLimit = computed(() => {
|
||||||
return characterCount > characterLimit.value
|
return characterCount.value > characterLimit.value
|
||||||
})
|
})
|
||||||
|
|
||||||
const postLanguageDisplay = $computed(() => languagesNameList.find(i => i.code === (draft.params.language || preferredLanguage))?.nativeName)
|
const postLanguageDisplay = computed(() => languagesNameList.find(i => i.code === (draft.value.params.language || preferredLanguage))?.nativeName)
|
||||||
|
|
||||||
|
const isDM = computed(() => draft.value.params.visibility === 'direct')
|
||||||
|
|
||||||
async function handlePaste(evt: ClipboardEvent) {
|
async function handlePaste(evt: ClipboardEvent) {
|
||||||
const files = evt.clipboardData?.files
|
const files = evt.clipboardData?.files
|
||||||
|
@ -120,7 +177,7 @@ function insertCustomEmoji(image: any) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleSensitive() {
|
async function toggleSensitive() {
|
||||||
draft.params.sensitive = !draft.params.sensitive
|
draft.value.params.sensitive = !draft.value.params.sensitive
|
||||||
}
|
}
|
||||||
|
|
||||||
async function publish() {
|
async function publish() {
|
||||||
|
@ -153,22 +210,27 @@ defineExpose({
|
||||||
editor.value?.commands?.focus?.()
|
editor.value?.commands?.focus?.()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function stopQuestionMarkPropagation(e: KeyboardEvent) {
|
||||||
|
if (e.key === '?')
|
||||||
|
e.stopImmediatePropagation()
|
||||||
|
}
|
||||||
|
|
||||||
|
onDeactivated(() => {
|
||||||
|
clearEmptyDrafts()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="isHydrated && currentUser" flex="~ col gap-4" py3 px2 sm:px4 aria-roledescription="publish-widget">
|
<div v-if="isHydrated && currentUser" flex="~ col gap-4" py3 px2 sm:px4 aria-roledescription="publish-widget">
|
||||||
<template v-if="draft.editingStatus">
|
<template v-if="draft.editingStatus">
|
||||||
<div flex="~ col gap-1">
|
<div id="state-editing" text-secondary self-center>
|
||||||
<div id="state-editing" text-secondary self-center>
|
{{ $t('state.editing') }}
|
||||||
{{ $t('state.editing') }}
|
|
||||||
</div>
|
|
||||||
<StatusCard :status="draft.editingStatus" :actions="false" :hover="false" is-preview px-0 />
|
|
||||||
</div>
|
</div>
|
||||||
<div border="b dashed gray/40" />
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div flex gap-3 flex-1>
|
<div flex gap-3 flex-1>
|
||||||
<NuxtLink :to="getAccountRoute(currentUser.account)">
|
<NuxtLink self-start :to="getAccountRoute(currentUser.account)">
|
||||||
<AccountBigAvatar :account="currentUser.account" square />
|
<AccountBigAvatar :account="currentUser.account" square />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<!-- This `w-0` style is used to avoid overflow problems in flex layouts,so don't remove it unless you know what you're doing -->
|
<!-- This `w-0` style is used to avoid overflow problems in flex layouts,so don't remove it unless you know what you're doing -->
|
||||||
|
@ -217,11 +279,16 @@ defineExpose({
|
||||||
</ol>
|
</ol>
|
||||||
</CommonErrorMessage>
|
</CommonErrorMessage>
|
||||||
|
|
||||||
<div relative flex-1 flex flex-col>
|
<div relative flex-1 flex flex-col min-h-30>
|
||||||
<EditorContent
|
<EditorContent
|
||||||
:editor="editor"
|
:editor="editor"
|
||||||
flex max-w-full
|
flex max-w-full
|
||||||
:class="shouldExpanded ? 'min-h-30 md:max-h-[calc(100vh-200px)] sm:max-h-[calc(100vh-400px)] max-h-35 of-y-auto overscroll-contain' : ''"
|
:class="{
|
||||||
|
'md:max-h-[calc(100vh-200px)] sm:max-h-[calc(100vh-400px)] max-h-35 of-y-auto overscroll-contain': shouldExpanded,
|
||||||
|
'py2 px3.5 bg-dm rounded-4 me--1 ms--1 mt--1': isDM,
|
||||||
|
}"
|
||||||
|
@keydown="stopQuestionMarkPropagation"
|
||||||
|
@keydown.esc.prevent="editor?.commands.blur()"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -273,92 +340,175 @@ defineExpose({
|
||||||
</div>
|
</div>
|
||||||
<div flex gap-4>
|
<div flex gap-4>
|
||||||
<div w-12 h-full sm:block hidden />
|
<div w-12 h-full sm:block hidden />
|
||||||
<div
|
<div flex="~ col 1" max-w-full>
|
||||||
v-if="shouldExpanded" flex="~ gap-1 1 wrap" m="s--1" pt-2 justify="end" max-w-full
|
<form v-if="isExpanded && draft.params.poll" my-4 flex="~ 1 col" gap-3 m="s--1">
|
||||||
border="t base"
|
<div
|
||||||
>
|
v-for="(option, index) in draft.params.poll.options"
|
||||||
<PublishEmojiPicker
|
:key="index"
|
||||||
@select="insertEmoji"
|
flex="~ row"
|
||||||
@select-custom="insertCustomEmoji"
|
gap-3
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
:value="option"
|
||||||
|
bg-base
|
||||||
|
border="~ base" flex-1 h10 pe-4 rounded-2 w-full flex="~ row"
|
||||||
|
items-center relative focus-within:box-shadow-outline gap-3
|
||||||
|
px-4 py-2
|
||||||
|
:placeholder="$t('polls.option_placeholder', { current: index + 1, max: currentInstance?.configuration?.polls.maxOptions })"
|
||||||
|
class="option-input"
|
||||||
|
@input="editPollOptionDraft($event, index)"
|
||||||
|
>
|
||||||
|
<CommonTooltip placement="top" :content="$t('polls.remove_option')" class="delete-button">
|
||||||
|
<button
|
||||||
|
btn-action-icon class="hover:bg-red/75"
|
||||||
|
:disabled="index === draft.params.poll!.options.length - 1 && (index + 1 !== currentInstance?.configuration?.polls.maxOptions || draft.params.poll!.options[index].length === 0)"
|
||||||
|
@click.prevent="deletePollOption(index)"
|
||||||
|
>
|
||||||
|
<div i-ri:delete-bin-line />
|
||||||
|
</button>
|
||||||
|
</CommonTooltip>
|
||||||
|
<span
|
||||||
|
v-if="currentInstance?.configuration?.polls.maxCharactersPerOption"
|
||||||
|
class="char-limit-radial"
|
||||||
|
aspect-ratio-1
|
||||||
|
h-10
|
||||||
|
:style="{ background: `radial-gradient(closest-side, rgba(var(--rgb-bg-base)) 79%, transparent 80% 100%), conic-gradient(${draft.params.poll!.options[index].length / currentInstance?.configuration?.polls.maxCharactersPerOption > 1 ? 'var(--c-danger)' : 'var(--c-primary)'} ${draft.params.poll!.options[index].length / currentInstance?.configuration?.polls.maxCharactersPerOption * 100}%, var(--c-primary-fade) 0)` }"
|
||||||
|
>{{ draft.params.poll!.options[index].length }}</span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div
|
||||||
|
v-if="shouldExpanded" flex="~ gap-1 1 wrap" m="s--1" pt-2 justify="end" max-w-full
|
||||||
|
border="t base"
|
||||||
>
|
>
|
||||||
<button btn-action-icon :title="$t('tooltip.emoji')">
|
<PublishEmojiPicker
|
||||||
<div i-ri:emotion-line />
|
@select="insertEmoji"
|
||||||
</button>
|
@select-custom="insertCustomEmoji"
|
||||||
</PublishEmojiPicker>
|
>
|
||||||
|
<button btn-action-icon :title="$t('tooltip.emojis')" :aria-label="$t('tooltip.add_emojis')">
|
||||||
<CommonTooltip placement="top" :content="$t('tooltip.add_media')">
|
<div i-ri:emotion-line />
|
||||||
<button btn-action-icon :aria-label="$t('tooltip.add_media')" @click="pickAttachments">
|
|
||||||
<div i-ri:image-add-line />
|
|
||||||
</button>
|
|
||||||
</CommonTooltip>
|
|
||||||
|
|
||||||
<PublishEditorTools v-if="editor" :editor="editor" />
|
|
||||||
|
|
||||||
<div flex-auto />
|
|
||||||
|
|
||||||
<PublishCharacterCounter :max="characterLimit" :length="characterCount" />
|
|
||||||
|
|
||||||
<CommonTooltip placement="top" :content="$t('tooltip.change_language')">
|
|
||||||
<CommonDropdown placement="bottom" auto-boundary-max-size>
|
|
||||||
<button btn-action-icon :aria-label="$t('tooltip.change_language')" w-max mr1>
|
|
||||||
<span v-if="postLanguageDisplay" text-secondary text-sm ml1>{{ postLanguageDisplay }}</span>
|
|
||||||
<div v-else i-ri:translate-2 />
|
|
||||||
<div i-ri:arrow-down-s-line text-sm text-secondary me--1 />
|
|
||||||
</button>
|
</button>
|
||||||
|
</PublishEmojiPicker>
|
||||||
|
|
||||||
<template #popper>
|
<CommonTooltip v-if="draft.params.poll === undefined" placement="top" :content="$t('tooltip.add_media')" no-auto-focus>
|
||||||
<PublishLanguagePicker v-model="draft.params.language" min-w-80 />
|
<button btn-action-icon :aria-label="$t('tooltip.add_media')" @click="pickAttachments">
|
||||||
</template>
|
<div i-ri:image-add-line />
|
||||||
</CommonDropdown>
|
|
||||||
</CommonTooltip>
|
|
||||||
|
|
||||||
<CommonTooltip placement="top" :content="$t('tooltip.add_content_warning')">
|
|
||||||
<button btn-action-icon :aria-label="$t('tooltip.add_content_warning')" @click="toggleSensitive">
|
|
||||||
<div v-if="draft.params.sensitive" i-ri:alarm-warning-fill text-orange />
|
|
||||||
<div v-else i-ri:alarm-warning-line />
|
|
||||||
</button>
|
|
||||||
</CommonTooltip>
|
|
||||||
|
|
||||||
<PublishVisibilityPicker v-model="draft.params.visibility" :editing="!!draft.editingStatus">
|
|
||||||
<template #default="{ visibility }">
|
|
||||||
<button :disabled="!!draft.editingStatus" :aria-label="$t('tooltip.change_content_visibility')" btn-action-icon :class="{ 'w-12': !draft.editingStatus }">
|
|
||||||
<div :class="visibility.icon" />
|
|
||||||
<div v-if="!draft.editingStatus" i-ri:arrow-down-s-line text-sm text-secondary me--1 />
|
|
||||||
</button>
|
</button>
|
||||||
|
</CommonTooltip>
|
||||||
|
|
||||||
|
<template v-if="draft.attachments.length === 0">
|
||||||
|
<CommonTooltip v-if="!draft.params.poll" placement="top" :content="$t('polls.create')" no-auto-focus>
|
||||||
|
<button btn-action-icon :aria-label="$t('polls.create')" @click="draft.params.poll = { options: [''], expiresIn: expiresInOptions[expiresInDefaultOptionIndex].seconds }">
|
||||||
|
<div i-ri:chat-poll-line />
|
||||||
|
</button>
|
||||||
|
</CommonTooltip>
|
||||||
|
<div v-else rounded-full b-1 border-dark flex="~ row" gap-1>
|
||||||
|
<CommonTooltip placement="top" :content="$t('polls.cancel')" no-auto-focus>
|
||||||
|
<button btn-action-icon b-r border-dark :aria-label="$t('polls.cancel')" @click="draft.params.poll = undefined">
|
||||||
|
<div i-ri:close-line />
|
||||||
|
</button>
|
||||||
|
</CommonTooltip>
|
||||||
|
<CommonDropdown placement="top">
|
||||||
|
<CommonTooltip placement="top" :content="$t('polls.settings')" no-auto-focus>
|
||||||
|
<button :aria-label="$t('polls.settings')" btn-action-icon w-12>
|
||||||
|
<div i-ri:list-settings-line />
|
||||||
|
<div i-ri:arrow-down-s-line text-sm text-secondary me--1 />
|
||||||
|
</button>
|
||||||
|
</CommonTooltip>
|
||||||
|
<template #popper>
|
||||||
|
<div flex="~ col" gap-1 p-2>
|
||||||
|
<CommonCheckbox v-model="draft.params.poll.multiple" :label="draft.params.poll.multiple ? $t('polls.disallow_multiple') : $t('polls.allow_multiple')" px-2 gap-3 h-9 flex justify-center hover:bg-active rounded-full icon-checked="i-ri:checkbox-multiple-blank-line" icon-unchecked="i-ri:checkbox-blank-circle-line" />
|
||||||
|
<CommonCheckbox v-model="draft.params.poll.hideTotals" :label="draft.params.poll.hideTotals ? $t('polls.show_votes') : $t('polls.hide_votes')" px-2 gap-3 h-9 flex justify-center hover:bg-active rounded-full icon-checked="i-ri:eye-close-line" icon-unchecked="i-ri:eye-line" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</CommonDropdown>
|
||||||
|
<CommonDropdown placement="bottom">
|
||||||
|
<CommonTooltip placement="top" :content="$t('polls.expiration')" no-auto-focus>
|
||||||
|
<button :aria-label="$t('polls.expiration')" btn-action-icon w-12>
|
||||||
|
<div i-ri:hourglass-line />
|
||||||
|
<div i-ri:arrow-down-s-line text-sm text-secondary me--1 />
|
||||||
|
</button>
|
||||||
|
</CommonTooltip>
|
||||||
|
<template #popper>
|
||||||
|
<CommonDropdownItem
|
||||||
|
v-for="expiresInOption in expiresInOptions"
|
||||||
|
:key="expiresInOption.seconds"
|
||||||
|
:text="expiresInOption.label"
|
||||||
|
:checked="draft.params.poll!.expiresIn === expiresInOption.seconds"
|
||||||
|
@click="draft.params.poll!.expiresIn = expiresInOption.seconds"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</CommonDropdown>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</PublishVisibilityPicker>
|
|
||||||
|
|
||||||
<CommonTooltip v-if="failedMessages.length > 0" id="publish-failed-tooltip" placement="top" :content="$t('tooltip.publish_failed')">
|
<PublishEditorTools v-if="editor" :editor="editor" />
|
||||||
<button
|
|
||||||
btn-danger rounded-3 text-sm w-full flex="~ gap1" items-center md:w-fit aria-describedby="publish-failed-tooltip"
|
|
||||||
>
|
|
||||||
<span block>
|
|
||||||
<div block i-carbon:face-dizzy-filled />
|
|
||||||
</span>
|
|
||||||
<span>{{ $t('state.publish_failed') }}</span>
|
|
||||||
</button>
|
|
||||||
</CommonTooltip>
|
|
||||||
|
|
||||||
<CommonTooltip v-else id="publish-tooltip" placement="top" :content="$t('tooltip.add_publishable_content')" :disabled="!(isPublishDisabled || isExceedingCharacterLimit)">
|
<div flex-auto />
|
||||||
<button
|
|
||||||
btn-solid rounded-3 text-sm w-full flex="~ gap1" items-center
|
<PublishCharacterCounter :max="characterLimit" :length="characterCount" />
|
||||||
md:w-fit
|
|
||||||
class="publish-button"
|
<CommonTooltip placement="top" :content="$t('tooltip.change_language')" no-auto-focus>
|
||||||
:aria-disabled="isPublishDisabled || isExceedingCharacterLimit"
|
<CommonDropdown placement="bottom" auto-boundary-max-size>
|
||||||
aria-describedby="publish-tooltip"
|
<button btn-action-icon :aria-label="$t('tooltip.change_language')" w-max mr1>
|
||||||
@click="publish"
|
<span v-if="postLanguageDisplay" text-secondary text-sm ml1>{{ postLanguageDisplay }}</span>
|
||||||
>
|
<div v-else i-ri:translate-2 />
|
||||||
<span v-if="isSending" block animate-spin preserve-3d>
|
<div i-ri:arrow-down-s-line text-sm text-secondary me--1 />
|
||||||
<div block i-ri:loader-2-fill />
|
</button>
|
||||||
</span>
|
|
||||||
<span v-if="failedMessages.length" block>
|
<template #popper>
|
||||||
<div block i-carbon:face-dizzy-filled />
|
<PublishLanguagePicker v-model="draft.params.language" min-w-80 />
|
||||||
</span>
|
</template>
|
||||||
<span v-if="draft.editingStatus">{{ $t('action.save_changes') }}</span>
|
</CommonDropdown>
|
||||||
<span v-else-if="draft.params.inReplyToId">{{ $t('action.reply') }}</span>
|
</CommonTooltip>
|
||||||
<span v-else>{{ !isSending ? $t('action.publish') : $t('state.publishing') }}</span>
|
|
||||||
</button>
|
<CommonTooltip placement="top" :content="$t('tooltip.add_content_warning')" no-auto-focus>
|
||||||
</CommonTooltip>
|
<button btn-action-icon :aria-label="$t('tooltip.add_content_warning')" @click="toggleSensitive">
|
||||||
|
<div v-if="draft.params.sensitive" i-ri:alarm-warning-fill text-orange />
|
||||||
|
<div v-else i-ri:alarm-warning-line />
|
||||||
|
</button>
|
||||||
|
</CommonTooltip>
|
||||||
|
|
||||||
|
<PublishVisibilityPicker v-model="draft.params.visibility" :editing="!!draft.editingStatus">
|
||||||
|
<template #default="{ visibility }">
|
||||||
|
<button :disabled="!!draft.editingStatus" :aria-label="$t('tooltip.change_content_visibility')" btn-action-icon :class="{ 'w-12': !draft.editingStatus }">
|
||||||
|
<div :class="visibility.icon" />
|
||||||
|
<div v-if="!draft.editingStatus" i-ri:arrow-down-s-line text-sm text-secondary me--1 />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</PublishVisibilityPicker>
|
||||||
|
|
||||||
|
<CommonTooltip v-if="failedMessages.length > 0" id="publish-failed-tooltip" placement="top" :content="$t('tooltip.publish_failed')" no-auto-focus>
|
||||||
|
<button
|
||||||
|
btn-danger rounded-3 text-sm w-full flex="~ gap1" items-center md:w-fit aria-describedby="publish-failed-tooltip"
|
||||||
|
>
|
||||||
|
<span block>
|
||||||
|
<div block i-carbon:face-dizzy-filled />
|
||||||
|
</span>
|
||||||
|
<span>{{ $t('state.publish_failed') }}</span>
|
||||||
|
</button>
|
||||||
|
</CommonTooltip>
|
||||||
|
|
||||||
|
<CommonTooltip v-else id="publish-tooltip" placement="top" :content="$t('tooltip.add_publishable_content')" :disabled="!(isPublishDisabled || isExceedingCharacterLimit)" no-auto-focus>
|
||||||
|
<button
|
||||||
|
btn-solid rounded-3 text-sm w-full flex="~ gap1" items-center
|
||||||
|
md:w-fit
|
||||||
|
class="publish-button"
|
||||||
|
:aria-disabled="isPublishDisabled || isExceedingCharacterLimit"
|
||||||
|
aria-describedby="publish-tooltip"
|
||||||
|
@click="publish"
|
||||||
|
>
|
||||||
|
<span v-if="isSending" block animate-spin preserve-3d>
|
||||||
|
<div block i-ri:loader-2-fill />
|
||||||
|
</span>
|
||||||
|
<span v-if="failedMessages.length" block>
|
||||||
|
<div block i-carbon:face-dizzy-filled />
|
||||||
|
</span>
|
||||||
|
<span v-if="draft.editingStatus">{{ $t('action.save_changes') }}</span>
|
||||||
|
<span v-else-if="draft.params.inReplyToId">{{ $t('action.reply') }}</span>
|
||||||
|
<span v-else>{{ !isSending ? $t('action.publish') : $t('state.publishing') }}</span>
|
||||||
|
</button>
|
||||||
|
</CommonTooltip>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -374,4 +524,18 @@ defineExpose({
|
||||||
background-color: var(--c-bg-btn-disabled);
|
background-color: var(--c-bg-btn-disabled);
|
||||||
color: var(--c-text-btn-disabled);
|
color: var(--c-text-btn-disabled);
|
||||||
}
|
}
|
||||||
|
.option-input:focus + .delete-button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-input:not(:focus) + .delete-button + .char-limit-radial {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.char-limit-radial {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -5,19 +5,19 @@ const route = useRoute()
|
||||||
const { formatNumber } = useHumanReadableNumber()
|
const { formatNumber } = useHumanReadableNumber()
|
||||||
const timeAgoOptions = useTimeAgoOptions()
|
const timeAgoOptions = useTimeAgoOptions()
|
||||||
|
|
||||||
let draftKey = $ref('home')
|
const draftKey = ref('home')
|
||||||
|
|
||||||
const draftKeys = $computed(() => Object.keys(currentUserDrafts.value))
|
const draftKeys = computed(() => Object.keys(currentUserDrafts.value))
|
||||||
const nonEmptyDrafts = $computed(() => draftKeys
|
const nonEmptyDrafts = computed(() => draftKeys.value
|
||||||
.filter(i => i !== draftKey && !isEmptyDraft(currentUserDrafts.value[i]))
|
.filter(i => i !== draftKey.value && !isEmptyDraft(currentUserDrafts.value[i]))
|
||||||
.map(i => [i, currentUserDrafts.value[i]] as const),
|
.map(i => [i, currentUserDrafts.value[i]] as const),
|
||||||
)
|
)
|
||||||
|
|
||||||
watchEffect(() => {
|
watchEffect(() => {
|
||||||
draftKey = route.query.draft?.toString() || 'home'
|
draftKey.value = route.query.draft?.toString() || 'home'
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onDeactivated(() => {
|
||||||
clearEmptyDrafts()
|
clearEmptyDrafts()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
<template>
|
<template>
|
||||||
<button
|
<button
|
||||||
v-if="$pwa?.needRefresh"
|
v-if="useNuxtApp().$pwa?.needRefresh"
|
||||||
bg="primary-fade" relative rounded
|
bg="primary-fade" relative rounded
|
||||||
flex="~ gap-1 center" px3 py1 text-primary
|
flex="~ gap-1 center" px3 py1 text-primary
|
||||||
@click="$pwa.updateServiceWorker()"
|
@click="useNuxtApp().$pwa?.updateServiceWorker()"
|
||||||
>
|
>
|
||||||
<div i-ri-download-cloud-2-line />
|
<div i-ri-download-cloud-2-line />
|
||||||
<h2 flex="~ gap-2" items-center>
|
<h2 flex="~ gap-2" items-center>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
v-if="$pwa?.showInstallPrompt && !$pwa?.needRefresh"
|
v-if="useNuxtApp().$pwa?.showInstallPrompt && !useNuxtApp().$pwa?.needRefresh"
|
||||||
m-2 p5 bg="primary-fade" relative
|
m-2 p5 bg="primary-fade" relative
|
||||||
rounded-lg of-hidden
|
rounded-lg of-hidden
|
||||||
flex="~ col gap-3"
|
flex="~ col gap-3"
|
||||||
|
@ -10,10 +10,10 @@
|
||||||
{{ $t('pwa.install_title') }}
|
{{ $t('pwa.install_title') }}
|
||||||
</h2>
|
</h2>
|
||||||
<div flex="~ gap-1">
|
<div flex="~ gap-1">
|
||||||
<button type="button" btn-solid px-4 py-1 text-center text-sm @click="$pwa.install()">
|
<button type="button" btn-solid px-4 py-1 text-center text-sm @click="useNuxtApp().$pwa?.install()">
|
||||||
{{ $t('pwa.install') }}
|
{{ $t('pwa.install') }}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" btn-text filter-saturate-0 px-4 py-1 text-center text-sm @click="$pwa.cancelInstall()">
|
<button type="button" btn-text filter-saturate-0 px-4 py-1 text-center text-sm @click="useNuxtApp().$pwa?.cancelInstall()">
|
||||||
{{ $t('pwa.dismiss') }}
|
{{ $t('pwa.dismiss') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
v-if="$pwa?.needRefresh"
|
v-if="useNuxtApp().$pwa?.needRefresh"
|
||||||
m-2 p5 bg="primary-fade" relative
|
m-2 p5 bg="primary-fade" relative
|
||||||
rounded-lg of-hidden
|
rounded-lg of-hidden
|
||||||
flex="~ col gap-3"
|
flex="~ col gap-3"
|
||||||
|
@ -9,10 +9,10 @@
|
||||||
{{ $t('pwa.title') }}
|
{{ $t('pwa.title') }}
|
||||||
</h2>
|
</h2>
|
||||||
<div flex="~ gap-1">
|
<div flex="~ gap-1">
|
||||||
<button type="button" btn-solid px-4 py-1 text-center text-sm @click="$pwa.updateServiceWorker()">
|
<button type="button" btn-solid px-4 py-1 text-center text-sm @click="useNuxtApp().$pwa?.updateServiceWorker()">
|
||||||
{{ $t('pwa.update') }}
|
{{ $t('pwa.update') }}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" btn-text filter-saturate-0 px-4 py-1 text-center text-sm @click="$pwa.close()">
|
<button type="button" btn-text filter-saturate-0 px-4 py-1 text-center text-sm @click="useNuxtApp().$pwa?.close()">
|
||||||
{{ $t('pwa.dismiss') }}
|
{{ $t('pwa.dismiss') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
266
components/report/ReportModal.vue
Normal file
266
components/report/ReportModal.vue
Normal file
|
@ -0,0 +1,266 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { mastodon } from 'masto'
|
||||||
|
import { toggleBlockAccount, toggleFollowAccount, toggleMuteAccount, useRelationship } from '~~/composables/masto/relationship'
|
||||||
|
|
||||||
|
const { account, status } = defineProps<{
|
||||||
|
account: mastodon.v1.Account
|
||||||
|
status?: mastodon.v1.Status
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(event: 'close'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { client } = useMasto()
|
||||||
|
|
||||||
|
const step = ref('selectCategory')
|
||||||
|
const serverRules = ref((await client.value.v2.instance.fetch()).rules || [])
|
||||||
|
const reportReason = ref('')
|
||||||
|
const selectedRuleIds = ref([])
|
||||||
|
const availableStatuses = ref(status ? [status] : [])
|
||||||
|
const selectedStatusIds = ref(status ? [status.id] : [])
|
||||||
|
const additionalComments = ref('')
|
||||||
|
const forwardReport = ref(false)
|
||||||
|
|
||||||
|
const dismissButton = ref<HTMLDivElement>()
|
||||||
|
|
||||||
|
loadStatuses() // Load statuses asynchronously ahead of time
|
||||||
|
|
||||||
|
function categoryChosen() {
|
||||||
|
step.value = reportReason.value === 'dontlike' ? 'furtherActions' : 'selectStatuses'
|
||||||
|
resetModal()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadStatuses() {
|
||||||
|
if (status) {
|
||||||
|
// Load the 5 statuses before and after the reported status
|
||||||
|
const prevStatuses = await client.value.v1.accounts.$select(account.id).statuses.list({
|
||||||
|
maxId: status.id,
|
||||||
|
limit: 5,
|
||||||
|
})
|
||||||
|
const nextStatuses = await client.value.v1.accounts.$select(account.id).statuses.list({
|
||||||
|
minId: status.id,
|
||||||
|
limit: 5,
|
||||||
|
})
|
||||||
|
availableStatuses.value = availableStatuses.value.concat(prevStatuses)
|
||||||
|
availableStatuses.value = availableStatuses.value.concat(nextStatuses)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// Reporting an account directly
|
||||||
|
// Load the 10 most recent statuses
|
||||||
|
const mostRecentStatuses = await client.value.v1.accounts.$select(account.id).statuses.list({
|
||||||
|
limit: 10,
|
||||||
|
})
|
||||||
|
availableStatuses.value = mostRecentStatuses
|
||||||
|
}
|
||||||
|
availableStatuses.value.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitReport() {
|
||||||
|
await client.value.v1.reports.create({
|
||||||
|
accountId: account.id,
|
||||||
|
statusIds: selectedStatusIds.value,
|
||||||
|
comment: additionalComments.value,
|
||||||
|
forward: forwardReport.value,
|
||||||
|
category: reportReason.value === 'spam' ? 'spam' : reportReason.value === 'violation' ? 'violation' : 'other',
|
||||||
|
ruleIds: reportReason.value === 'violation' ? selectedRuleIds.value : null,
|
||||||
|
})
|
||||||
|
step.value = 'furtherActions'
|
||||||
|
resetModal()
|
||||||
|
}
|
||||||
|
|
||||||
|
function unfollow() {
|
||||||
|
emit('close')
|
||||||
|
toggleFollowAccount(useRelationship(account).value!, account)
|
||||||
|
}
|
||||||
|
|
||||||
|
function mute() {
|
||||||
|
emit('close')
|
||||||
|
toggleMuteAccount(useRelationship(account).value!, account)
|
||||||
|
}
|
||||||
|
|
||||||
|
function block() {
|
||||||
|
emit('close')
|
||||||
|
toggleBlockAccount(useRelationship(account).value!, account)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetModal() {
|
||||||
|
// TODO: extract this scroll/reset logic into ModalDialog element
|
||||||
|
dismissButton.value?.scrollIntoView() // scroll to top
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div my-8 px-3 sm:px-8 flex="~ col gap-4" relative>
|
||||||
|
<h2 mxa text-xl>
|
||||||
|
<i18n-t :keypath="reportReason === 'dontlike' ? 'report.limiting' : 'report.reporting'">
|
||||||
|
<b text-primary>@{{ account.acct }}</b>
|
||||||
|
</i18n-t>
|
||||||
|
</h2>
|
||||||
|
<button ref="dismissButton" btn-action-icon absolute top--8 right-0 m1 aria-label="Close" @click="emit('close')">
|
||||||
|
<div i-ri:close-line />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<template v-if="step === 'selectCategory'">
|
||||||
|
<h1 mxa text-4xl mb4>
|
||||||
|
{{ status ? $t('report.whats_wrong_post') : $t('report.whats_wrong_account') }}
|
||||||
|
</h1>
|
||||||
|
<p text-xl>
|
||||||
|
{{ $t('report.select_one') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<input id="dontlike" v-model="reportReason" type="radio" value="dontlike">
|
||||||
|
<label pl-2 for="dontlike" font-bold>{{ $t('report.dontlike') }}</label>
|
||||||
|
<p pl-6>
|
||||||
|
{{ $t('report.dontlike_desc') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<input id="spam" v-model="reportReason" type="radio" value="spam">
|
||||||
|
<label pl-2 for="spam" font-bold>{{ $t('report.spam') }}</label>
|
||||||
|
<p pl-6>
|
||||||
|
{{ $t('report.spam_desc') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="serverRules.length > 0">
|
||||||
|
<input id="violation" v-model="reportReason" type="radio" value="violation">
|
||||||
|
<label pl-2 for="violation" font-bold>{{ $t('report.violation') }}</label>
|
||||||
|
<p v-if="reportReason === 'violation'" pl-6 pt-2 text-primary font-bold>
|
||||||
|
{{ $t('report.select_many') }}
|
||||||
|
</p>
|
||||||
|
<ul pl-6>
|
||||||
|
<li v-for="rule in serverRules" :key="rule.id" pt-2>
|
||||||
|
<input
|
||||||
|
:id="rule.id"
|
||||||
|
v-model="selectedRuleIds"
|
||||||
|
type="checkbox"
|
||||||
|
:value="rule.id"
|
||||||
|
:disabled="reportReason !== 'violation'"
|
||||||
|
>
|
||||||
|
<label pl-2 :for="rule.id">{{ rule.text }}</label>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<input id="other" v-model="reportReason" type="radio" value="other">
|
||||||
|
<label pl-2 for="other" font-bold>{{ $t('report.other') }}</label>
|
||||||
|
<p pl-6>
|
||||||
|
{{ $t('report.other_desc') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="reportReason && reportReason !== 'dontlike'">
|
||||||
|
<h3 mt-8 mb-4 font-bold>
|
||||||
|
{{ $t('report.anything_else') }}
|
||||||
|
</h3>
|
||||||
|
<textarea v-model="additionalComments" w-full h-20 p-3 border :placeholder="$t('report.additional_comments')" />
|
||||||
|
<div v-if="getServerName(account) && getServerName(account) !== currentServer">
|
||||||
|
<h3 mt-8 mb-2 font-bold>
|
||||||
|
{{ $t('report.another_server') }}
|
||||||
|
</h3>
|
||||||
|
<p pb-1>
|
||||||
|
{{ $t('report.forward_question') }}
|
||||||
|
</p>
|
||||||
|
<input id="forward" v-model="forwardReport" type="checkbox" value="rule.id">
|
||||||
|
<label pl-2 for="forward"><b>{{ $t('report.forward', [getServerName(account)]) }}</b></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
btn-solid mxa mt-10
|
||||||
|
:disabled="!reportReason || (reportReason === 'violation' && selectedRuleIds.length < 1)"
|
||||||
|
@click="categoryChosen()"
|
||||||
|
>
|
||||||
|
{{ $t('action.next') }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="step === 'selectStatuses'">
|
||||||
|
<h1 mxa text-4xl mb4>
|
||||||
|
{{ status ? $t('report.select_posts_other') : $t('report.select_posts') }}
|
||||||
|
</h1>
|
||||||
|
<p text-primary font-bold>
|
||||||
|
{{ $t('report.select_many') }}
|
||||||
|
</p>
|
||||||
|
<table>
|
||||||
|
<tr v-for="availableStatus in availableStatuses" :key="availableStatus.id">
|
||||||
|
<td>
|
||||||
|
<input
|
||||||
|
:id="availableStatus.id"
|
||||||
|
v-model="selectedStatusIds"
|
||||||
|
type="checkbox"
|
||||||
|
:value="availableStatus.id"
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<label :for="availableStatus.id">
|
||||||
|
<StatusCard :status="availableStatus" :actions="false" pointer-events-none />
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
<button
|
||||||
|
btn-solid mxa mt-5
|
||||||
|
@click="submitReport()"
|
||||||
|
>
|
||||||
|
{{ $t('report.submit') }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="step === 'furtherActions'">
|
||||||
|
<h1 mxa text-4xl mb4>
|
||||||
|
{{ reportReason === 'dontlike' ? $t('report.further_actions.limit.title') : $t('report.further_actions.report.title') }}
|
||||||
|
</h1>
|
||||||
|
<p text-xl>
|
||||||
|
{{ reportReason === 'dontlike' ? $t('report.further_actions.limit.description') : $t('report.further_actions.report.description') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div v-if="useRelationship(account).value?.following">
|
||||||
|
<button btn-outline mxa mt-4 mb-2 @click="unfollow()">
|
||||||
|
<i18n-t keypath="menu.unfollow_account">
|
||||||
|
<b>@{{ account.acct }}</b>
|
||||||
|
</i18n-t>
|
||||||
|
</button><br>
|
||||||
|
{{ $t('report.unfollow_desc') }}
|
||||||
|
</div>
|
||||||
|
<div v-if="!useRelationship(account).value?.muting">
|
||||||
|
<button btn-outline mxa mt-4 mb-2 @click="mute()">
|
||||||
|
<i18n-t keypath="menu.mute_account">
|
||||||
|
<b>@{{ account.acct }}</b>
|
||||||
|
</i18n-t>
|
||||||
|
</button><br>
|
||||||
|
{{ $t('report.mute_desc') }}
|
||||||
|
</div>
|
||||||
|
<div v-if="!useRelationship(account).value?.blocking">
|
||||||
|
<button btn-outline mxa mt-4 mb-2 @click="block()">
|
||||||
|
<i18n-t keypath="menu.block_account">
|
||||||
|
<b>@{{ account.acct }}</b>
|
||||||
|
</i18n-t>
|
||||||
|
</button><br>
|
||||||
|
{{ $t('report.block_desc') }}
|
||||||
|
</div>
|
||||||
|
<button btn-solid mxa mt-10 @click="emit('close')">
|
||||||
|
{{ $t('action.done') }}
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
tr {
|
||||||
|
border-bottom-width: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:last-child {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding-top: 10px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -6,12 +6,15 @@ defineProps<{
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- TODO: reuse AccountInfo.vue -->
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div flex gap-2 items-center>
|
<div flex gap-2 items-center>
|
||||||
<AccountAvatar w-10 h-10 :account="account" shrink-0 />
|
<AccountAvatar w-10 h-10 :account="account" shrink-0 />
|
||||||
<div flex="~ col gap1" shrink h-full overflow-hidden leading-none>
|
<div flex="~ col gap1" shrink h-full overflow-hidden leading-none>
|
||||||
<div flex="~" gap-2>
|
<div flex="~" gap-2>
|
||||||
<AccountDisplayName :account="account" line-clamp-1 ws-pre-wrap break-all text-base />
|
<AccountDisplayName :account="account" line-clamp-1 ws-pre-wrap break-all text-base />
|
||||||
|
<AccountLockIndicator v-if="account.locked" text-xs />
|
||||||
<AccountBotIndicator v-if="account.bot" text-xs />
|
<AccountBotIndicator v-if="account.bot" text-xs />
|
||||||
</div>
|
</div>
|
||||||
<AccountHandle text-sm :account="account" text-secondary-light />
|
<AccountHandle text-sm :account="account" text-secondary-light />
|
||||||
|
|
|
@ -5,7 +5,7 @@ const { hashtag } = defineProps<{
|
||||||
hashtag: mastodon.v1.Tag
|
hashtag: mastodon.v1.Tag
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const totalTrend = $computed(() =>
|
const totalTrend = computed(() =>
|
||||||
hashtag.history?.reduce((total: number, item) => total + (Number(item.accounts) || 0), 0),
|
hashtag.history?.reduce((total: number, item) => total + (Number(item.accounts) || 0), 0),
|
||||||
)
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue