forked from Mirrors/elk
Compare commits
654 commits
userquin/f
...
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 | ||
|
a82558130a | ||
|
b51bae5361 | ||
|
c94ff39982 | ||
|
5717e5e677 | ||
|
0dbea5915f | ||
|
f533a10097 | ||
|
c0561e7eed | ||
|
0418d05753 | ||
|
01d1a30413 | ||
|
55e0f040a3 | ||
|
60924e5f5d | ||
|
776594ec3d | ||
|
189695c767 | ||
|
791642fac4 | ||
|
5dd3f4bfa3 | ||
|
a25376b60d | ||
|
eea2511e1d | ||
|
01ed4f68dc | ||
|
6abd2a8770 | ||
|
9465c2fe89 | ||
|
c7558ee7c5 | ||
|
8c7dc5a6df | ||
|
ae268de5bf | ||
|
05f3f04578 | ||
|
dc397e133c | ||
|
3732a2cc16 | ||
|
fa3cfd6059 | ||
|
c76e8e8f5e | ||
|
9589bbae3a | ||
|
ecd17c6709 | ||
|
0f477236ac | ||
|
82d6b2ad29 | ||
|
c4d8137186 | ||
|
69c1bd8b6a | ||
|
bc09a28af6 | ||
|
cb0b7b58bb | ||
|
8fd697126b | ||
|
b72809c048 | ||
|
73a8e4f545 | ||
|
cd6d328266 | ||
|
dbfb450e23 | ||
|
f3ad179c69 | ||
|
4d88c79019 | ||
|
b7ebf31bd9 | ||
|
1445a57d8d | ||
|
ddb765c4a2 | ||
|
32eaee82e2 | ||
|
2842a5f383 | ||
|
a5cece7b42 | ||
|
baba3faa34 | ||
|
2ccec5e09c | ||
|
1356f86b54 | ||
|
5259f6940f | ||
|
5edf713bcc | ||
|
981d777682 | ||
|
9e455499b9 | ||
|
3596d8bec3 | ||
|
d810b2de3a | ||
|
8c783204d3 | ||
|
958f5967b9 | ||
|
bcf0965795 | ||
|
f98c667613 | ||
|
29b37e67c7 | ||
|
015d8a944e | ||
|
41175b02ca | ||
|
75ca138c6e | ||
|
aeba239964 | ||
|
fb01c467cb | ||
|
6b649df218 | ||
|
2a06cf26d8 | ||
|
3ad0abce3b | ||
|
1be6a8c6ec | ||
|
ff070ea9da |
417 changed files with 29504 additions and 12112 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
|
12
.github/renovate.json5
vendored
12
.github/renovate.json5
vendored
|
@ -3,6 +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",
|
||||||
|
"ignoreDeps": [
|
||||||
|
"vue",
|
||||||
|
"vue-tsc",
|
||||||
|
"typescript",
|
||||||
|
|
||||||
|
// Intl.Segmenter is not supported in Firefox
|
||||||
|
"string-length"
|
||||||
|
],
|
||||||
"packageRules": [
|
"packageRules": [
|
||||||
{
|
{
|
||||||
"groupName": "devDependencies",
|
"groupName": "devDependencies",
|
||||||
|
@ -56,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
|
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.0.2
|
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
|
47
.vscode/settings.json
vendored
47
.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"
|
||||||
},
|
},
|
||||||
|
@ -22,8 +18,45 @@
|
||||||
],
|
],
|
||||||
"i18n-ally.preferredDelimiter": "_",
|
"i18n-ally.preferredDelimiter": "_",
|
||||||
"i18n-ally.sortKeys": true,
|
"i18n-ally.sortKeys": true,
|
||||||
"i18n-ally.sourceLanguage": "en-US",
|
"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
|
|
@ -1,6 +1,6 @@
|
||||||
# Contributing Guide
|
# Contributing Guide
|
||||||
|
|
||||||
Hi! We are really excited that you are interested in contributing to Elk. Before submitting your contribution, please make sure to take a moment and read through the following guide.
|
Hi! We are excited that you are interested in contributing to Elk. Before submitting your contribution, please make sure to take a moment and read through the following guide.
|
||||||
|
|
||||||
Refer also to https://github.com/antfu/contribute.
|
Refer also to https://github.com/antfu/contribute.
|
||||||
|
|
||||||
|
@ -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:
|
||||||
|
@ -54,16 +53,16 @@ nr test
|
||||||
|
|
||||||
In order to run Elk with PWA enabled, run `pnpm dev:pwa` in Elk's root folder to start dev server or `pnpm dev:mocked:pwa` to start dev server with `@elkdev@universeodon.com` user.
|
In order to run Elk with PWA enabled, run `pnpm dev:pwa` in Elk's root folder to start dev server or `pnpm dev:mocked:pwa` to start dev server with `@elkdev@universeodon.com` user.
|
||||||
|
|
||||||
You should test the Elk PWA application on private browsing mode on any Chromium based browser: will not work on Firefox and Safari.
|
You should test the Elk PWA application on private browsing mode on any Chromium-based browser: will not work on Firefox and Safari.
|
||||||
|
|
||||||
If not using private browsing mode, you will need to uninstall the PWA application from your browser once you finish testing:
|
If not using private browsing mode, you will need to uninstall the PWA application from your browser once you finish testing:
|
||||||
- Open `Dev Tools` (`Option + ⌘ + J` on macOS, `Shift + CTRL + J` on Windows/Linux)
|
- Open `Dev Tools` (`Option + ⌘ + J` on macOS, `Shift + CTRL + J` on Windows/Linux)
|
||||||
- Go to `Application > Storage`, you should check following checkboxes:
|
- Go to `Application > Storage`, you should check the following checkboxes:
|
||||||
- Application: [x] Unregister service worker
|
- Application: [x] Unregister service worker
|
||||||
- Storage: [x] IndexedDB and [x] Local and session storage
|
- Storage: [x] IndexedDB and [x] Local and session storage
|
||||||
- Cache: [x] Cache storage and [x] Application cache
|
- Cache: [x] Cache storage and [x] Application cache
|
||||||
- Click on `Clear site data` button
|
- Click on `Clear site data` button
|
||||||
- Go to `Application > Service Workers` and check the current `service worker` is missing or has the state `deleted` or `redundant`
|
- Go to `Application > Service Workers` and check if the current `service worker` is missing or has the state `deleted` or `redundant`
|
||||||
|
|
||||||
## CI errors
|
## CI errors
|
||||||
|
|
||||||
|
@ -82,18 +81,18 @@ Elk supports `right-to-left` languages, we need to make sure that the UI is work
|
||||||
Simple approach used by most websites of relying on direction set in HTML element does not work because direction for various items, such as timeline, does not always match direction set in HTML.
|
Simple approach used by most websites of relying on direction set in HTML element does not work because direction for various items, such as timeline, does not always match direction set in HTML.
|
||||||
|
|
||||||
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`. Same rules applies for 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 rule above. For icons inside 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 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 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`.
|
||||||
|
|
||||||
## Internationalization
|
## Internationalization
|
||||||
|
|
||||||
We are using [vue-i18n](https://vue-i18n.intlify.dev/) via [nuxt-i18n](https://v8.i18n.nuxtjs.org/) to handle internationalization.
|
We are using [vue-i18n](https://vue-i18n.intlify.dev/) via [nuxt-i18n](https://v8.i18n.nuxtjs.org/) to handle internationalization.
|
||||||
|
|
||||||
You can check current [translation status](https://docs.elk.zone/docs/guide/contributing#translation-status): more instructions on table caption.
|
You can check the current [translation status](https://docs.elk.zone/docs/guide/contributing#translation-status): more instructions on the table caption.
|
||||||
|
|
||||||
If you are updating a translation in your local environment, you can run the following commands to check the status:
|
If you are updating a translation in your local environment, you can run the following commands to check the status:
|
||||||
- from root folder: `nr prepare-translation-status`
|
- from root folder: `nr prepare-translation-status`
|
||||||
|
@ -103,9 +102,9 @@ 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 have 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)
|
||||||
- Add all country variants files with empty `messages` object: `{}`
|
- Add all country variants files with empty `messages` object: `{}`
|
||||||
- Translate the strings in the generic language file
|
- Translate the strings in the generic language file
|
||||||
|
@ -120,7 +119,7 @@ Check [Pluralization rule callback](https://vue-i18n.intlify.dev/guide/essential
|
||||||
|
|
||||||
### Messages interpolation
|
### Messages interpolation
|
||||||
|
|
||||||
Most of the messages used in Elk do not require any interpolation, however, there are some messages that require interpolation: check [Message Format Syntax](https://vue-i18n.intlify.dev/guide/essentials/syntax.html) for more info.
|
Most of the messages used in Elk do not require any interpolation, however, some messages require interpolation: check [Message Format Syntax](https://vue-i18n.intlify.dev/guide/essentials/syntax.html) for more info.
|
||||||
|
|
||||||
We're using these types of interpolation:
|
We're using these types of interpolation:
|
||||||
- [List interpolation](https://vue-i18n.intlify.dev/guide/essentials/syntax.html#list-interpolation)
|
- [List interpolation](https://vue-i18n.intlify.dev/guide/essentials/syntax.html#list-interpolation)
|
||||||
|
@ -142,7 +141,7 @@ Check [Custom Plural Number Formatting Entries](#custom-plural-number-formatting
|
||||||
|
|
||||||
When using plural number formatting, we'll have always `{n}` available in the message, for example, `You have {n} new notifications|You have {n} new notification|You have {n} new notifications` or `You have no new notifications|You have 1 new notification|You have {n} new notifications`.
|
When using plural number formatting, we'll have always `{n}` available in the message, for example, `You have {n} new notifications|You have {n} new notification|You have {n} new notifications` or `You have no new notifications|You have 1 new notification|You have {n} new notifications`.
|
||||||
|
|
||||||
We've included `v` named parameter, it will be used to pass the formatted number using [Intl.NumberFormat::format](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/format): will be the number with separators symbols. The exception to previous rule is when we're using `plural` **with** `i18n-t` component, in this case, we'll need to use `{0}` instead `{v}` to access the number.
|
We've included `v` named parameter, it will be used to pass the formatted number using [Intl.NumberFormat::format](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/format): will be the number with separators symbols. The exception to the previous rule is when we're using `plural` **with** `i18n-t` component, in this case, we'll need to use `{0}` instead `{v}` to access the number.
|
||||||
|
|
||||||
Additionally, Elk will use [compact notation for numbers](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat#parameters) for some entries, check `notation` and `compactDisplay` options: for example, `1K` for `1000`, `1M` for `1000000`, `1B` for `1000000000` and so on. That entry will be available in the message using `{v}` named parameter (or `{0}` if using the message **with** `i18n-t` component).
|
Additionally, Elk will use [compact notation for numbers](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat/NumberFormat#parameters) for some entries, check `notation` and `compactDisplay` options: for example, `1K` for `1000`, `1M` for `1000000`, `1B` for `1000000000` and so on. That entry will be available in the message using `{v}` named parameter (or `{0}` if using the message **with** `i18n-t` component).
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
@ -34,8 +35,8 @@ ARG GID=911
|
||||||
|
|
||||||
# Create a dedicated user and group
|
# Create a dedicated user and group
|
||||||
RUN set -eux; \
|
RUN set -eux; \
|
||||||
addgroup -g $UID elk; \
|
addgroup -g $GID elk; \
|
||||||
adduser -u $GID -D -G elk elk;
|
adduser -u $UID -D -G elk elk;
|
||||||
|
|
||||||
USER elk
|
USER elk
|
||||||
|
|
||||||
|
|
25
README.md
25
README.md
|
@ -37,17 +37,36 @@ The Elk team maintains a deployment at:
|
||||||
- 🦌 Production: [elk.zone](https://elk.zone)
|
- 🦌 Production: [elk.zone](https://elk.zone)
|
||||||
- 🐙 Canary: [main.elk.zone](https://main.elk.zone) (deploys on every commit to `main` branch)
|
- 🐙 Canary: [main.elk.zone](https://main.elk.zone) (deploys on every commit to `main` branch)
|
||||||
|
|
||||||
|
### 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 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.
|
||||||
|
|
||||||
|
1. checkout source ```git clone https://github.com/elk-zone/elk.git```
|
||||||
|
1. got into new source dir: ```cd elk```
|
||||||
|
1. build Docker image: ```docker build .```
|
||||||
|
1. create local storage directory for settings: ```mkdir elk-storage```
|
||||||
|
1. adjust permissions of storage dir: ```sudo chown 911:911 ./elk-storage```
|
||||||
|
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.
|
||||||
|
|
||||||
### 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.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.freelancers.online](https://elk.freelancers.online) - Use Elk for the `freelancers.online` 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.
|
||||||
|
|
||||||
|
@ -119,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
|
||||||
|
|
||||||
|
@ -132,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)"
|
||||||
/>
|
/>
|
||||||
|
<slot>
|
||||||
<div h-full p1 shrink-0>
|
<div h-full p1 shrink-0>
|
||||||
<AccountFollowButton :account="account" :context="relationshipContext" />
|
<AccountFollowButton :account="account" :context="relationshipContext" />
|
||||||
</div>
|
</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="isLoading">
|
||||||
|
<span i-svg-spinners-180-ring-with-bg />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
<template v-if="relationship?.blocking">
|
<template v-if="relationship?.blocking">
|
||||||
<span group-hover="hidden">{{ $t('account.blocking') }}</span>
|
<span elk-group-hover="hidden">{{ $t('account.blocking') }}</span>
|
||||||
<span hidden group-hover="inline">{{ $t('account.unblock') }}</span>
|
<span hidden elk-group-hover="inline">{{ $t('account.unblock') }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="relationship?.muting">
|
<template v-if="relationship?.muting">
|
||||||
<span group-hover="hidden">{{ $t('account.muting') }}</span>
|
<span elk-group-hover="hidden">{{ $t('account.muting') }}</span>
|
||||||
<span hidden group-hover="inline">{{ $t('account.unmute') }}</span>
|
<span hidden elk-group-hover="inline">{{ $t('account.unmute') }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="relationship ? relationship.following : context === 'following'">
|
<template v-else-if="relationship ? relationship.following : context === 'following'">
|
||||||
<span group-hover="hidden">{{ relationship?.followedBy ? $t('account.mutuals') : $t('account.following') }}</span>
|
<span elk-group-hover="hidden">{{ relationship?.followedBy ? $t('account.mutuals') : $t('account.following') }}</span>
|
||||||
<span hidden group-hover="inline">{{ $t('account.unfollow') }}</span>
|
<span hidden elk-group-hover="inline">{{ $t('account.unfollow') }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="relationship?.requested">
|
<template v-else-if="relationship?.requested">
|
||||||
<span>{{ $t('account.follow_requested') }}</span>
|
<span elk-group-hover="hidden">{{ $t('account.follow_requested') }}</span>
|
||||||
|
<span hidden elk-group-hover="inline">{{ $t('account.withdraw_follow_request') }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="relationship ? relationship.followedBy : context === 'followedBy'">
|
<template v-else-if="relationship ? relationship.followedBy : context === 'followedBy'">
|
||||||
<span group-hover="hidden">{{ $t('account.follows_you') }}</span>
|
<span elk-group-hover="hidden">{{ $t('account.follows_you') }}</span>
|
||||||
<span hidden group-hover="inline">{{ $t('account.follow_back') }}</span>
|
<span hidden elk-group-hover="inline">{{ account.locked ? $t('account.request_follow') : $t('account.follow_back') }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<span>{{ account.locked ? $t('account.request_follow') : $t('account.follow') }}</span>
|
<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">
|
||||||
|
<VMenu
|
||||||
|
v-if="!disabled && account && !getPreferences(userSettings, 'hideAccountHoverCard')"
|
||||||
|
placement="bottom-start"
|
||||||
|
:delay="{ show: 500, hide: 100 }"
|
||||||
|
v-bind="$attrs"
|
||||||
|
:close-on-content-click="false"
|
||||||
|
>
|
||||||
<slot />
|
<slot />
|
||||||
<template #popper>
|
<template #popper>
|
||||||
<AccountHoverCard v-if="account" :account="account" />
|
<AccountHoverCard v-if="account" :account="account" />
|
||||||
</template>
|
</template>
|
||||||
</VMenu>
|
</VMenu>
|
||||||
<slot v-else />
|
<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 />
|
||||||
|
|
|
@ -10,11 +10,18 @@ const { link = true, avatar = true } = defineProps<{
|
||||||
const userSettings = useUserSettings()
|
const userSettings = useUserSettings()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
export default {
|
||||||
|
inheritAttrs: false,
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AccountHoverWrapper :account="account">
|
<AccountHoverWrapper :account="account">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
:to="link ? getAccountRoute(account) : undefined"
|
:to="link ? getAccountRoute(account) : undefined"
|
||||||
:class="link ? 'text-link-rounded -ml-1.8rem pl-1.8rem rtl-(ml0 pl-0.5rem -mr-1.8rem pr-1.8rem)' : ''"
|
:class="link ? 'text-link-rounded -ml-1.5rem pl-1.5rem rtl-(ml0 pl-0.5rem -mr-1.5rem pr-1.5rem)' : ''"
|
||||||
|
v-bind="$attrs"
|
||||||
min-w-0 flex gap-2 items-center
|
min-w-0 flex gap-2 items-center
|
||||||
>
|
>
|
||||||
<AccountAvatar v-if="avatar" :account="account" w-5 h-5 />
|
<AccountAvatar v-if="avatar" :account="account" w-5 h-5 />
|
||||||
|
|
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,74 +1,63 @@
|
||||||
<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
|
|
||||||
relationship = relationship!.muting
|
|
||||||
? await client.v1.accounts.mute(account.id, {
|
|
||||||
// TODO support more options
|
|
||||||
})
|
|
||||||
: await client.v1.accounts.unmute(account.id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleBlockUser = async () => {
|
async function toggleReblogs() {
|
||||||
if (!relationship!.blocking && await openConfirmDialog({
|
if (!relationship.value!.showingReblogs) {
|
||||||
title: t('confirm.block_account.title', [account.acct]),
|
const dialogChoice = await openConfirmDialog({
|
||||||
confirm: t('confirm.block_account.confirm'),
|
title: t('confirm.show_reblogs.title'),
|
||||||
cancel: t('confirm.block_account.cancel'),
|
description: t('confirm.show_reblogs.description', [account.acct]),
|
||||||
}) !== 'confirm')
|
|
||||||
return
|
|
||||||
|
|
||||||
relationship!.blocking = !relationship!.blocking
|
|
||||||
relationship = await client.v1.accounts[relationship!.blocking ? 'block' : 'unblock'](account.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleBlockDomain = async () => {
|
|
||||||
if (!relationship!.domainBlocking && await openConfirmDialog({
|
|
||||||
title: t('confirm.block_domain.title', [getServerName(account)]),
|
|
||||||
confirm: t('confirm.block_domain.confirm'),
|
|
||||||
cancel: t('confirm.block_domain.cancel'),
|
|
||||||
}) !== 'confirm')
|
|
||||||
return
|
|
||||||
|
|
||||||
relationship!.domainBlocking = !relationship!.domainBlocking
|
|
||||||
await client.v1.domainBlocks[relationship!.domainBlocking ? 'block' : 'unblock'](getServerName(account))
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleReblogs = async () => {
|
|
||||||
if (!relationship!.showingReblogs && await openConfirmDialog({
|
|
||||||
title: t('confirm.show_reblogs.title', [account.acct]),
|
|
||||||
confirm: t('confirm.show_reblogs.confirm'),
|
confirm: t('confirm.show_reblogs.confirm'),
|
||||||
cancel: t('confirm.show_reblogs.cancel'),
|
cancel: t('confirm.show_reblogs.cancel'),
|
||||||
}) !== 'confirm')
|
})
|
||||||
|
if (dialogChoice.choice !== 'confirm')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const showingReblogs = !relationship.value?.showingReblogs
|
||||||
|
relationship.value = await client.value.v1.accounts.$select(account.id).follow({ reblogs: showingReblogs })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addUserNote() {
|
||||||
|
emit('addNote')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeUserNote() {
|
||||||
|
if (!relationship.value!.note || relationship.value!.note.length === 0)
|
||||||
return
|
return
|
||||||
|
|
||||||
const showingReblogs = !relationship?.showingReblogs
|
const newNote = await client.value.v1.accounts.$select(account.id).note.create({ comment: '' })
|
||||||
relationship = await client.v1.accounts.follow(account.id, { reblogs: showingReblogs })
|
relationship.value!.note = newNote.note
|
||||||
|
emit('removeNote')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<CommonDropdown :eager-mount="command">
|
<CommonDropdown :eager-mount="command">
|
||||||
<button flex gap-1 items-center w-full rounded op75 hover="op100 text-purple" group aria-label="More actions">
|
<button flex gap-1 items-center w-full rounded op75 hover="op100 text-purple" group aria-label="More actions">
|
||||||
<div rounded-5 p2 group-hover="bg-purple/10">
|
<div rounded-5 p2 elk-group-hover="bg-purple/10">
|
||||||
<div i-ri:more-2-fill />
|
<div i-ri:more-2-fill />
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
@ -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,32 +27,51 @@ const userSettings = useUserSettings()
|
||||||
</template>
|
</template>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
|
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 }">
|
||||||
|
<template
|
||||||
|
v-if="!getPreferences(userSettings, 'hideFollowerCount')"
|
||||||
|
>
|
||||||
<CommonLocalizedNumber
|
<CommonLocalizedNumber
|
||||||
|
v-if="account.followingCount >= 0"
|
||||||
keypath="account.following_count"
|
keypath="account.following_count"
|
||||||
:count="account.followingCount"
|
:count="account.followingCount"
|
||||||
font-bold
|
font-bold
|
||||||
:class="isExactActive ? 'text-primary' : 'text-base'"
|
: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 }">
|
||||||
|
<template v-if="!getPreferences(userSettings, 'hideFollowerCount')">
|
||||||
<CommonLocalizedNumber
|
<CommonLocalizedNumber
|
||||||
|
v-if="account.followersCount >= 0"
|
||||||
keypath="account.followers_count"
|
keypath="account.followers_count"
|
||||||
:count="account.followersCount"
|
:count="account.followersCount"
|
||||||
font-bold
|
font-bold
|
||||||
:class="isExactActive ? 'text-primary' : 'text-base'"
|
: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 {
|
||||||
|
return {
|
||||||
index: 0,
|
index: 0,
|
||||||
type: 'search',
|
type: 'search',
|
||||||
search,
|
search,
|
||||||
onActivate: () => router.push(search.to),
|
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>
|
||||||
|
|
|
@ -14,7 +14,7 @@ const build = useBuildInfo()
|
||||||
<p>
|
<p>
|
||||||
<i18n-t keypath="help.build_preview.desc1">
|
<i18n-t keypath="help.build_preview.desc1">
|
||||||
<NuxtLink :href="`https://github.com/elk-zone/elk/commit/${build.commit}`" target="_blank" text-rose hover:underline>
|
<NuxtLink :href="`https://github.com/elk-zone/elk/commit/${build.commit}`" target="_blank" text-rose hover:underline>
|
||||||
<code>{{ build.commit.slice(0, 7) }}</code>
|
<code>{{ build.shortCommit }}</code>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</i18n-t>
|
</i18n-t>
|
||||||
</p>
|
</p>
|
||||||
|
|
|
@ -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),
|
||||||
|
@ -29,9 +23,9 @@ useCommands(() => command
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div flex w-full items-center lg:text-lg of-x-auto scrollbar-hide>
|
<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>
|
||||||
|
<NuxtLink hover:text-primary href="https://github.com/sponsors/elk-zone" target="_blank">
|
||||||
{{ $t('help.desc_para3') }}
|
{{ $t('help.desc_para3') }}
|
||||||
<p flex="~ gap-2 wrap" mxa>
|
</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) {
|
||||||
|
|
|
@ -1,117 +1,101 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { mastodon } from 'masto'
|
import type { mastodon } from 'masto'
|
||||||
|
import { useForm } from 'slimeform'
|
||||||
|
|
||||||
const emit = defineEmits<{
|
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 } = $defineProps<{
|
const list = defineModel<mastodon.v1.List>({ required: true })
|
||||||
list: mastodon.v1.List
|
|
||||||
}>()
|
|
||||||
const { modelValue } = defineModel<{
|
|
||||||
modelValue: string
|
|
||||||
}>()
|
|
||||||
modelValue.value = list.title
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const client = useMastoClient()
|
const client = useMastoClient()
|
||||||
|
|
||||||
let isEditing = $ref<boolean>(false)
|
const { form, isDirty, submitter, reset } = useForm({
|
||||||
let busy = $ref<boolean>(false)
|
form: () => ({ ...list.value }),
|
||||||
let deleteBusy = $ref<boolean>(false)
|
})
|
||||||
let actionError = $ref<string | undefined>(undefined)
|
|
||||||
|
|
||||||
const enableSaveButton = computed(() => list.title !== modelValue.value)
|
const isEditing = ref<boolean>(false)
|
||||||
|
const deleting = ref<boolean>(false)
|
||||||
|
const actionError = ref<string | undefined>(undefined)
|
||||||
|
|
||||||
const edit = ref()
|
const input = ref<HTMLInputElement>()
|
||||||
const deleteBtn = ref()
|
const editBtn = ref<HTMLButtonElement>()
|
||||||
const input = ref()
|
const deleteBtn = ref<HTMLButtonElement>()
|
||||||
|
|
||||||
const prepareEdit = () => {
|
async function prepareEdit() {
|
||||||
isEditing = true
|
isEditing.value = true
|
||||||
actionError = undefined
|
actionError.value = undefined
|
||||||
nextTick(() => {
|
|
||||||
input.value?.focus()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const cancelEdit = () => {
|
|
||||||
isEditing = false
|
|
||||||
actionError = undefined
|
|
||||||
modelValue.value = list.title
|
|
||||||
|
|
||||||
nextTick(() => {
|
|
||||||
edit.value?.focus()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
async function finishEditing() {
|
|
||||||
if (busy || !isEditing || !enableSaveButton.value)
|
|
||||||
return
|
|
||||||
|
|
||||||
busy = true
|
|
||||||
actionError = undefined
|
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
input.value?.focus()
|
||||||
|
}
|
||||||
|
async function cancelEdit() {
|
||||||
|
isEditing.value = false
|
||||||
|
actionError.value = undefined
|
||||||
|
reset()
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
editBtn.value?.focus()
|
||||||
|
}
|
||||||
|
|
||||||
|
const { submit, submitting } = submitter(async () => {
|
||||||
try {
|
try {
|
||||||
const updateList = await client.v1.lists.update(list.id, {
|
list.value = await client.v1.lists.$select(form.id).update({
|
||||||
title: modelValue.value,
|
title: form.title,
|
||||||
})
|
})
|
||||||
cancelEdit()
|
cancelEdit()
|
||||||
emit('listUpdated', updateList)
|
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
actionError = (err as Error).message
|
actionError.value = (err as Error).message
|
||||||
nextTick(() => {
|
await nextTick()
|
||||||
input.value?.focus()
|
input.value?.focus()
|
||||||
})
|
|
||||||
}
|
}
|
||||||
finally {
|
})
|
||||||
busy = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async function removeList() {
|
async function removeList() {
|
||||||
if (deleteBusy)
|
if (deleting.value)
|
||||||
return
|
return
|
||||||
|
|
||||||
deleteBusy = true
|
|
||||||
actionError = undefined
|
|
||||||
await nextTick()
|
|
||||||
|
|
||||||
const confirmDelete = await openConfirmDialog({
|
const confirmDelete = await openConfirmDialog({
|
||||||
title: t('confirm.delete_list.title', [list.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'),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (confirmDelete === 'confirm') {
|
deleting.value = true
|
||||||
|
actionError.value = undefined
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
if (confirmDelete.choice === 'confirm') {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
try {
|
try {
|
||||||
await client.v1.lists.remove(list.id)
|
await client.v1.lists.$select(list.value.id).remove()
|
||||||
emit('listRemoved', list.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
|
||||||
nextTick(() => {
|
await nextTick()
|
||||||
deleteBtn.value?.focus()
|
deleteBtn.value?.focus()
|
||||||
})
|
|
||||||
}
|
}
|
||||||
finally {
|
finally {
|
||||||
deleteBusy = false
|
deleting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
deleteBusy = false
|
deleting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearError() {
|
async function clearError() {
|
||||||
actionError = undefined
|
actionError.value = undefined
|
||||||
nextTick(() => {
|
await nextTick()
|
||||||
if (isEditing)
|
if (isEditing.value)
|
||||||
input.value?.focus()
|
input.value?.focus()
|
||||||
else
|
else
|
||||||
deleteBtn.value?.focus()
|
deleteBtn.value?.focus()
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onDeactivated(cancelEdit)
|
onDeactivated(cancelEdit)
|
||||||
|
@ -122,7 +106,7 @@ onDeactivated(cancelEdit)
|
||||||
hover:bg-active flex justify-between items-center gap-x-2
|
hover:bg-active flex justify-between items-center gap-x-2
|
||||||
:aria-describedby="actionError ? `action-list-error-${list.id}` : undefined"
|
:aria-describedby="actionError ? `action-list-error-${list.id}` : undefined"
|
||||||
:class="actionError ? 'border border-base border-rounded rounded-be-is-0 rounded-be-ie-0 border-b-unset border-$c-danger-active' : null"
|
:class="actionError ? 'border border-base border-rounded rounded-be-is-0 rounded-be-ie-0 border-b-unset border-$c-danger-active' : null"
|
||||||
@submit.prevent="finishEditing"
|
@submit.prevent="submit"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="isEditing"
|
v-if="isEditing"
|
||||||
|
@ -141,20 +125,15 @@ onDeactivated(cancelEdit)
|
||||||
</CommonTooltip>
|
</CommonTooltip>
|
||||||
<input
|
<input
|
||||||
ref="input"
|
ref="input"
|
||||||
v-model="modelValue"
|
v-model="form.title"
|
||||||
rounded-3
|
rounded-3 w-full bg-transparent
|
||||||
w-full
|
outline="focus:none" pe-4 pb="1px"
|
||||||
bg-transparent
|
flex-1 placeholder-text-secondary
|
||||||
outline="focus:none"
|
|
||||||
pe-4
|
|
||||||
pb="1px"
|
|
||||||
flex-1
|
|
||||||
placeholder-text-secondary
|
|
||||||
@keydown.esc="cancelEdit()"
|
@keydown.esc="cancelEdit()"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<NuxtLink v-else :to="`list/${list.id}`" block grow p4>
|
<NuxtLink v-else :to="`list/${list.id}`" block grow p4>
|
||||||
{{ list.title }}
|
{{ form.title }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<div mr4 flex gap2>
|
<div mr4 flex gap2>
|
||||||
<CommonTooltip v-if="isEditing" :content="$t('list.save')" no-auto-focus>
|
<CommonTooltip v-if="isEditing" :content="$t('list.save')" no-auto-focus>
|
||||||
|
@ -163,10 +142,10 @@ onDeactivated(cancelEdit)
|
||||||
text-sm p2 border-1 transition-colors
|
text-sm p2 border-1 transition-colors
|
||||||
border-dark hover:text-primary
|
border-dark hover:text-primary
|
||||||
btn-action-icon
|
btn-action-icon
|
||||||
:disabled="deleteBusy || !enableSaveButton || busy"
|
:disabled="deleting || !isDirty || submitting"
|
||||||
>
|
>
|
||||||
<template v-if="isEditing">
|
<template v-if="isEditing">
|
||||||
<span v-if="busy" aria-hidden="true" block animate animate-spin preserve-3d class="rtl-flip">
|
<span v-if="submitting" aria-hidden="true" block animate animate-spin preserve-3d class="rtl-flip">
|
||||||
<span block i-ri:loader-2-fill aria-hidden="true" />
|
<span block i-ri:loader-2-fill aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
<span v-else block text-current i-ri:save-2-fill class="rtl-flip" />
|
<span v-else block text-current i-ri:save-2-fill class="rtl-flip" />
|
||||||
|
@ -175,7 +154,7 @@ onDeactivated(cancelEdit)
|
||||||
</CommonTooltip>
|
</CommonTooltip>
|
||||||
<CommonTooltip v-else :content="$t('list.edit')" no-auto-focus>
|
<CommonTooltip v-else :content="$t('list.edit')" no-auto-focus>
|
||||||
<button
|
<button
|
||||||
ref="edit"
|
ref="editBtn"
|
||||||
type="button"
|
type="button"
|
||||||
text-sm p2 border-1 transition-colors
|
text-sm p2 border-1 transition-colors
|
||||||
border-dark hover:text-primary
|
border-dark hover:text-primary
|
||||||
|
@ -187,7 +166,6 @@ onDeactivated(cancelEdit)
|
||||||
</CommonTooltip>
|
</CommonTooltip>
|
||||||
<CommonTooltip :content="$t('list.delete')" no-auto-focus>
|
<CommonTooltip :content="$t('list.delete')" no-auto-focus>
|
||||||
<button
|
<button
|
||||||
ref="delete"
|
|
||||||
type="button"
|
type="button"
|
||||||
text-sm p2 border-1 transition-colors
|
text-sm p2 border-1 transition-colors
|
||||||
border-dark hover:text-primary
|
border-dark hover:text-primary
|
||||||
|
@ -195,7 +173,7 @@ onDeactivated(cancelEdit)
|
||||||
:disabled="isEditing"
|
:disabled="isEditing"
|
||||||
@click.prevent="removeList"
|
@click.prevent="removeList"
|
||||||
>
|
>
|
||||||
<span v-if="deleteBusy" aria-hidden="true" block animate animate-spin preserve-3d class="rtl-flip">
|
<span v-if="deleting" aria-hidden="true" block animate animate-spin preserve-3d class="rtl-flip">
|
||||||
<span block i-ri:loader-2-fill aria-hidden="true" />
|
<span block i-ri:loader-2-fill aria-hidden="true" />
|
||||||
</span>
|
</span>
|
||||||
<span v-else block text-current i-ri:delete-bin-2-line class="rtl-flip" />
|
<span v-else block text-current i-ri:delete-bin-2-line class="rtl-flip" />
|
||||||
|
|
|
@ -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>
|
||||||
|
|
166
components/magickeys/MagickeysKeyboardShortcuts.vue
Normal file
166
components/magickeys/MagickeysKeyboardShortcuts.vue
Normal file
|
@ -0,0 +1,166 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
const emit = defineEmits(['close'])
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
/* TODOs:
|
||||||
|
* - I18n
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface ShortcutDef {
|
||||||
|
keys: string[]
|
||||||
|
isSequence: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShortcutItem {
|
||||||
|
description: string
|
||||||
|
shortcut: ShortcutDef
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ShortcutItemGroup {
|
||||||
|
name: string
|
||||||
|
items: ShortcutItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const isMac = useIsMac()
|
||||||
|
const modifierKeyName = computed(() => isMac.value ? '⌘' : 'Ctrl')
|
||||||
|
|
||||||
|
const shortcutItemGroups = computed<ShortcutItemGroup[]>(() => [
|
||||||
|
{
|
||||||
|
name: t('magic_keys.groups.navigation.title'),
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
description: t('magic_keys.groups.navigation.shortcut_help'),
|
||||||
|
shortcut: { keys: ['?'], isSequence: false },
|
||||||
|
},
|
||||||
|
// {
|
||||||
|
// description: t('magic_keys.groups.navigation.next_status'),
|
||||||
|
// shortcut: { keys: ['j'], isSequence: false },
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// description: t('magic_keys.groups.navigation.previous_status'),
|
||||||
|
// 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'),
|
||||||
|
shortcut: { keys: ['g', 'h'], isSequence: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: t('magic_keys.groups.navigation.go_to_notifications'),
|
||||||
|
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'),
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
description: t('magic_keys.groups.actions.search'),
|
||||||
|
shortcut: { keys: [modifierKeyName.value, 'k'], isSequence: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: t('magic_keys.groups.actions.command_mode'),
|
||||||
|
shortcut: { keys: [modifierKeyName.value, '/'], isSequence: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: t('magic_keys.groups.actions.compose'),
|
||||||
|
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'),
|
||||||
|
shortcut: { keys: ['f'], isSequence: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: t('magic_keys.groups.actions.boost'),
|
||||||
|
shortcut: { keys: ['b'], isSequence: false },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: t('magic_keys.groups.media.title'),
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div px-3 sm:px-5 py-2 sm:py-4 max-w-220 relative max-h-screen>
|
||||||
|
<button btn-action-icon absolute top-1 sm:top-2 right-1 sm:right-2 m1 :aria-label="$t('modals.aria_label_close')" @click="emit('close')">
|
||||||
|
<div i-ri:close-fill />
|
||||||
|
</button>
|
||||||
|
<h2 text-xl font-700 mb3>
|
||||||
|
{{ $t('magic_keys.dialog_header') }}
|
||||||
|
</h2>
|
||||||
|
<div mb2 grid grid-cols-1 md:grid-cols-3 gap-y- md:gap-x-6 lg:gap-x-8>
|
||||||
|
<div
|
||||||
|
v-for="group in shortcutItemGroups"
|
||||||
|
:key="group.name"
|
||||||
|
>
|
||||||
|
<h3 font-700 my-2 text-lg>
|
||||||
|
{{ group.name }}
|
||||||
|
</h3>
|
||||||
|
<div
|
||||||
|
v-for="item in group.items"
|
||||||
|
:key="item.description"
|
||||||
|
flex my-1 lg:my-2 justify-between place-items-center max-w-full text-base
|
||||||
|
>
|
||||||
|
<div mr-2 break-words overflow-hidden leading-4 h-full inline-block align-middle>
|
||||||
|
{{ item.description }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<template
|
||||||
|
v-for="(key, idx) in item.shortcut.keys"
|
||||||
|
:key="idx"
|
||||||
|
>
|
||||||
|
<span v-if="idx !== 0" mx1 text-sm op80>{{ item.shortcut.isSequence ? $t('magic_keys.sequence_then') : '+' }}</span>
|
||||||
|
<code class="px2 md:px1.5 lg:px2 lg:px2 py0 lg:py-0.5" rounded bg-code border="px $c-border-code" shadow-sm my1 font-mono font-600>{{ key }}</code>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
|
@ -8,19 +8,34 @@ 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)]"
|
||||||
border="b base" 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">
|
<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>
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
v-if="backOnSmallScreen || back" flex="~ gap1" items-center btn-text p-0 xl:hidden
|
v-if="backOnSmallScreen || back" flex="~ gap1" items-center btn-text p-0 xl:hidden
|
||||||
|
@ -29,23 +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 />
|
||||||
|
</slot>
|
||||||
</div>
|
</div>
|
||||||
<div :class="{ 'xl:block': $route.name !== 'tag' }" hidden h-6 />
|
<PwaInstallPrompt xl:hidden />
|
||||||
<PwaInstallPrompt lg: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>
|
||||||
|
|
|
@ -7,9 +7,11 @@ import {
|
||||||
isEditHistoryDialogOpen,
|
isEditHistoryDialogOpen,
|
||||||
isErrorDialogOpen,
|
isErrorDialogOpen,
|
||||||
isFavouritedBoostedByDialogOpen,
|
isFavouritedBoostedByDialogOpen,
|
||||||
|
isKeyboardShortcutsDialogOpen,
|
||||||
isMediaPreviewOpen,
|
isMediaPreviewOpen,
|
||||||
isPreviewHelpOpen,
|
isPreviewHelpOpen,
|
||||||
isPublishDialogOpen,
|
isPublishDialogOpen,
|
||||||
|
isReportDialogOpen,
|
||||||
isSigninDialogOpen,
|
isSigninDialogOpen,
|
||||||
} from '~/composables/dialog'
|
} from '~/composables/dialog'
|
||||||
|
|
||||||
|
@ -32,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>
|
||||||
|
@ -98,5 +100,11 @@ const handleFavouritedBoostedByClose = () => {
|
||||||
>
|
>
|
||||||
<StatusFavouritedBoostedBy />
|
<StatusFavouritedBoostedBy />
|
||||||
</ModalDialog>
|
</ModalDialog>
|
||||||
|
<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()" />
|
||||||
|
</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,11 +53,10 @@ 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>
|
</div>
|
||||||
|
@ -69,7 +68,6 @@ onUnmounted(() => locked.value = false)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div absolute top-0 w-full flex justify-end>
|
<div absolute top-0 w-full flex justify-end>
|
||||||
<button
|
<button
|
||||||
|
|
|
@ -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>
|
||||||
|
@ -13,13 +17,13 @@ const { notifications } = useNotifications()
|
||||||
>
|
>
|
||||||
<!-- These weird styles above are used for scroll locking, don't change it unless you know exactly what you're doing. -->
|
<!-- These weird styles above are used for scroll locking, don't change it unless you know exactly what you're doing. -->
|
||||||
<template v-if="currentUser">
|
<template v-if="currentUser">
|
||||||
<NuxtLink to="/home" :active-class="moreMenuVisible ? '' : 'text-primary'" flex flex-row items-center place-content-center h-full flex-1 @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'" :active-class="moreMenuVisible ? '' : 'text-primary'" flex flex-row items-center place-content-center h-full flex-1 @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" :active-class="moreMenuVisible ? '' : 'text-primary'" flex flex-row items-center place-content-center h-full flex-1 @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>
|
||||||
|
@ -27,30 +31,30 @@ const { notifications } = useNotifications()
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink to="/conversations" :active-class="moreMenuVisible ? '' : 'text-primary'" flex flex-row items-center place-content-center h-full flex-1 @click="$scrollToTop">
|
<NuxtLink to="/conversations" :aria-label="$t('nav.conversations')" :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:at-line />
|
<div i-ri:at-line />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<NuxtLink :to="`/${currentServer}/explore`" :active-class="moreMenuVisible ? '' : 'text-primary'" flex flex-row items-center place-content-center h-full flex-1 @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`" :active-class="moreMenuVisible ? '' : 'text-primary'" flex flex-row items-center place-content-center h-full flex-1 @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 />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<NuxtLink :to="`/${currentServer}/public`" :active-class="moreMenuVisible ? '' : 'text-primary'" flex flex-row items-center place-content-center h-full flex-1 @click="$scrollToTop">
|
<NuxtLink :to="`/${currentServer}/public`" :aria-label="$t('nav.federated')" :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:earth-line />
|
<div i-ri:earth-line />
|
||||||
</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>
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const buildInfo = useAppConfig().buildInfo
|
const buildInfo = useBuildInfo()
|
||||||
const timeAgoOptions = useTimeAgoOptions()
|
const timeAgoOptions = useTimeAgoOptions()
|
||||||
|
const config = useRuntimeConfig()
|
||||||
const userSettings = useUserSettings()
|
const userSettings = useUserSettings()
|
||||||
|
|
||||||
const buildTimeDate = new Date(buildInfo.time)
|
const buildTimeDate = new Date(buildInfo.time)
|
||||||
|
@ -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
|
||||||
|
@ -65,7 +68,7 @@ function toggleDark() {
|
||||||
target="_blank"
|
target="_blank"
|
||||||
font-mono
|
font-mono
|
||||||
>
|
>
|
||||||
{{ buildInfo.commit.slice(0, 7) }}
|
{{ buildInfo.shortCommit }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
@ -73,9 +76,9 @@ function toggleDark() {
|
||||||
<NuxtLink cursor-pointer hover:underline to="/settings/about">
|
<NuxtLink cursor-pointer hover:underline to="/settings/about">
|
||||||
{{ $t('settings.about.label') }}
|
{{ $t('settings.about.label') }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<template v-if="$config.public.privacyPolicyUrl">
|
<template v-if="config.public.privacyPolicyUrl">
|
||||||
·
|
·
|
||||||
<NuxtLink cursor-pointer hover:underline :to="$config.public.privacyPolicyUrl">
|
<NuxtLink cursor-pointer hover:underline :to="config.public.privacyPolicyUrl">
|
||||||
{{ $t('nav.privacy') }}
|
{{ $t('nav.privacy') }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -3,27 +3,44 @@
|
||||||
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 id="a" width="240" height="234" x="4" y="1" maskUnits="userSpaceOnUse" style="mask-type:alpha">
|
<mask
|
||||||
|
id="a"
|
||||||
|
width="240"
|
||||||
|
height="234"
|
||||||
|
x="4"
|
||||||
|
y="1"
|
||||||
|
maskUnits="userSpaceOnUse"
|
||||||
|
style="mask-type:alpha"
|
||||||
|
>
|
||||||
<path
|
<path
|
||||||
|
id="path19"
|
||||||
fill="#D9D9D9"
|
fill="#D9D9D9"
|
||||||
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"
|
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"
|
||||||
/>
|
/>
|
||||||
</mask>
|
</mask>
|
||||||
<g mask="url(#a)">
|
<g
|
||||||
|
id="g28"
|
||||||
|
mask="url(#a)"
|
||||||
|
transform="matrix(0.90923731,0,0,1.0049564,13.520015,-3.1040835)"
|
||||||
|
>
|
||||||
<path
|
<path
|
||||||
|
id="path22"
|
||||||
class="body"
|
class="body"
|
||||||
d="M116.94 88.1c-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.023l5.956 8.461 11.4.155v47.889l-13.91 21.966L-19.556 387h13.192l1.144-51.227c6.558-3.881 21.58-13.971 34.391-29.494 17.386-20.879 30.731-51.227 15.022-88.793l11.744-5.045c12.887 30.814 8.388 57.514-2.898 79.013 21.58-.698 40.11-2.095 55.819-4.734l-3.584-43.698 12.659-1.087L129.98 387h13.116l2.212-94.459c10.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.698l-18.683-5.976h57.42l5.567-12.807c-5.414.233-11.896-2.639-11.896-2.639l1.297-6.209H242l-65.199-34.384c-7.244 2.794-14.87 6.442-20.208 10.866-4.27-3.105-19.063-12.807-39.653-13.195Z"
|
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"
|
||||||
/>
|
/>
|
||||||
<path
|
<path
|
||||||
|
id="path24"
|
||||||
class="wood"
|
class="wood"
|
||||||
d="M6.217 24.493 18.494 21c5.948 21.577 13.345 33.375 22.648 39.352 8.388 5.099 19.75 5.239 31.799 4.579-3.508-1.164-6.787-2.794-9.837-5.045-6.787-5.045-12.582-13.428-16.929-28.64l12.201-3.649c3.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-.381-.776-.762-1.553-1.296-2.406-3.66-5.822-10.828-11.953-24.097-16.92l4.27-12.109c21.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-21.962-8.304-43.466-2.638-62.53-.853-14.336 1.32-27.452.698-38.814-6.598-11.21-7.14-21.047-20.8-28.215-46.802Z"
|
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"
|
||||||
/>
|
/>
|
||||||
<path
|
<path
|
||||||
|
id="path26"
|
||||||
class="wood"
|
class="wood"
|
||||||
d="M90.098 45.294c-2.516-5.744-4.041-12.807-3.355-21.5l12.659.932c-.763 10.555 2.897 17.696 7.015 22.353-5.338-.931-10.447-1.04-16.319-1.785ZM170.167 43.974l8.312-9.702c21.58 19.094 8.159 46.415 8.159 46.415l-11.819-1.32c-.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.93Z"
|
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>
|
</g>
|
||||||
</svg>
|
</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"
|
||||||
|
:class="isSmallScreen
|
||||||
|
? `
|
||||||
|
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
|
transition-100
|
||||||
group-hover="bg-active" group-focus-visible:ring="2 current"
|
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' }"
|
|
||||||
>
|
>
|
||||||
|
<CommonTooltip :content="$t('nav.back')">
|
||||||
<NuxtLink
|
<NuxtLink
|
||||||
:aria-label="$t('nav.back')"
|
:aria-label="$t('nav.back')"
|
||||||
|
:class="{ 'pointer-events-none op0': !back || back === '/', 'xl:flex': $route.name !== 'tag' }"
|
||||||
@click="$router.go(-1)"
|
@click="$router.go(-1)"
|
||||||
>
|
>
|
||||||
<div i-ri:arrow-left-line class="rtl-flip" btn-text />
|
<div text-xl i-ri:arrow-left-line class="rtl-flip" btn-text />
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
|
</CommonTooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -6,7 +6,6 @@ const { busy, oauth, singleInstanceServer } = useSignIn()
|
||||||
<VDropdown v-if="isHydrated && currentUser" sm:hidden>
|
<VDropdown v-if="isHydrated && currentUser" sm:hidden>
|
||||||
<div style="-webkit-touch-callout: none;">
|
<div style="-webkit-touch-callout: none;">
|
||||||
<AccountAvatar
|
<AccountAvatar
|
||||||
ref="avatar"
|
|
||||||
:account="currentUser.account"
|
:account="currentUser.account"
|
||||||
h-8
|
h-8
|
||||||
w-8
|
w-8
|
||||||
|
@ -16,7 +15,7 @@ const { busy, oauth, singleInstanceServer } = useSignIn()
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template #popper="{ hide }">
|
<template #popper="{ hide }">
|
||||||
<UserSwitcher ref="switcher" @click="hide()" />
|
<UserSwitcher @click="hide()" />
|
||||||
</template>
|
</template>
|
||||||
</VDropdown>
|
</VDropdown>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
@ -35,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,60 +22,65 @@ 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>
|
||||||
|
<div i-ri:user-add-line text-xl me-2 color-purple />
|
||||||
<AccountDisplayName
|
<AccountDisplayName
|
||||||
:account="notification.account"
|
:account="notification.account"
|
||||||
text-purple me-1 font-bold line-clamp-1 ws-pre-wrap break-all
|
text-purple me-1 font-bold line-clamp-1 ws-pre-wrap break-all
|
||||||
/>
|
/>
|
||||||
<span>{{ $t("notification.signed_up") }}</span>
|
<span>{{ $t("notification.signed_up") }}</span>
|
||||||
</div>
|
</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" />
|
||||||
</template>
|
</AccountCard>
|
||||||
<template v-else-if="notification.type === 'favourite'">
|
|
||||||
<StatusCard :status="notification.status!" :faded="true">
|
|
||||||
<template #meta>
|
|
||||||
<div flex="~" gap-1 items-center mt1>
|
|
||||||
<div i-ri:heart-fill text-xl me-1 color-red />
|
|
||||||
<AccountInlineInfo text-primary font-bold :account="notification.account" me1 />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</StatusCard>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="notification.type === 'reblog'">
|
|
||||||
<StatusCard :status="notification.status!" :faded="true">
|
|
||||||
<template #meta>
|
|
||||||
<div flex="~" gap-1 items-center mt1>
|
|
||||||
<div i-ri:repeat-fill text-xl me-1 color-green />
|
|
||||||
<AccountInlineInfo text-primary font-bold :account="notification.account" me1 />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</StatusCard>
|
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="notification.type === 'update'">
|
<template v-else-if="notification.type === 'update'">
|
||||||
<StatusCard :status="notification.status!" :faded="true">
|
<StatusCard :status="notification.status!" :in-notification="true" :actions="false">
|
||||||
<template #meta>
|
<template #meta>
|
||||||
<div flex="~" gap-1 items-center mt1>
|
<div flex="~" gap-1 items-center mt1>
|
||||||
<div i-ri:edit-2-fill text-xl me-1 text-secondary />
|
<div i-ri:edit-2-fill text-xl me-1 text-secondary />
|
||||||
|
@ -83,7 +95,9 @@ 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 -->
|
||||||
<div text-red font-bold>
|
<div text-red font-bold>
|
||||||
[DEV] {{ $t('notification.missing_type') }} '{{ notification.type }}'
|
[DEV] {{ $t('notification.missing_type') }} '{{ notification.type }}'
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -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,21 +4,59 @@ 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 likes = computed(() => group.likes.filter(i => i.favourite && !i.reblog))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<article flex flex-col relative>
|
<article flex flex-col relative>
|
||||||
<StatusCard :status="group.status!" :faded="true">
|
<StatusLink :status="group.status!" pb4 pt5>
|
||||||
<template #meta>
|
<div flex flex-col gap-3>
|
||||||
<div flex flex-col gap-1 mt-1>
|
<div v-if="reblogs.length" flex="~ gap-1">
|
||||||
<div v-for="like of group.likes" :key="like.account.id" flex>
|
<div i-ri:repeat-fill text-xl me-2 color-green />
|
||||||
<div v-if="like.reblog" i-ri:repeat-fill text-xl me-2 color-green />
|
<template v-for="i, idx of reblogs" :key="idx">
|
||||||
<div v-if="like.favourite && !like.reblog" i-ri:heart-fill text-xl me-2 color-red />
|
<AccountHoverWrapper :account="i.account">
|
||||||
<AccountInlineInfo text-primary font-bold :account="like.account" me2 />
|
<NuxtLink :to="getAccountRoute(i.account)">
|
||||||
<div v-if="like.favourite && like.reblog" i-ri:heart-fill text-xl me-2 color-red />
|
<AccountAvatar text-primary font-bold :account="i.account" class="h-1.5em w-1.5em" />
|
||||||
</div>
|
</NuxtLink>
|
||||||
</div>
|
</AccountHoverWrapper>
|
||||||
</template>
|
</template>
|
||||||
</StatusCard>
|
<div ml1>
|
||||||
|
{{ $t('notification.reblogged_post') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="likes.length" flex="~ gap-1">
|
||||||
|
<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">
|
||||||
|
<AccountHoverWrapper :account="i.account">
|
||||||
|
<NuxtLink :to="getAccountRoute(i.account)">
|
||||||
|
<AccountAvatar text-primary font-bold :account="i.account" class="h-1.5em w-1.5em" />
|
||||||
|
</NuxtLink>
|
||||||
|
</AccountHoverWrapper>
|
||||||
|
</template>
|
||||||
|
<div ms1>
|
||||||
|
{{ $t('notification.favourited_post') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div ps9 mt-1>
|
||||||
|
<StatusBody :status="group.status!" text-secondary />
|
||||||
|
<!-- When no text content is presented, we show media instead -->
|
||||||
|
<template v-if="!group.status!.content">
|
||||||
|
<StatusMedia
|
||||||
|
v-if="group.status!.mediaAttachments?.length"
|
||||||
|
:status="group.status!"
|
||||||
|
:is-preview="false"
|
||||||
|
pointer-events-none
|
||||||
|
/>
|
||||||
|
<StatusPoll
|
||||||
|
v-else-if="group.status!.poll"
|
||||||
|
:status="group.status!"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</StatusLink>
|
||||||
</article>
|
</article>
|
||||||
</template>
|
</template>
|
||||||
|
|
|
@ -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,9 +85,12 @@ function groupItems(items: mastodon.v1.Notification[]): NotificationSlot[] {
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
else if (group.length && (group[0].type === 'reblog' || group[0].type === 'favourite')) {
|
||||||
const { status } = group[0]
|
if (!group[0].status) {
|
||||||
if (status && group.length > 1 && (group[0].type === 'reblog' || group[0].type === 'favourite')) {
|
// 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) {
|
||||||
|
@ -88,11 +101,15 @@ function groupItems(items: mastodon.v1.Notification[]): NotificationSlot[] {
|
||||||
}
|
}
|
||||||
like[notification.type === 'reblog' ? 'reblog' : 'favourite'] = notification
|
like[notification.type === 'reblog' ? 'reblog' : 'favourite'] = notification
|
||||||
}
|
}
|
||||||
likes.sort((a, b) => a.reblog ? !b.reblog || (a.favourite && !b.favourite) ? -1 : 0 : 0)
|
likes.sort((a, b) => a.reblog
|
||||||
|
? (!b.reblog || (a.favourite && !b.favourite))
|
||||||
|
? -1
|
||||||
|
: 0
|
||||||
|
: 0)
|
||||||
results.push({
|
results.push({
|
||||||
id: `grouped-${id++}`,
|
id: `grouped-${id++}`,
|
||||||
type: 'grouped-reblogs-and-favourites',
|
type: 'grouped-reblogs-and-favourites',
|
||||||
status,
|
status: group[0].status,
|
||||||
likes,
|
likes,
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
|
@ -101,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()
|
||||||
|
|
||||||
|
@ -148,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,86 +17,86 @@ 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 {
|
||||||
const subscription = await updateSubscription()
|
await updateSubscription()
|
||||||
}
|
}
|
||||||
catch (err) {
|
catch (err) {
|
||||||
// todo: handle error
|
// todo: handle error
|
||||||
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>
|
||||||
|
|
|
@ -15,11 +15,15 @@ const emit = defineEmits<{
|
||||||
(evt: 'setDescription', description: string): void
|
(evt: 'setDescription', description: string): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
// from https://github.com/mastodon/mastodon/blob/dfa984/app/models/media_attachment.rb#L40
|
||||||
|
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>
|
||||||
|
|
||||||
|
@ -55,7 +59,10 @@ const toggleApply = () => {
|
||||||
</h1>
|
</h1>
|
||||||
<div flex flex-col gap-2>
|
<div flex flex-col gap-2>
|
||||||
<textarea v-model="description" p-3 h-50 bg-base rounded-2 border-strong border-1 md:w-100 />
|
<textarea v-model="description" p-3 h-50 bg-base rounded-2 border-strong border-1 md:w-100 />
|
||||||
<button btn-outline @click="toggleApply">
|
<div flex flex-row-reverse>
|
||||||
|
<PublishCharacterCounter :length="description.length" :max="maxDescriptionLength" />
|
||||||
|
</div>
|
||||||
|
<button btn-outline :disabled="description.length > maxDescriptionLength" @click="toggleApply">
|
||||||
{{ $t('action.apply') }}
|
{{ $t('action.apply') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
12
components/publish/PublishCharacterCounter.vue
Normal file
12
components/publish/PublishCharacterCounter.vue
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
max: number
|
||||||
|
length: number
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div dir="ltr" pointer-events-none pe-1 pt-2 text-sm tabular-nums text-secondary flex gap="0.5" :class="{ 'text-rose-500': length > max }">
|
||||||
|
{{ length ?? 0 }}<span text-secondary-light>/</span><span text-secondary-light>{{ max }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
|
@ -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)
|
||||||
|
@ -71,6 +126,9 @@ const characterCount = $computed(() => {
|
||||||
// taken from https://github.com/mastodon/mastodon/blob/07f8b4d1b19f734d04e69daeb4c3421ef9767aac/app/lib/text_formatter.rb
|
// taken from https://github.com/mastodon/mastodon/blob/07f8b4d1b19f734d04e69daeb4c3421ef9767aac/app/lib/text_formatter.rb
|
||||||
const linkRegex = /(https?:\/\/(www\.)?|xmpp:)\S+/g
|
const linkRegex = /(https?:\/\/(www\.)?|xmpp:)\S+/g
|
||||||
|
|
||||||
|
// taken from https://github.com/mastodon/mastodon/blob/af578e/app/javascript/mastodon/features/compose/util/counter.js
|
||||||
|
const countableMentionRegex = /(^|[^/\w])@(([a-z0-9_]+)@[a-z0-9.-]+[a-z0-9]+)/ig
|
||||||
|
|
||||||
// maximum of 23 chars per link
|
// maximum of 23 chars per link
|
||||||
// https://github.com/elk-zone/elk/issues/1651
|
// https://github.com/elk-zone/elk/issues/1651
|
||||||
const maxLength = 23
|
const maxLength = 23
|
||||||
|
@ -78,20 +136,29 @@ const characterCount = $computed(() => {
|
||||||
for (const [fullMatch] of text.matchAll(linkRegex))
|
for (const [fullMatch] of text.matchAll(linkRegex))
|
||||||
length -= fullMatch.length - Math.min(maxLength, fullMatch.length)
|
length -= fullMatch.length - Math.min(maxLength, fullMatch.length)
|
||||||
|
|
||||||
if (draft.mentions) {
|
for (const [fullMatch, before, _handle, username] of text.matchAll(countableMentionRegex))
|
||||||
// + 1 is needed as mentions always need a space seperator at the end
|
length -= fullMatch.length - (before + username).length - 1 // - 1 for the @
|
||||||
length += draft.mentions.map((mention) => {
|
|
||||||
|
if (draft.value.mentions) {
|
||||||
|
// + 1 is needed as mentions always need a space separator at the end
|
||||||
|
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 postLanguageDisplay = $computed(() => languagesNameList.find(i => i.code === (draft.params.language || preferredLanguage))?.nativeName)
|
const isExceedingCharacterLimit = computed(() => {
|
||||||
|
return characterCount.value > characterLimit.value
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
||||||
|
@ -110,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() {
|
||||||
|
@ -143,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>
|
<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>
|
</div>
|
||||||
<StatusCard :status="draft.editingStatus" :actions="false" :hover="false" is-preview px-0 />
|
|
||||||
</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 -->
|
||||||
|
@ -207,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>
|
||||||
|
|
||||||
|
@ -263,6 +340,42 @@ 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 flex="~ col 1" max-w-full>
|
||||||
|
<form v-if="isExpanded && draft.params.poll" my-4 flex="~ 1 col" gap-3 m="s--1">
|
||||||
|
<div
|
||||||
|
v-for="(option, index) in draft.params.poll.options"
|
||||||
|
:key="index"
|
||||||
|
flex="~ row"
|
||||||
|
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
|
<div
|
||||||
v-if="shouldExpanded" flex="~ gap-1 1 wrap" m="s--1" pt-2 justify="end" max-w-full
|
v-if="shouldExpanded" flex="~ gap-1 1 wrap" m="s--1" pt-2 justify="end" max-w-full
|
||||||
border="t base"
|
border="t base"
|
||||||
|
@ -271,26 +384,70 @@ defineExpose({
|
||||||
@select="insertEmoji"
|
@select="insertEmoji"
|
||||||
@select-custom="insertCustomEmoji"
|
@select-custom="insertCustomEmoji"
|
||||||
>
|
>
|
||||||
<button btn-action-icon :title="$t('tooltip.emoji')">
|
<button btn-action-icon :title="$t('tooltip.emojis')" :aria-label="$t('tooltip.add_emojis')">
|
||||||
<div i-ri:emotion-line />
|
<div i-ri:emotion-line />
|
||||||
</button>
|
</button>
|
||||||
</PublishEmojiPicker>
|
</PublishEmojiPicker>
|
||||||
|
|
||||||
<CommonTooltip placement="top" :content="$t('tooltip.add_media')">
|
<CommonTooltip v-if="draft.params.poll === undefined" placement="top" :content="$t('tooltip.add_media')" no-auto-focus>
|
||||||
<button btn-action-icon :aria-label="$t('tooltip.add_media')" @click="pickAttachments">
|
<button btn-action-icon :aria-label="$t('tooltip.add_media')" @click="pickAttachments">
|
||||||
<div i-ri:image-add-line />
|
<div i-ri:image-add-line />
|
||||||
</button>
|
</button>
|
||||||
</CommonTooltip>
|
</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>
|
||||||
|
|
||||||
<PublishEditorTools v-if="editor" :editor="editor" />
|
<PublishEditorTools v-if="editor" :editor="editor" />
|
||||||
|
|
||||||
<div flex-auto />
|
<div flex-auto />
|
||||||
|
|
||||||
<div dir="ltr" pointer-events-none pe-1 pt-2 text-sm tabular-nums text-secondary flex gap="0.5" :class="{ 'text-rose-500': characterCount > characterLimit }">
|
<PublishCharacterCounter :max="characterLimit" :length="characterCount" />
|
||||||
{{ characterCount ?? 0 }}<span text-secondary-light>/</span><span text-secondary-light>{{ characterLimit }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<CommonTooltip placement="top" :content="$t('tooltip.change_language')">
|
<CommonTooltip placement="top" :content="$t('tooltip.change_language')" no-auto-focus>
|
||||||
<CommonDropdown placement="bottom" auto-boundary-max-size>
|
<CommonDropdown placement="bottom" auto-boundary-max-size>
|
||||||
<button btn-action-icon :aria-label="$t('tooltip.change_language')" w-max mr1>
|
<button btn-action-icon :aria-label="$t('tooltip.change_language')" w-max mr1>
|
||||||
<span v-if="postLanguageDisplay" text-secondary text-sm ml1>{{ postLanguageDisplay }}</span>
|
<span v-if="postLanguageDisplay" text-secondary text-sm ml1>{{ postLanguageDisplay }}</span>
|
||||||
|
@ -304,7 +461,7 @@ defineExpose({
|
||||||
</CommonDropdown>
|
</CommonDropdown>
|
||||||
</CommonTooltip>
|
</CommonTooltip>
|
||||||
|
|
||||||
<CommonTooltip placement="top" :content="$t('tooltip.add_content_warning')">
|
<CommonTooltip placement="top" :content="$t('tooltip.add_content_warning')" no-auto-focus>
|
||||||
<button btn-action-icon :aria-label="$t('tooltip.add_content_warning')" @click="toggleSensitive">
|
<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-if="draft.params.sensitive" i-ri:alarm-warning-fill text-orange />
|
||||||
<div v-else i-ri:alarm-warning-line />
|
<div v-else i-ri:alarm-warning-line />
|
||||||
|
@ -320,7 +477,7 @@ defineExpose({
|
||||||
</template>
|
</template>
|
||||||
</PublishVisibilityPicker>
|
</PublishVisibilityPicker>
|
||||||
|
|
||||||
<CommonTooltip v-if="failedMessages.length > 0" id="publish-failed-tooltip" placement="top" :content="$t('tooltip.publish_failed')">
|
<CommonTooltip v-if="failedMessages.length > 0" id="publish-failed-tooltip" placement="top" :content="$t('tooltip.publish_failed')" no-auto-focus>
|
||||||
<button
|
<button
|
||||||
btn-danger rounded-3 text-sm w-full flex="~ gap1" items-center md:w-fit aria-describedby="publish-failed-tooltip"
|
btn-danger rounded-3 text-sm w-full flex="~ gap1" items-center md:w-fit aria-describedby="publish-failed-tooltip"
|
||||||
>
|
>
|
||||||
|
@ -331,12 +488,12 @@ defineExpose({
|
||||||
</button>
|
</button>
|
||||||
</CommonTooltip>
|
</CommonTooltip>
|
||||||
|
|
||||||
<CommonTooltip v-else id="publish-tooltip" placement="top" :content="$t('tooltip.add_publishable_content')" :disabled="!isPublishDisabled">
|
<CommonTooltip v-else id="publish-tooltip" placement="top" :content="$t('tooltip.add_publishable_content')" :disabled="!(isPublishDisabled || isExceedingCharacterLimit)" no-auto-focus>
|
||||||
<button
|
<button
|
||||||
btn-solid rounded-3 text-sm w-full flex="~ gap1" items-center
|
btn-solid rounded-3 text-sm w-full flex="~ gap1" items-center
|
||||||
md:w-fit
|
md:w-fit
|
||||||
class="publish-button"
|
class="publish-button"
|
||||||
:aria-disabled="isPublishDisabled"
|
:aria-disabled="isPublishDisabled || isExceedingCharacterLimit"
|
||||||
aria-describedby="publish-tooltip"
|
aria-describedby="publish-tooltip"
|
||||||
@click="publish"
|
@click="publish"
|
||||||
>
|
>
|
||||||
|
@ -354,6 +511,7 @@ defineExpose({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
@ -366,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>
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue