mirror of
https://github.com/wekan/wekan.git
synced 2026-11-05 15:45:01 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9c5888485 | ||
|
|
09c9d72398 | ||
|
|
4f3d45f3de | ||
|
|
3aaa46fdae | ||
|
|
59f1b705e0 | ||
|
|
22e5df55d7 | ||
|
|
016b4bf3bd | ||
|
|
4d44349ab6 | ||
|
|
d3edc56170 | ||
|
|
5134e3d2a8 | ||
|
|
f686b76023 | ||
|
|
cd2adcad02 | ||
|
|
0766a65fef | ||
|
|
58ad81e301 | ||
|
|
2912f4b080 | ||
|
|
97d1eca09d | ||
|
|
95b75177b8 | ||
|
|
880ec2d707 | ||
|
|
dbc7cccea3 | ||
|
|
d8b3579117 | ||
|
|
c7e53e9f38 | ||
|
|
6012521ccc | ||
|
|
0195a5b3d4 | ||
|
|
b12e761afb | ||
|
|
0c0d666ffa | ||
|
|
fc2ad0a48b | ||
|
|
dad03959d9 | ||
|
|
ac960e7d81 | ||
|
|
3213d7fa1d | ||
|
|
4757676abf | ||
|
|
efaf94242e | ||
|
|
d87baa0244 | ||
|
|
006d24502c | ||
|
|
8f4efbca57 | ||
|
|
dde09dead5 | ||
|
|
3621413d6f | ||
|
|
3c3fc07b3c | ||
|
|
c1e8976b28 | ||
|
|
dcce935310 | ||
|
|
1b05673200 | ||
|
|
dafb63299f | ||
|
|
84b0480aa8 | ||
|
|
f6fc97e77a | ||
|
|
d7105e531d | ||
|
|
f25957dcf3 | ||
|
|
984ea93aa8 | ||
|
|
2441563008 | ||
|
|
f3950b9adc | ||
|
|
5e07b8db6c | ||
|
|
eeeb0dbec9 | ||
|
|
96d56ecc6c | ||
|
|
aadfd9b6b3 | ||
|
|
1c44876beb | ||
|
|
bd86b518ea | ||
|
|
f576a0cca9 | ||
|
|
46c0ad43e8 | ||
|
|
499772e4c2 | ||
|
|
90fe133363 | ||
|
|
b812d36871 | ||
|
|
d05a42a5af | ||
|
|
42507de1d0 | ||
|
|
131b0cc509 | ||
|
|
93ae5ac6ee | ||
|
|
34bb91d1f5 | ||
|
|
d231df56fc | ||
|
|
e422ab691e | ||
|
|
3f712a0445 | ||
|
|
d99dadadee | ||
|
|
a58b7bee2c | ||
|
|
62da564a1e | ||
|
|
2ada2e2d61 | ||
|
|
5de1acf496 | ||
|
|
3008bb5606 | ||
|
|
4f7521ae3c | ||
|
|
1c63049913 | ||
|
|
6b27764452 | ||
|
|
ae49937b44 | ||
|
|
7f27ea1f26 | ||
|
|
144777ae6b | ||
|
|
f4b5025a7b | ||
|
|
cdcf8ec39d | ||
|
|
739f9ed7a0 | ||
|
|
f06d082e34 | ||
|
|
4036b16774 | ||
|
|
0110e84ce5 | ||
|
|
00a50ae139 | ||
|
|
c67a67bc9a | ||
|
|
0b8a7face3 | ||
|
|
8b2114cd0f | ||
|
|
0426c7741a | ||
|
|
c2a28a9233 | ||
|
|
a6b10e3592 | ||
|
|
1ac4d1b1f9 | ||
|
|
222492f313 | ||
|
|
4f95b10f0d | ||
|
|
86a18665f0 | ||
|
|
1ded34dfdd | ||
|
|
db791e1d40 | ||
|
|
e8e22dc25e | ||
|
|
08d20930de | ||
|
|
10f244de77 | ||
|
|
4e28465964 | ||
|
|
2936a682ce | ||
|
|
ab80aaf9a3 | ||
|
|
0fb943b6fb | ||
|
|
6d8b0c15ec | ||
|
|
abc23a7eda | ||
|
|
d9aa210012 | ||
|
|
2e8e1e2ce2 | ||
|
|
c8bcc4817d | ||
|
|
2dd25c733e | ||
|
|
cb6c6533c5 | ||
|
|
292b804474 | ||
|
|
623fccf06e | ||
|
|
cfd7ad2e5f | ||
|
|
762e5a9432 | ||
|
|
f1d2a497bc | ||
|
|
2f9c972d46 | ||
|
|
582a22c291 | ||
|
|
d200234535 | ||
|
|
8548bae527 | ||
|
|
d6b2d55c36 | ||
|
|
f7e3533b13 | ||
|
|
e91c14cd9a | ||
|
|
e7439e1d45 | ||
|
|
4a34b91ecf | ||
|
|
d9984d2069 | ||
|
|
e982282954 | ||
|
|
e68938698c | ||
|
|
55299a5172 | ||
|
|
8bbc6fcda1 | ||
|
|
71463fb445 | ||
|
|
b60a1e5a85 | ||
|
|
114e9ecd5a | ||
|
|
c6739fe452 | ||
|
|
efd45c651e | ||
|
|
fdc2c57b8f | ||
|
|
2c75b95908 | ||
|
|
a3630d96ce | ||
|
|
db2aafe16c | ||
|
|
c4b90d08a0 | ||
|
|
cb5278ddb3 | ||
|
|
bc79de8c57 | ||
|
|
81714cd695 | ||
|
|
d42369e96a | ||
|
|
efff590f74 | ||
|
|
64b5762020 | ||
|
|
c29a59eb95 | ||
|
|
dcaa316a01 | ||
|
|
7721c5f655 | ||
|
|
492895200b | ||
|
|
1b346dc465 | ||
|
|
45c291b406 | ||
|
|
c3af2a08a9 | ||
|
|
21c94ce098 | ||
|
|
2d8a28c2f6 | ||
|
|
c58b2896d1 | ||
|
|
6b98272272 | ||
|
|
ee22fb1a3b | ||
|
|
154c9a6e52 | ||
|
|
121f37be91 | ||
|
|
79c40407d1 | ||
|
|
148d18d456 | ||
|
|
15877dd1bc | ||
|
|
3ccb465c3f | ||
|
|
71d9776b61 | ||
|
|
34d9817110 | ||
|
|
aa939d7bec | ||
|
|
194e5b0a51 | ||
|
|
532a96da11 | ||
|
|
ba0de93f79 | ||
|
|
9a15491985 | ||
|
|
c8ed3e0fd0 | ||
|
|
4a9c3f21d2 | ||
|
|
3a2fef4dc7 | ||
|
|
f241681e80 | ||
|
|
c71c875649 | ||
|
|
0da86171e9 | ||
|
|
b80e21fd48 | ||
|
|
24a7270b7f | ||
|
|
73ea323da1 | ||
|
|
be6727607c | ||
|
|
9c5fa760c3 | ||
|
|
27040c6f2f | ||
|
|
c15e1ab4b2 | ||
|
|
b676cdc169 | ||
|
|
4ad13db5ef | ||
|
|
efe0e9287c | ||
|
|
cfcead6a1a | ||
|
|
40f60582b7 | ||
|
|
96845f6f4b | ||
|
|
c5f5ad2338 | ||
|
|
0c8c254dc5 | ||
|
|
0a1f8584ee | ||
|
|
2042fd5102 | ||
|
|
0403e27c62 | ||
|
|
d9e48a909d | ||
|
|
0cea141702 | ||
|
|
9188843369 | ||
|
|
dbf07aadf9 | ||
|
|
e001c4f9c2 | ||
|
|
ffe207e191 | ||
|
|
b2198cb61b | ||
|
|
a6bec88266 | ||
|
|
8a1f7a14a9 | ||
|
|
7f948bf438 | ||
|
|
6d0ab6cf11 | ||
|
|
89b64966c6 | ||
|
|
d5e3325e87 | ||
|
|
897376ba54 | ||
|
|
16103f9ad2 | ||
|
|
8cbe2605fe | ||
|
|
f5eb4ecf61 | ||
|
|
8d4f4caa2f | ||
|
|
9a74138a84 | ||
|
|
fa7e447536 | ||
|
|
05cc2460ea | ||
|
|
5c50f38e25 | ||
|
|
338afc15f8 | ||
|
|
dd2082b0ea | ||
|
|
4a86e9c19e | ||
|
|
568cd9b6ec | ||
|
|
fea468471d | ||
|
|
83199b9af7 | ||
|
|
3839ee698d | ||
|
|
a60b996c92 | ||
|
|
1230164c16 | ||
|
|
8008f410fe | ||
|
|
bd90782da2 | ||
|
|
5eadc45563 | ||
|
|
3785a13f6a | ||
|
|
97cabdeca4 | ||
|
|
2802326888 | ||
|
|
1f46af0639 | ||
|
|
1bcb1da92f | ||
|
|
067efac7f8 | ||
|
|
2bdd16060e | ||
|
|
433bc62211 | ||
|
|
29f9c62a64 | ||
|
|
21766cf38d | ||
|
|
101cdb7579 | ||
|
|
54cf951d9c | ||
|
|
57a43ee8e1 | ||
|
|
ff2c0ed7f6 | ||
|
|
63d022fe5f | ||
|
|
e9cf825b22 | ||
|
|
4ce8514e36 | ||
|
|
429fdc5319 | ||
|
|
ffd5cbd2ad | ||
|
|
cf4b48df09 | ||
|
|
73c21dfe3e | ||
|
|
22a12dc7f8 | ||
|
|
b1403ca7af | ||
|
|
bc21c901f4 | ||
|
|
88fef5dc08 | ||
|
|
3f9379ac86 | ||
|
|
85af731c1b | ||
|
|
bb32a50550 | ||
|
|
c99aa7f845 | ||
|
|
508484ef1e | ||
|
|
548196c9a9 | ||
|
|
9a118c83b2 | ||
|
|
d442bd8a7e | ||
|
|
5a13e927bc | ||
|
|
c78af24a36 | ||
|
|
6551062492 | ||
|
|
a0ed5de6f1 | ||
|
|
f406d46fe7 | ||
|
|
4b0aa715eb | ||
|
|
f5af33ac38 | ||
|
|
f165a24192 | ||
|
|
e6ea524541 | ||
|
|
de23dece66 | ||
|
|
2017f2871f | ||
|
|
302b47db6f | ||
|
|
482c2c29f4 | ||
|
|
4d5b832499 | ||
|
|
41173f0f6b | ||
|
|
d7abe9910a | ||
|
|
48a2e97ea3 | ||
|
|
4993242c5f | ||
|
|
12eef31162 | ||
|
|
0b5c855820 | ||
|
|
0c53ef6960 | ||
|
|
2ab0afdc93 | ||
|
|
f2a1d7b9ba | ||
|
|
cd1f1b8f05 | ||
|
|
b941f36e4c | ||
|
|
487a528806 | ||
|
|
dc9a7bb1ac |
No files matched your search
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"root": true,
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": "latest",
|
||||
"sourceType": "module",
|
||||
"project": [
|
||||
"./tsconfig.lint.json"
|
||||
]
|
||||
},
|
||||
"plugins": [
|
||||
"@typescript-eslint"
|
||||
],
|
||||
"rules": {
|
||||
"@typescript-eslint/no-floating-promises": "error",
|
||||
"@typescript-eslint/no-misused-promises": "error"
|
||||
}
|
||||
}
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
# https://github.com/docker/login-action
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f
|
||||
uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
|
||||
@@ -32,6 +32,6 @@ jobs:
|
||||
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
|
||||
|
||||
- name: Run chart-releaser
|
||||
uses: helm/chart-releaser-action@cae68fefc6b5f367a0275617c9f83181ba54714f # v1.7.0
|
||||
uses: helm/chart-releaser-action@df287104677278f869ca5480bd260358397cc3f9 # v1.8.0
|
||||
env:
|
||||
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||
@@ -26,7 +26,7 @@ Work out which you are from the git identity (the next section says how), then:
|
||||
| Who | Where the work goes |
|
||||
| --- | --- |
|
||||
| **The maintainer** — `Lauri Ojansivu <x@xet7.org>` | **Directly on `main`.** Never a feature branch, never a topic branch, never a pull request. |
|
||||
| **A contributor** — anybody else | **A branch in your own fork, then a pull request** for the maintainer to review. Never a commit on `main`. |
|
||||
| **A contributor** — anybody else | **A branch in your own fork, then one pull request per feature or fix** for the maintainer to review. Never a commit on `main`, and no changelog changes. |
|
||||
|
||||
Nothing else is a third option. If a tool, a task runner or an agent harness offers
|
||||
some other arrangement — a topic branch for the maintainer, a direct push for a
|
||||
@@ -41,7 +41,9 @@ released section links a commit that release does not contain.
|
||||
|
||||
The contributor's half is review. Nobody but the maintainer commits to `main` in
|
||||
wekan/wekan, so a change from anyone else arrives as a pull request — which is also
|
||||
the only place it can be discussed before it lands.
|
||||
the only place it can be discussed before it lands. That is why each pull request
|
||||
holds one feature or one fix, and leaves the changelogs to the maintainer: see
|
||||
"Contributor mode" below.
|
||||
|
||||
Check which branch you are on before committing:
|
||||
|
||||
@@ -143,12 +145,29 @@ git config user.name && git config user.email
|
||||
already exists, on <https://wekan.fi/sponsors>, and belongs nowhere else. The **publishing / release
|
||||
steps** below are available to a human maintainer only: an AI stops after
|
||||
committing locally and never runs a step that pushes or publishes.
|
||||
|
||||
**Maintainer mode is the only mode that commits directly and the only mode that
|
||||
updates changelogs**, in WeKan and in the maintainer's repositories under
|
||||
`.tools/` alike. It applies to the changes the WeKan maintainer makes, and to the
|
||||
changes of an agent working as the maintainer according to this file.
|
||||
- **Contributor mode** — the identity is somebody ELSE, in a fork or a clone of your
|
||||
own — a person, or an AI that files pull requests on its own account. Then: do
|
||||
**not** commit to `main` and do **not** run any release/publishing step. Make the
|
||||
changes on a branch and open a **pull request** for the maintainer to review. The
|
||||
"commit as Lauri Ojansivu", "commit directly to `main`", and all release
|
||||
instructions below are **maintainer-only and do not apply to you**.
|
||||
**not** commit directly to the repository or to `main`, and do **not** run any
|
||||
release/publishing step. Make the changes on a branch and open a **pull request**
|
||||
for the maintainer to review. The "commit as Lauri Ojansivu", "commit directly to
|
||||
`main`", changelog and release instructions below are **maintainer-only and do
|
||||
not apply to you**.
|
||||
|
||||
Every pull request, to wekan/wekan or to a maintainer's repository under `.tools/`,
|
||||
follows two more rules:
|
||||
- **One feature or one fix per pull request.** Changes share a pull request only
|
||||
when they are related to that one feature or fix. Anything unrelated, however
|
||||
small, goes in a pull request of its own, so each can be reviewed, merged or
|
||||
declined without holding up the others.
|
||||
- **No changelog changes.** Do not edit `CHANGELOG-big.md`, `CHANGELOG.md`, or the
|
||||
`CHANGELOG.md` or `CHANGELOG` of a repository under `.tools/` — not an Upcoming
|
||||
entry, not TODO Later. Describe the change in the pull request instead. The
|
||||
maintainer writes the entry after merging, when there is a commit for it to link.
|
||||
|
||||
Who that pull request credits follows the attribution rule above, and the two
|
||||
cases differ:
|
||||
@@ -171,8 +190,8 @@ The one repository under `.tools/` that is somebody else's is `sandstorm-io/sand
|
||||
cloned for reference only.
|
||||
|
||||
Everything below marked as maintainer-specific (committing directly, the exact commit
|
||||
author, and the entire "Making a release" / publishing flow) applies only in maintainer
|
||||
mode. The rest (translation policy, CHANGELOG structure, tests, validating from code) is
|
||||
author, updating changelogs, and the entire "Making a release" / publishing flow) applies
|
||||
only in maintainer mode. The rest (translation policy, tests, validating from code) is
|
||||
good practice for everyone.
|
||||
|
||||
## Process: "Fix open issues"
|
||||
@@ -206,6 +225,9 @@ When asked to fix an open issue (one issue at a time):
|
||||
|
||||
Fixes #1234,
|
||||
```
|
||||
|
||||
A contributor instead commits on a branch for one pull request that fixes this
|
||||
one issue, with no changelog changes (see "Contributor mode" above).
|
||||
6. **If the bug is already fixed**, still add a commit that CLOSES the issue (a commit
|
||||
whose message ends `Fixes #1234,`), noting where/when it was fixed.
|
||||
|
||||
@@ -407,6 +429,10 @@ directly after the merge.
|
||||
|
||||
### CHANGELOG
|
||||
|
||||
- **[maintainer only] Changelogs are updated only in maintainer mode.** This whole
|
||||
section is for the maintainer and for an agent working as the maintainer, in WeKan
|
||||
and in the repositories under `.tools/`. A pull request contributor changes no
|
||||
changelog in any of them (see "Contributor mode" above).
|
||||
- **Every repository writes its changelog in the format that repository's own file
|
||||
already uses.** Open its changelog, read the entries above the place you are
|
||||
adding one, and match them — never import another project's shape into it. The
|
||||
@@ -817,7 +843,7 @@ have cost a released section its accuracy:
|
||||
### Security issues
|
||||
|
||||
- Fix the vulnerability, add a CRITICAL section to the WeKan CHANGELOG like previous
|
||||
entries, and update `.tools/wekan.fi/hall-of-fame/index.html` and the vuln-name
|
||||
entries (maintainer mode only, as with every changelog change), and update `.tools/wekan.fi/hall-of-fame/index.html` and the vuln-name
|
||||
subdirectory `index.html` like previous security issues.
|
||||
- **Every security fix gets a TEST and a NEGATIVE TEST, and they are written so the
|
||||
fault cannot exist ANYWHERE in the codebase — not just at the place it was
|
||||
|
||||
+2232
-7
File diff suppressed because it is too large.
Load diff
+30
-32
@@ -5,49 +5,47 @@ each one changed and the project status are in
|
||||
Written by `node releases/changelog-short.mjs` from CHANGELOG-big.md:
|
||||
make changes there.
|
||||
|
||||
# v12.74 2026-10-30 WeKan ® release
|
||||
# v12.95 2026-11-05 WeKan ® release
|
||||
|
||||
**Caddy** and **FerretDB**, the Go servers WeKan ships, no longer
|
||||
open MPTCP listeners, which Linux kernel bug **CVE-2026-98432** is reachable
|
||||
through. The real fix is a patched host kernel.
|
||||
**Continuous backup** now runs on one server at a time, with a
|
||||
lease, when WeKan has several servers. Missing-board **exports** and REST
|
||||
updates of missing **integrations**, **users**, **organizations** and **teams**
|
||||
answer 404 instead of 200, the **minicard** shows a picked Requested by and
|
||||
Assigned by, a card stops offering the attachment "+" when uploads are blocked,
|
||||
and the Docker image's **nodemailer**, **body-parser**, **qs**, **uuid** and
|
||||
**postcss-selector-parser** are raised past the advisories a Grype scan found.
|
||||
|
||||
[All changes in v12.74](CHANGELOG-big.md#v1274-2026-10-30-wekan--release)
|
||||
[All changes in v12.95](CHANGELOG-big.md#v1295-2026-11-05-wekan--release)
|
||||
|
||||
# v12.73 2026-10-30 WeKan ® release
|
||||
# v12.94 2026-11-04 WeKan ® release
|
||||
|
||||
**CHANGELOG.md** is now short: the In short of the upcoming
|
||||
release and of the five newest releases, in plain Markdown. The status and every
|
||||
change of each release are in **CHANGELOG-big.md**, which is the one that is
|
||||
written. **CodeQL** alert #598 in the Sandstorm host test is fixed, and the
|
||||
release script pulls before releasing, so a moved main no longer stops it.
|
||||
On an iPad the **opened card covers the header bar** instead of
|
||||
sitting
|
||||
behind it, and the speed test measures where the time goes when a WeKan board is
|
||||
opened, on FerretDB and on MongoDB.
|
||||
|
||||
[All changes in v12.73](CHANGELOG-big.md#v1273-2026-10-30-wekan--release)
|
||||
[All changes in v12.94](CHANGELOG-big.md#v1294-2026-11-04-wekan--release)
|
||||
|
||||
# v12.72 2026-10-30 WeKan ® release
|
||||
# v12.93 2026-11-04 WeKan ® release
|
||||
|
||||
A card link can no longer launch an operating-system handler with
|
||||
a code-execution history, such as **ms-msdt** (Follina) or **telnet**, even if
|
||||
an administrator lists it, and an attempt to write one shows in **Admin Panel →
|
||||
Problems**. The **Apache** guide follows the 2024 Confusion Attacks, and the
|
||||
Sandstorm store build publishes to **both app markets**.
|
||||
The database **speed test** has a new small-scale result, ordered
|
||||
from fastest to slowest with how many times slower each database is, and two
|
||||
more databases: **pgrust** (PostgreSQL in Rust) and **TiDB**. Noria, YugabyteDB
|
||||
and CockroachDB were looked at and cannot be used, for reasons the README gives.
|
||||
|
||||
[All changes in v12.72](CHANGELOG-big.md#v1272-2026-10-30-wekan--release)
|
||||
[All changes in v12.93](CHANGELOG-big.md#v1293-2026-11-04-wekan--release)
|
||||
|
||||
# v12.71 2026-10-30 WeKan ® release
|
||||
# v12.92 2026-11-03 WeKan ® release
|
||||
|
||||
The **Sandstorm** build installs Sandstorm from
|
||||
**install.sandstorm.org**, as the Sandstorm workflows already did, and the
|
||||
docs link sandstorm.org. The Hall of Fame guards read every vulnerability name
|
||||
now that each one links to its own page.
|
||||
A missing **REST checklist** answers a JSON 404 instead of an
|
||||
empty 500.
|
||||
|
||||
[All changes in v12.71](CHANGELOG-big.md#v1271-2026-10-30-wekan--release)
|
||||
[All changes in v12.92](CHANGELOG-big.md#v1292-2026-11-03-wekan--release)
|
||||
|
||||
# v12.70 2026-10-29 WeKan ® release
|
||||
# v12.91 2026-11-03 WeKan ® release
|
||||
|
||||
The **AppImages** and the **Launchpad** snaps build again: their
|
||||
FerretDB and mongo-tools are readable by every user, and the snap source
|
||||
carries what its downloads need. **Continuous backup** names a changed
|
||||
encrypted file as changed with **OpenSSL 4**, and the release and test tooling
|
||||
runs on macOS and reports a bad certificate as one.
|
||||
A **GitHub CodeQL** finding in a test is fixed, and the guard
|
||||
against
|
||||
replacing text with itself now also sees patterns with a `^` or `$` anchor.
|
||||
|
||||
[All changes in v12.70](CHANGELOG-big.md#v1270-2026-10-29-wekan--release)
|
||||
[All changes in v12.91](CHANGELOG-big.md#v1291-2026-11-03-wekan--release)
|
||||
@@ -26,7 +26,7 @@ Work out which you are from the git identity (the next section says how), then:
|
||||
| Who | Where the work goes |
|
||||
| --- | --- |
|
||||
| **The maintainer** — `Lauri Ojansivu <x@xet7.org>` | **Directly on `main`.** Never a feature branch, never a topic branch, never a pull request. |
|
||||
| **A contributor** — anybody else | **A branch in your own fork, then a pull request** for the maintainer to review. Never a commit on `main`. |
|
||||
| **A contributor** — anybody else | **A branch in your own fork, then one pull request per feature or fix** for the maintainer to review. Never a commit on `main`, and no changelog changes. |
|
||||
|
||||
Nothing else is a third option. If a tool, a task runner or an agent harness offers
|
||||
some other arrangement — a topic branch for the maintainer, a direct push for a
|
||||
@@ -41,7 +41,9 @@ released section links a commit that release does not contain.
|
||||
|
||||
The contributor's half is review. Nobody but the maintainer commits to `main` in
|
||||
wekan/wekan, so a change from anyone else arrives as a pull request — which is also
|
||||
the only place it can be discussed before it lands.
|
||||
the only place it can be discussed before it lands. That is why each pull request
|
||||
holds one feature or one fix, and leaves the changelogs to the maintainer: see
|
||||
"Contributor mode" below.
|
||||
|
||||
Check which branch you are on before committing:
|
||||
|
||||
@@ -143,12 +145,29 @@ git config user.name && git config user.email
|
||||
already exists, on <https://wekan.fi/sponsors>, and belongs nowhere else. The **publishing / release
|
||||
steps** below are available to a human maintainer only: an AI stops after
|
||||
committing locally and never runs a step that pushes or publishes.
|
||||
|
||||
**Maintainer mode is the only mode that commits directly and the only mode that
|
||||
updates changelogs**, in WeKan and in the maintainer's repositories under
|
||||
`.tools/` alike. It applies to the changes the WeKan maintainer makes, and to the
|
||||
changes of an agent working as the maintainer according to this file.
|
||||
- **Contributor mode** — the identity is somebody ELSE, in a fork or a clone of your
|
||||
own — a person, or an AI that files pull requests on its own account. Then: do
|
||||
**not** commit to `main` and do **not** run any release/publishing step. Make the
|
||||
changes on a branch and open a **pull request** for the maintainer to review. The
|
||||
"commit as Lauri Ojansivu", "commit directly to `main`", and all release
|
||||
instructions below are **maintainer-only and do not apply to you**.
|
||||
**not** commit directly to the repository or to `main`, and do **not** run any
|
||||
release/publishing step. Make the changes on a branch and open a **pull request**
|
||||
for the maintainer to review. The "commit as Lauri Ojansivu", "commit directly to
|
||||
`main`", changelog and release instructions below are **maintainer-only and do
|
||||
not apply to you**.
|
||||
|
||||
Every pull request, to wekan/wekan or to a maintainer's repository under `.tools/`,
|
||||
follows two more rules:
|
||||
- **One feature or one fix per pull request.** Changes share a pull request only
|
||||
when they are related to that one feature or fix. Anything unrelated, however
|
||||
small, goes in a pull request of its own, so each can be reviewed, merged or
|
||||
declined without holding up the others.
|
||||
- **No changelog changes.** Do not edit `CHANGELOG-big.md`, `CHANGELOG.md`, or the
|
||||
`CHANGELOG.md` or `CHANGELOG` of a repository under `.tools/` — not an Upcoming
|
||||
entry, not TODO Later. Describe the change in the pull request instead. The
|
||||
maintainer writes the entry after merging, when there is a commit for it to link.
|
||||
|
||||
Who that pull request credits follows the attribution rule above, and the two
|
||||
cases differ:
|
||||
@@ -171,8 +190,8 @@ The one repository under `.tools/` that is somebody else's is `sandstorm-io/sand
|
||||
cloned for reference only.
|
||||
|
||||
Everything below marked as maintainer-specific (committing directly, the exact commit
|
||||
author, and the entire "Making a release" / publishing flow) applies only in maintainer
|
||||
mode. The rest (translation policy, CHANGELOG structure, tests, validating from code) is
|
||||
author, updating changelogs, and the entire "Making a release" / publishing flow) applies
|
||||
only in maintainer mode. The rest (translation policy, tests, validating from code) is
|
||||
good practice for everyone.
|
||||
|
||||
## Process: "Fix open issues"
|
||||
@@ -206,6 +225,9 @@ When asked to fix an open issue (one issue at a time):
|
||||
|
||||
Fixes #1234,
|
||||
```
|
||||
|
||||
A contributor instead commits on a branch for one pull request that fixes this
|
||||
one issue, with no changelog changes (see "Contributor mode" above).
|
||||
6. **If the bug is already fixed**, still add a commit that CLOSES the issue (a commit
|
||||
whose message ends `Fixes #1234,`), noting where/when it was fixed.
|
||||
|
||||
@@ -407,6 +429,10 @@ directly after the merge.
|
||||
|
||||
### CHANGELOG
|
||||
|
||||
- **[maintainer only] Changelogs are updated only in maintainer mode.** This whole
|
||||
section is for the maintainer and for an agent working as the maintainer, in WeKan
|
||||
and in the repositories under `.tools/`. A pull request contributor changes no
|
||||
changelog in any of them (see "Contributor mode" above).
|
||||
- **Every repository writes its changelog in the format that repository's own file
|
||||
already uses.** Open its changelog, read the entries above the place you are
|
||||
adding one, and match them — never import another project's shape into it. The
|
||||
@@ -817,7 +843,7 @@ have cost a released section its accuracy:
|
||||
### Security issues
|
||||
|
||||
- Fix the vulnerability, add a CRITICAL section to the WeKan CHANGELOG like previous
|
||||
entries, and update `.tools/wekan.fi/hall-of-fame/index.html` and the vuln-name
|
||||
entries (maintainer mode only, as with every changelog change), and update `.tools/wekan.fi/hall-of-fame/index.html` and the vuln-name
|
||||
subdirectory `index.html` like previous security issues.
|
||||
- **Every security fix gets a TEST and a NEGATIVE TEST, and they are written so the
|
||||
fault cannot exist ANYWHERE in the codebase — not just at the place it was
|
||||
|
||||
+10
-2
@@ -38,7 +38,7 @@ FROM --platform=$BUILDPLATFORM debian:trixie AS bundle
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
ARG VERSION=12.74
|
||||
ARG VERSION=12.95
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
COPY --chmod=755 releases/check-telemetry.py /tmp/check-telemetry.py
|
||||
@@ -151,7 +151,7 @@ ENV \
|
||||
NODE_VERSION=v26.13.0 \
|
||||
METEOR_RELEASE=METEOR@3.6-rc.1 \
|
||||
USE_EDGE=false \
|
||||
NPM_VERSION=12.2.1 \
|
||||
NPM_VERSION=12.2.2 \
|
||||
SRC_PATH=./ \
|
||||
WITH_API=true \
|
||||
MONGO_OPLOG_URL="" \
|
||||
@@ -292,6 +292,14 @@ ENV \
|
||||
LOGOUT_ON_HOURS="" \
|
||||
LOGOUT_ON_MINUTES="" \
|
||||
USERS_DISABLE_INACTIVE_AFTER_DAYS="" \
|
||||
LOGOUT_IDLE_MINUTES="" \
|
||||
REQUIRE_2FA="" \
|
||||
PASSWORD_MIN_LENGTH="" \
|
||||
PASSWORD_MIN_LENGTH_ENFORCE="" \
|
||||
PASSWORD_CHECK_PWNED="" \
|
||||
HSTS_MAX_AGE="" \
|
||||
HSTS_INCLUDE_SUBDOMAINS="" \
|
||||
HSTS_PRELOAD="" \
|
||||
ATTACHMENTS_RETENTION_DAYS="" \
|
||||
ATTACHMENTS_RETENTION_MODE="" \
|
||||
ATTACHMENTS_PURGE_DELETED_AFTER_DAYS="" \
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
|
||||
appVersion: "v12.74.0"
|
||||
appVersion: "v12.95.0"
|
||||
files:
|
||||
userUploads:
|
||||
- README.md
|
||||
|
||||
@@ -8,13 +8,6 @@
|
||||
# TODO:
|
||||
# addcustomfieldtoboard: There is error: Settings must be object. So adding does not work yet.
|
||||
|
||||
try:
|
||||
# python 3
|
||||
from urllib.parse import urlencode
|
||||
except ImportError:
|
||||
# python 2
|
||||
from urllib import urlencode
|
||||
|
||||
import json
|
||||
import requests
|
||||
import sys
|
||||
@@ -163,7 +156,7 @@ If *nix: chmod +x api.py => ./api.py users
|
||||
|
||||
if arguments == 0:
|
||||
print(syntax)
|
||||
exit
|
||||
sys.exit()
|
||||
|
||||
# TODO:
|
||||
# print(" python3 api.py attachmentjson BOARDID ATTACHMENTID # One attachment as JSON base64")
|
||||
@@ -329,7 +322,7 @@ if arguments == 8:
|
||||
if new_card_id:
|
||||
edcard = wekanurl + apiboards + boardid + s + l + s + listid + s + cs + s + new_card_id
|
||||
put_data = {'labelIds': labelIds}
|
||||
body = requests.put(edcard, data=put_data, headers=headers)
|
||||
requests.put(edcard, data=put_data, headers=headers)
|
||||
print("=== EDIT CARD ===\n")
|
||||
body = requests.get(edcard, headers=headers)
|
||||
data2 = body.text.replace('}', "}\n")
|
||||
@@ -372,7 +365,7 @@ if arguments == 6:
|
||||
print(edcard)
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
put_data = {'title': '{}'.format(newcardtitle), 'description': '{}'.format(newcarddescription)}
|
||||
body = requests.put(edcard, data=put_data, headers=headers)
|
||||
requests.put(edcard, data=put_data, headers=headers)
|
||||
print("=== EDIT CARD ===\n")
|
||||
body = requests.get(edcard, headers=headers)
|
||||
data2 = body.text.replace('}',"}\n")
|
||||
@@ -411,7 +404,7 @@ if arguments == 5:
|
||||
print(edcard)
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
put_data = {'labelIds': labelIds}
|
||||
body = requests.put(edcard, data=put_data, headers=headers)
|
||||
requests.put(edcard, data=put_data, headers=headers)
|
||||
print("=== ADD LABEL ===\n")
|
||||
body = requests.get(edcard, headers=headers)
|
||||
data2 = body.text.replace('}',"}\n")
|
||||
@@ -437,7 +430,7 @@ if arguments == 5:
|
||||
print(edcard)
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
put_data = {'color': '{}'.format(newcolor)}
|
||||
body = requests.put(edcard, data=put_data, headers=headers)
|
||||
requests.put(edcard, data=put_data, headers=headers)
|
||||
print("=== EDIT CARD COLOR ===\n")
|
||||
body = requests.get(edcard, headers=headers)
|
||||
data2 = body.text.replace('}', "}\n")
|
||||
@@ -456,7 +449,7 @@ if arguments >= 4:
|
||||
print(edlist)
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
put_data = {'title': '{}'.format(newlisttitle)}
|
||||
body = requests.put(edlist, data=put_data, headers=headers)
|
||||
requests.put(edlist, data=put_data, headers=headers)
|
||||
print("=== EDIT LIST ===\n")
|
||||
body = requests.get(edlist, headers=headers)
|
||||
data2 = body.text.replace('}', "}\n")
|
||||
@@ -482,7 +475,7 @@ if arguments >= 4:
|
||||
print(edlist)
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
put_data = {'color': '{}'.format(newcolor)}
|
||||
body = requests.put(edlist, data=put_data, headers=headers)
|
||||
requests.put(edlist, data=put_data, headers=headers)
|
||||
print("=== EDIT LIST COLOR ===\n")
|
||||
body = requests.get(edlist, headers=headers)
|
||||
data2 = body.text.replace('}', "}\n")
|
||||
@@ -1050,7 +1043,7 @@ if arguments >= 1:
|
||||
if arguments < 6:
|
||||
print("Usage: python3 api.py uploadattachment BOARDID SWIMLANEID LISTID CARDID FILEPATH [STORAGE_BACKEND]")
|
||||
print("Storage backends: fs, gridfs, s3")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
|
||||
boardid = sys.argv[2]
|
||||
swimlaneid = sys.argv[3]
|
||||
@@ -1064,7 +1057,7 @@ if arguments >= 1:
|
||||
import os, mimetypes
|
||||
if not os.path.isfile(filepath):
|
||||
print(f"Error: File '{filepath}' not found")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
filename = os.path.basename(filepath)
|
||||
file_type = mimetypes.guess_type(filepath)[0] or 'application/octet-stream'
|
||||
params = {'boardId': boardid, 'swimlaneId': swimlaneid, 'listId': listid, 'cardId': cardid,
|
||||
@@ -1092,7 +1085,7 @@ if arguments >= 1:
|
||||
# ------- DOWNLOAD ATTACHMENT START -----------
|
||||
if arguments < 3:
|
||||
print("Usage: python3 api.py downloadattachment ATTACHMENTID OUTPUTPATH")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
|
||||
attachmentid = sys.argv[2]
|
||||
outputpath = sys.argv[3]
|
||||
@@ -1121,7 +1114,7 @@ if arguments >= 1:
|
||||
# ------- ATTACHMENT INFO START -----------
|
||||
if arguments < 2:
|
||||
print("Usage: python3 api.py attachmentinfo ATTACHMENTID")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
|
||||
attachmentid = sys.argv[2]
|
||||
|
||||
@@ -1160,7 +1153,7 @@ if arguments >= 1:
|
||||
# ------- LIST CARD ATTACHMENTS START -----------
|
||||
if arguments < 5:
|
||||
print("Usage: python3 api.py listcardattachments BOARDID SWIMLANEID LISTID CARDID")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
|
||||
boardid = sys.argv[2]
|
||||
swimlaneid = sys.argv[3]
|
||||
@@ -1199,7 +1192,7 @@ if arguments >= 1:
|
||||
# ------- COPY/MOVE ATTACHMENT START -----------
|
||||
if arguments < 6:
|
||||
print("Usage: python3 api.py copymoveattachment ATTACHMENTID TARGETBOARDID TARGETSWIMLANEID TARGETLISTID TARGETCARDID [copy|move]")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
|
||||
attachmentid = sys.argv[2]
|
||||
targetboardid = sys.argv[3]
|
||||
@@ -1210,7 +1203,7 @@ if arguments >= 1:
|
||||
|
||||
if operation not in ['copy', 'move']:
|
||||
print("Operation must be 'copy' or 'move'")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
|
||||
# Prepare request data
|
||||
request_data = {
|
||||
@@ -1253,7 +1246,7 @@ if arguments >= 1:
|
||||
# ------- DELETE ATTACHMENT START -----------
|
||||
if arguments < 2:
|
||||
print("Usage: python3 api.py deleteattachment ATTACHMENTID")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
|
||||
attachmentid = sys.argv[2]
|
||||
|
||||
@@ -1322,7 +1315,7 @@ if arguments >= 1:
|
||||
# ------- ADD BOARD MEMBER START -----------
|
||||
if arguments < 3:
|
||||
print("Usage: python3 api.py addboardmember BOARDID USERID ROLE")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
userid = sys.argv[3]
|
||||
role = sys.argv[4] if arguments > 3 else 'normal'
|
||||
@@ -1339,7 +1332,7 @@ if arguments >= 1:
|
||||
# ------- REMOVE BOARD MEMBER START -----------
|
||||
if arguments < 3:
|
||||
print("Usage: python3 api.py removeboardmember BOARDID USERID")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
userid = sys.argv[3]
|
||||
remove_member_url = wekanurl + apiboards + boardid + s + 'members' + s + userid + s + 'remove'
|
||||
@@ -1354,7 +1347,7 @@ if arguments >= 1:
|
||||
# ------- SET BOARD MEMBER ROLE START -----------
|
||||
if arguments < 4:
|
||||
print("Usage: python3 api.py setboardmemberrole BOARDID USERID ROLE")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
userid = sys.argv[3]
|
||||
role = sys.argv[4]
|
||||
@@ -1370,7 +1363,7 @@ if arguments >= 1:
|
||||
# ------- LIST BOARD DOMAINS START -----------
|
||||
if arguments < 2:
|
||||
print("Usage: python3 api.py boarddomains BOARDID")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
domains_url = wekanurl + apiboards + boardid + s + 'domains'
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
@@ -1383,7 +1376,7 @@ if arguments >= 1:
|
||||
# ------- ADD BOARD DOMAIN START -----------
|
||||
if arguments < 3:
|
||||
print("Usage: python3 api.py addboarddomain BOARDID DOMAIN")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
domain = sys.argv[3]
|
||||
add_domain_url = wekanurl + apiboards + boardid + s + 'domains'
|
||||
@@ -1398,7 +1391,7 @@ if arguments >= 1:
|
||||
# ------- REMOVE BOARD DOMAIN START -----------
|
||||
if arguments < 3:
|
||||
print("Usage: python3 api.py removeboarddomain BOARDID DOMAIN")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
domain = sys.argv[3]
|
||||
remove_domain_url = wekanurl + apiboards + boardid + s + 'domains' + s + domain
|
||||
@@ -1413,7 +1406,7 @@ if arguments >= 1:
|
||||
if arguments < 4:
|
||||
print("Usage: python3 api.py {} BOARDID LISTID CARDID IDS".format(sys.argv[1]))
|
||||
print("IDS = comma-separated userIds, or '' to clear")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
listid = sys.argv[3]
|
||||
cardid = sys.argv[4]
|
||||
@@ -1435,7 +1428,7 @@ if arguments >= 1:
|
||||
print("Usage: python3 api.py setcarddate BOARDID LISTID CARDID DATETYPE DATEVALUE")
|
||||
print("DATETYPE: received, start, due, end")
|
||||
print("DATEVALUE: ISO 8601, e.g. 2026-06-07T12:00:00.000Z")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
listid = sys.argv[3]
|
||||
cardid = sys.argv[4]
|
||||
@@ -1453,7 +1446,7 @@ if arguments >= 1:
|
||||
edcard = wekanurl + apiboards + boardid + s + l + s + listid + s + cs + s + cardid
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
put_data = {datemap[datetype]: datevalue}
|
||||
body = requests.put(edcard, data=put_data, headers=headers)
|
||||
requests.put(edcard, data=put_data, headers=headers)
|
||||
print("=== SET CARD DATE {} ===\n".format(datetype.upper()))
|
||||
print(body.text)
|
||||
# ------- SET CARD DATE END -----------
|
||||
@@ -1463,7 +1456,7 @@ if arguments >= 1:
|
||||
if arguments < 4:
|
||||
print("Usage: python3 api.py setcardlabels BOARDID LISTID CARDID LABELIDS")
|
||||
print("LABELIDS = comma-separated labelIds, or '' to clear")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
listid = sys.argv[3]
|
||||
cardid = sys.argv[4]
|
||||
@@ -1483,7 +1476,7 @@ if arguments >= 1:
|
||||
# ------- MOVE CARD START -----------
|
||||
if arguments < 7:
|
||||
print("Usage: python3 api.py movecard BOARDID LISTID CARDID NEWBOARDID NEWSWIMLANEID NEWLISTID")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
listid = sys.argv[3]
|
||||
cardid = sys.argv[4]
|
||||
@@ -1497,7 +1490,7 @@ if arguments >= 1:
|
||||
'newSwimlaneId': newswimlaneid,
|
||||
'newListId': newlistid,
|
||||
}
|
||||
body = requests.put(edcard, data=put_data, headers=headers)
|
||||
requests.put(edcard, data=put_data, headers=headers)
|
||||
print("=== MOVE CARD ===\n")
|
||||
print(body.text)
|
||||
# ------- MOVE CARD END -----------
|
||||
@@ -1508,7 +1501,7 @@ if arguments >= 1:
|
||||
print("Usage: python3 api.py bulkaddcards BOARDID LISTID AUTHORID SWIMLANEID CARDSJSONFILE")
|
||||
print("CARDSJSONFILE = JSON file with a list of titles, e.g. [\"Card A\", \"Card B\"],")
|
||||
print(" or a list of card objects [{\"title\": \"Card A\", \"description\": \"...\"}, ...]")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
listid = sys.argv[3]
|
||||
authorid = sys.argv[4]
|
||||
@@ -1516,16 +1509,16 @@ if arguments >= 1:
|
||||
cardsjsonfile = sys.argv[6] if arguments > 5 else None
|
||||
if not cardsjsonfile:
|
||||
print("CARDSJSONFILE is required")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
try:
|
||||
with open(cardsjsonfile, 'r') as f:
|
||||
raw_cards = json.load(f)
|
||||
except Exception as e:
|
||||
print("Error reading CARDSJSONFILE: {}".format(e))
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
if not isinstance(raw_cards, list) or len(raw_cards) == 0:
|
||||
print("CARDSJSONFILE must contain a non-empty JSON list")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
cards = []
|
||||
for item in raw_cards:
|
||||
if isinstance(item, str):
|
||||
@@ -1534,7 +1527,7 @@ if arguments >= 1:
|
||||
cards.append(item)
|
||||
else:
|
||||
print("Each card must be a string (title) or an object")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
bulk_url = wekanurl + apiboards + boardid + s + l + s + listid + s + cs + s + 'bulk'
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
|
||||
post_data = {'authorId': authorid, 'swimlaneId': swimlaneid, 'cards': cards}
|
||||
@@ -1548,7 +1541,7 @@ if arguments >= 1:
|
||||
if arguments < 3:
|
||||
print("Usage: python3 api.py bulkdeletecards BOARDID CARDIDS")
|
||||
print("CARDIDS = comma-separated cardIds")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
cardids_raw = sys.argv[3]
|
||||
cardids = [x for x in cardids_raw.split(',') if x]
|
||||
@@ -1565,7 +1558,7 @@ if arguments >= 1:
|
||||
if arguments < 5:
|
||||
print("Usage: python3 api.py bulkcardlabels BOARDID CARDIDS ADDLABELIDS REMOVELABELIDS")
|
||||
print("CARDIDS / ADDLABELIDS / REMOVELABELIDS = comma-separated, or '' for none")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
cardids = [x for x in sys.argv[3].split(',') if x]
|
||||
add_labelids = [x for x in sys.argv[4].split(',') if x]
|
||||
@@ -1582,7 +1575,7 @@ if arguments >= 1:
|
||||
# ------- CREATE LINKED CARD START -----------
|
||||
if arguments < 6:
|
||||
print("Usage: python3 api.py linkcard BOARDID LISTID SWIMLANEID AUTHORID LINKEDCARDID")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
listid = sys.argv[3]
|
||||
swimlaneid = sys.argv[4]
|
||||
@@ -1600,7 +1593,7 @@ if arguments >= 1:
|
||||
# ------- ADD/REMOVE CARD MEMBER / ASSIGNEE START -----------
|
||||
if arguments < 5:
|
||||
print("Usage: python3 api.py {} BOARDID LISTID CARDID USERID".format(sys.argv[1]))
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
listid = sys.argv[3]
|
||||
cardid = sys.argv[4]
|
||||
@@ -1620,14 +1613,14 @@ if arguments >= 1:
|
||||
# ------- COPY CARD START -----------
|
||||
if arguments < 4:
|
||||
print("Usage: python3 api.py copycard BOARDID LISTID CARDID TOSWIMLANEID [TOBOARDID] [TOLISTID] [POSITION]")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
listid = sys.argv[3]
|
||||
cardid = sys.argv[4]
|
||||
toswimlaneid = sys.argv[5] if arguments > 4 else None
|
||||
if not toswimlaneid:
|
||||
print("TOSWIMLANEID is required")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
toboardid = sys.argv[6] if arguments > 5 else boardid
|
||||
tolistid = sys.argv[7] if arguments > 6 else listid
|
||||
url = wekanurl + apiboards + boardid + s + l + s + listid + s + cs + s + cardid + s + 'copy'
|
||||
@@ -1644,7 +1637,7 @@ if arguments >= 1:
|
||||
# ------- COPY/MOVE SWIMLANE START -----------
|
||||
if arguments < 3:
|
||||
print("Usage: python3 api.py {} BOARDID SWIMLANEID [TOBOARDID] [POSITION]".format(sys.argv[1]))
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
swimlaneid = sys.argv[3]
|
||||
toboardid = sys.argv[4] if arguments > 3 else boardid
|
||||
@@ -1663,7 +1656,7 @@ if arguments >= 1:
|
||||
# ------- COPY/MOVE LIST START -----------
|
||||
if arguments < 3:
|
||||
print("Usage: python3 api.py {} BOARDID LISTID [TOSWIMLANEID] [TOBOARDID] [POSITION]".format(sys.argv[1]))
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
listid = sys.argv[3]
|
||||
toswimlaneid = sys.argv[4] if arguments > 3 else None
|
||||
@@ -1702,7 +1695,7 @@ if arguments >= 1:
|
||||
# ------- GET CARD SETTINGS START -----------
|
||||
if arguments < 2:
|
||||
print("Usage: python3 api.py getcardsettings BOARDID")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
url = wekanurl + apiboards + boardid + s + 'cardSettings'
|
||||
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
|
||||
@@ -1717,7 +1710,7 @@ if arguments >= 1:
|
||||
if arguments < 4:
|
||||
print("Usage: python3 api.py setcardsetting BOARDID KEY VALUE")
|
||||
print("e.g. python3 api.py setcardsetting BOARDID allowsDueDate true")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
boardid = sys.argv[2]
|
||||
key = sys.argv[3]
|
||||
value = sys.argv[4]
|
||||
@@ -1945,7 +1938,7 @@ if arguments >= 3 and sys.argv[1] == 'uploadbackground':
|
||||
response = requests.post(url, headers=headers, params=params, data=f)
|
||||
except Exception as e:
|
||||
print(f"Error reading file: {e}")
|
||||
exit(1)
|
||||
sys.exit(1)
|
||||
print(response.text)
|
||||
|
||||
if arguments >= 3 and sys.argv[1] == 'downloadbackground':
|
||||
|
||||
@@ -989,15 +989,18 @@ exit /b %errorlevel%
|
||||
|
||||
:buildcommon
|
||||
REM The same diagnostics as build.sh build_wekan: npm verbose output and
|
||||
REM foreground install scripts, METEOR_PROFILE, Node module resolution and the
|
||||
REM resolver command/output tracer, so a failed Windows build leaves the same
|
||||
REM evidence in its log. WEKAN_BUILD_HEAP_SNAPSHOT=1 caps the heap at
|
||||
REM foreground install scripts, METEOR_PROFILE and the resolver command/output
|
||||
REM tracer, so a failed Windows build leaves the same evidence in its log. Node
|
||||
REM module resolution (one line per require, gigabytes a minute) only with
|
||||
REM WEKAN_BUILD_TRACE_MODULES=1. WEKAN_BUILD_HEAP_SNAPSHOT=1 caps the heap at
|
||||
REM WEKAN_BUILD_HEAP_SNAPSHOT_MB (4096) and writes a heap snapshot near the limit.
|
||||
setlocal
|
||||
set "npm_config_loglevel=verbose"
|
||||
set "npm_config_foreground_scripts=true"
|
||||
if not defined METEOR_PROFILE set "METEOR_PROFILE=100"
|
||||
if defined NODE_DEBUG (set "NODE_DEBUG=%NODE_DEBUG%,module") else (set "NODE_DEBUG=module")
|
||||
if "%WEKAN_BUILD_TRACE_MODULES%"=="1" (
|
||||
if defined NODE_DEBUG (set "NODE_DEBUG=!NODE_DEBUG!,module") else (set "NODE_DEBUG=module")
|
||||
)
|
||||
if "%WEKAN_BUILD_HEAP_SNAPSHOT%"=="1" (
|
||||
if not defined WEKAN_BUILD_HEAP_SNAPSHOT_MB set "WEKAN_BUILD_HEAP_SNAPSHOT_MB=4096"
|
||||
set "TOOL_NODE_FLAGS=--max-old-space-size=!WEKAN_BUILD_HEAP_SNAPSHOT_MB! --heapsnapshot-near-heap-limit=1"
|
||||
@@ -1005,7 +1008,7 @@ if "%WEKAN_BUILD_HEAP_SNAPSHOT%"=="1" (
|
||||
echo Heap snapshot: ON, heap capped at !WEKAN_BUILD_HEAP_SNAPSHOT_MB! MB; the build will fail sooner and write Heap.*.heapsnapshot.
|
||||
)
|
||||
set "NODE_OPTIONS=%NODE_OPTIONS% --require="%REPO%\tools\build-command-output.cjs""
|
||||
echo Build diagnostics: resolver command/output tracing, Node module resolution, npm verbose output, foreground install scripts, METEOR_PROFILE=%METEOR_PROFILE% ^(milliseconds^).
|
||||
echo Build diagnostics: resolver command/output tracing, npm verbose output, foreground install scripts, METEOR_PROFILE=%METEOR_PROFILE% ^(milliseconds^).
|
||||
call :buildcommon_steps
|
||||
set "BUILD_RC=%errorlevel%"
|
||||
if not "%BUILD_RC%"=="0" if defined WEKAN_BUILD_LOG (
|
||||
@@ -1977,7 +1980,7 @@ netstat -ano | findstr /r /c:":3001 .*LISTENING" >nul 2>&1
|
||||
if not errorlevel 1 (
|
||||
echo ==^> Reusing the MongoDB already listening on :3001 ^(not started or stopped by this run^).
|
||||
) else (
|
||||
for %%i in ("%REPO%\..\mongodb-test-3001") do set "DBPATH=%%~fi"
|
||||
for %%i in ("%REPO%\.tools\mongodb-test-3001") do set "DBPATH=%%~fi"
|
||||
if not exist "!DBPATH!" md "!DBPATH!"
|
||||
echo ==^> Starting MongoDB ^(Meteor's mongod^) on :3001, dbpath !DBPATH!.
|
||||
start "WekanTestMongo" /MIN "%MONGOD_BIN%" --port 3001 --dbpath "!DBPATH!" --bind_ip 127.0.0.1 --logpath "%RUN_LOGDIR%\wekan-test-mongod.log"
|
||||
|
||||
@@ -468,10 +468,17 @@ function build_wekan(){
|
||||
echo "===== wekan build started $(date '+%F %T') ====="
|
||||
export npm_config_loglevel=verbose npm_config_foreground_scripts=true
|
||||
export METEOR_PROFILE="${METEOR_PROFILE:-100}"
|
||||
export NODE_DEBUG="${NODE_DEBUG:+$NODE_DEBUG,}module"
|
||||
# Node module resolution, one line per require. OFF unless
|
||||
# WEKAN_BUILD_TRACE_MODULES=1: a Meteor build writes it at gigabytes a
|
||||
# minute - 1.1 GB in the first three minutes of a menu dev build, over
|
||||
# 12 GB in the first half hour of EVERYTHING on macOS, written twice
|
||||
# through tee - and slows the build it is tracing.
|
||||
if [ "${WEKAN_BUILD_TRACE_MODULES:-0}" = 1 ]; then
|
||||
export NODE_DEBUG="${NODE_DEBUG:+$NODE_DEBUG,}module"
|
||||
fi
|
||||
# NODE_OPTIONS parses quoted paths, including checkouts with spaces.
|
||||
export NODE_OPTIONS="${NODE_OPTIONS:-} --require=\"$(pwd)/tools/build-command-output.cjs\""
|
||||
echo "Build diagnostics: resolver command/output tracing, Node module resolution, npm verbose output, foreground install scripts, METEOR_PROFILE=$METEOR_PROFILE (milliseconds)."
|
||||
echo "Build diagnostics: resolver command/output tracing, npm verbose output, foreground install scripts, METEOR_PROFILE=$METEOR_PROFILE (milliseconds), Node module resolution $([ "${WEKAN_BUILD_TRACE_MODULES:-0}" = 1 ] && echo on || echo 'off (WEKAN_BUILD_TRACE_MODULES=1 turns it on)')."
|
||||
build_stage "1/4 Remove dependencies and build caches" rm -rf node_modules node_modules/.cache .meteor/local .build _build || return $?
|
||||
# Updating npm metadata has historically been best effort; installation is required.
|
||||
build_stage "2/4 Compile app to resolve Meteor plugin npm dependencies" meteor update --npm || echo "WARNING: Meteor plugin npm dependency compilation failed; trying dependency installation."
|
||||
@@ -645,8 +652,18 @@ function run_playwright_docker(){
|
||||
local docker_base_url="${WEKAN_BASE_URL:-http://127.0.0.1:3000}"
|
||||
local docker_mongo_url="${WEKAN_MONGO_URL:-mongodb://127.0.0.1:3001/meteor}"
|
||||
local docker_host_proxy_port=""
|
||||
local docker_network="host"
|
||||
# Docker Desktop on macOS keeps the host outside the container's loopback.
|
||||
if [ "$(uname -s)" = Darwin ]; then
|
||||
# Its host network loses connections to host.docker.internal: from a
|
||||
# container on that network about 1 connect in 75 never completed, so a
|
||||
# test page waited 60 s for a document that never came (Firefox, 2026-11-01:
|
||||
# 8 of 600 direct connects, against 0 of 1000 from the bridge network).
|
||||
# Nothing here needs the host network on macOS - WeKan and Mongo are reached
|
||||
# through host.docker.internal and SMTP through docker exec - so the
|
||||
# container uses Docker's bridge network, where the proxy below still
|
||||
# serves localhost.
|
||||
docker_network="bridge"
|
||||
# Meteor's dynamic imports use ROOT_URL (localhost in the test server).
|
||||
# Keep the browser on that same origin and bridge its loopback to the host.
|
||||
# Browsing host.docker.internal while ROOT_URL is localhost makes Firefox
|
||||
@@ -670,35 +687,53 @@ function run_playwright_docker(){
|
||||
mkdir -p "$filesroot"
|
||||
echo "Running Playwright $browser in Docker ($image)."
|
||||
echo "Expecting WeKan at $docker_base_url."
|
||||
local smtp_proxy_pid="" smtp_proxy_ready="" container_name="wekan-playwright-${browser}-$$"
|
||||
if [ "$(uname -s)" = Darwin ] && [ -n "${WEKAN_TEST_SMTP_PORT:-}" ]; then
|
||||
smtp_proxy_ready="$(mktemp "$WEKAN_DIR/.tools/tmp/smtp-proxy.XXXXXX")"
|
||||
node "$pwdir/helpers/docker-smtp-proxy.cjs" "$container_name" "$WEKAN_TEST_SMTP_PORT" "$smtp_proxy_ready" &
|
||||
smtp_proxy_pid=$!
|
||||
for i in 1 2 3 4 5 6 7 8 9 10; do
|
||||
[ -s "$smtp_proxy_ready" ] && break
|
||||
kill -0 "$smtp_proxy_pid" 2>/dev/null || break
|
||||
sleep 0.1
|
||||
local container_name="wekan-playwright-${browser}-$$"
|
||||
# Docker Desktop keeps the container's loopback from the host, so a server a
|
||||
# spec starts in the container cannot be reached by WeKan outside it. Each
|
||||
# port below is forwarded from the host into the container through docker
|
||||
# exec (helpers/docker-smtp-proxy.cjs, which carries any TCP): the test SMTP
|
||||
# server, and a small pool for the mock Jira/GitLab servers of the list-sync
|
||||
# specs, which take a free port from WEKAN_TEST_REVERSE_PORTS
|
||||
# (helpers/reachable-server.cjs). Elsewhere they listen on any port.
|
||||
local forward_pids="" forward_ready="" reverse_ports=""
|
||||
if [ "$(uname -s)" = Darwin ]; then
|
||||
reverse_ports="${WEKAN_TEST_REVERSE_PORTS:-39101 39102 39103 39104}"
|
||||
local fport fready fpid i
|
||||
for fport in ${WEKAN_TEST_SMTP_PORT:-} $reverse_ports; do
|
||||
fready="$(mktemp "$WEKAN_DIR/.tools/tmp/docker-port-proxy.XXXXXX")"
|
||||
node "$pwdir/helpers/docker-smtp-proxy.cjs" "$container_name" "$fport" "$fready" &
|
||||
fpid=$!
|
||||
forward_pids="$forward_pids $fpid"; forward_ready="$forward_ready $fready"
|
||||
for i in 1 2 3 4 5 6 7 8 9 10; do
|
||||
[ -s "$fready" ] && break
|
||||
kill -0 "$fpid" 2>/dev/null || break
|
||||
sleep 0.1
|
||||
done
|
||||
if [ ! -s "$fready" ]; then
|
||||
kill $forward_pids 2>/dev/null; wait $forward_pids 2>/dev/null
|
||||
rm -f $forward_ready
|
||||
echo "ERROR: local test proxy for port $fport did not start."
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
if [ ! -s "$smtp_proxy_ready" ]; then
|
||||
kill "$smtp_proxy_pid" 2>/dev/null; wait "$smtp_proxy_pid" 2>/dev/null
|
||||
rm -f "$smtp_proxy_ready"
|
||||
echo "ERROR: local SMTP test proxy did not start."
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
# Mount the whole repo so specs that reach the repo-root node_modules
|
||||
# (e.g. @wekanteam/exceljs) and .tools resolve; run from tests/playwright.
|
||||
# The repo and the files root are mounted at their OWN host paths, so a path
|
||||
# names the same file for the specs in here and for the server outside:
|
||||
# admin-continuous-backup gives the server a key file it wrote and reads the
|
||||
# stream the server wrote, and with the repo at /repo neither side found the
|
||||
# other's file.
|
||||
# Run as the host user (--user) with a writable HOME so the container does
|
||||
# not leave root-owned files under test-results/ (which would later make
|
||||
# native Chromium/Firefox runs fail with "EACCES: permission denied, mkdir
|
||||
# .../test-results/.playwright-artifacts-N").
|
||||
docker_exec run --rm --init --ipc=host --network host \
|
||||
docker_exec run --rm --init --ipc=host --network "$docker_network" \
|
||||
--name "$container_name" \
|
||||
--label org.wekan.test-run=everything \
|
||||
--user "$(id -u):$(id -g)" \
|
||||
-e HOME=/repo/.tools/tmp \
|
||||
-e TMPDIR=/repo/.tools/tmp \
|
||||
-e HOME="$reporoot/.tools/tmp" \
|
||||
-e TMPDIR="$reporoot/.tools/tmp" \
|
||||
-e WEKAN_BASE_URL="$docker_base_url" \
|
||||
-e WEKAN_DOCKER_HOST_PROXY_PORT="$docker_host_proxy_port" \
|
||||
-e WEKAN_MONGO_URL="$docker_mongo_url" \
|
||||
@@ -710,16 +745,17 @@ function run_playwright_docker(){
|
||||
-e WEKAN_TEST_SERIAL="${WEKAN_TEST_SERIAL:-0}" \
|
||||
-e WEKAN_TEST_SERVER_MODE="${WEKAN_TEST_SERVER_MODE:-bundle}" \
|
||||
-e WEKAN_TEST_SMTP_PORT="${WEKAN_TEST_SMTP_PORT:-}" \
|
||||
-e WEKAN_TEST_REVERSE_PORTS="$reverse_ports" \
|
||||
-e PLAYWRIGHT_HTML_OPEN=never \
|
||||
-e WEKAN_FILES_PATH=/wekan-files \
|
||||
-e WEKAN_FILES_PATH="$filesroot" \
|
||||
-e PLAYWRIGHT_JSON_OUTPUT_NAME="${PLAYWRIGHT_JSON_OUTPUT_NAME:-}" \
|
||||
-v "$filesroot":/wekan-files \
|
||||
-v "$reporoot":/repo -w /repo/tests/playwright \
|
||||
-v "$filesroot":"$filesroot" \
|
||||
-v "$reporoot":"$reporoot" -w "$pwdir" \
|
||||
"$image" \
|
||||
sh -c '
|
||||
export PATH=/repo/tests/playwright/node_modules/.bin:$PATH
|
||||
export PATH="$PWD/node_modules/.bin:$PATH"
|
||||
if [ -n "$WEKAN_DOCKER_HOST_PROXY_PORT" ]; then
|
||||
node /repo/tests/playwright/helpers/docker-host-proxy.cjs &
|
||||
node "$PWD/helpers/docker-host-proxy.cjs" &
|
||||
proxy_pid=$!
|
||||
trap '\''kill "$proxy_pid" 2>/dev/null || true; wait "$proxy_pid" 2>/dev/null || true'\'' EXIT
|
||||
ready=0
|
||||
@@ -732,9 +768,9 @@ function run_playwright_docker(){
|
||||
npx playwright test --project="$0" "$@"
|
||||
' "$browser" "$@"
|
||||
local playwright_rc=$?
|
||||
if [ -n "$smtp_proxy_pid" ]; then
|
||||
kill "$smtp_proxy_pid" 2>/dev/null; wait "$smtp_proxy_pid" 2>/dev/null
|
||||
rm -f "$smtp_proxy_ready"
|
||||
if [ -n "$forward_pids" ]; then
|
||||
kill $forward_pids 2>/dev/null; wait $forward_pids 2>/dev/null
|
||||
rm -f $forward_ready
|
||||
fi
|
||||
return "$playwright_rc"
|
||||
}
|
||||
@@ -1506,7 +1542,11 @@ function run_all_tests(){
|
||||
echo "ERROR: nothing is listening on :$TEST_DB_PORT and no mongod was found (Meteor dev_bundle or PATH)."
|
||||
echo " Meteor's bundled mongod is normally at <dev_bundle>/mongodb/bin/mongod."; rm -rf "$STATDIR"; return 1
|
||||
fi
|
||||
local DBPATH="../mongodb-test-$TEST_DB_PORT"
|
||||
# In .tools/, where everything this script writes lives (CLAUDE.md;
|
||||
# tests/toolsDirOnly.test.cjs). It was in the checkout's PARENT directory,
|
||||
# which a sandboxed editor cannot write - the test database never
|
||||
# started there.
|
||||
local DBPATH="$WEKAN_DIR/.tools/mongodb-test-$TEST_DB_PORT"
|
||||
mkdir -p "$DBPATH"
|
||||
echo "==> Starting MongoDB (Meteor's mongod) on :$TEST_DB_PORT, dbpath $DBPATH."
|
||||
{ echo "===== mongod :$TEST_DB_PORT - started $(date '+%Y-%m-%d %H:%M:%S %Z') ====="; exec "$MONGOD_BIN" --port "$TEST_DB_PORT" --dbpath "$DBPATH" --bind_ip 127.0.0.1 --nounixsocket; } > "$RUN_LOGDIR/wekan-test-mongod.log" 2>&1 &
|
||||
@@ -1534,7 +1574,7 @@ function run_all_tests(){
|
||||
export WEKAN_FILES_PATH_HOST="$WRITABLE_ABS/files"
|
||||
# ...and the same path for a LOCAL playwright run, not only the Docker one.
|
||||
# Only WEKAN_FILES_PATH_HOST was exported, which is read when the browsers run
|
||||
# in a container (it is mounted there as /wekan-files). A local run fell back
|
||||
# in a container (it is mounted there at the same path). A local run fell back
|
||||
# to the spec's own default of .build/bundle/files, so a spec that writes a
|
||||
# stored attachment wrote it OUTSIDE the root this server reads - and then
|
||||
# tested what WeKan does with a file it does not own.
|
||||
|
||||
+26
-1
@@ -62,7 +62,10 @@ try {
|
||||
if ('serviceWorker' in navigator) {
|
||||
window.addEventListener('load', function() {
|
||||
const swPath = new URL('pwa-service-worker.js', Meteor.absoluteUrl()).pathname;
|
||||
navigator.serviceWorker.register(swPath);
|
||||
navigator.serviceWorker.register(swPath).catch(error => {
|
||||
// No service worker (a private window, an old browser): the app works without the offline cache.
|
||||
if (process.env.DEBUG === 'true') console.warn('service worker registration failed:', error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -111,6 +114,28 @@ Meteor.startup(() => {
|
||||
}
|
||||
});
|
||||
|
||||
// LOGOUT_IDLE_MINUTES: report the user's activity while signed in, if the
|
||||
// limit is set (models/lib/idleLogout.js).
|
||||
const { createIdleActivityReporter } = require('/client/lib/idleActivity');
|
||||
const idleReporter = createIdleActivityReporter({
|
||||
target: document,
|
||||
touch: () => Meteor.call('touchActivity', () => {}),
|
||||
});
|
||||
let idleCheckedFor = null;
|
||||
Tracker.autorun(() => {
|
||||
const userId = Meteor.userId();
|
||||
if (!userId) {
|
||||
idleCheckedFor = null;
|
||||
idleReporter.stop();
|
||||
return;
|
||||
}
|
||||
if (idleCheckedFor === userId) return;
|
||||
idleCheckedFor = userId;
|
||||
Meteor.call('getIdleLogout', (error, result) => {
|
||||
if (!error && result && result.enabled && Meteor.userId() === userId) idleReporter.start();
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize mobile mode on startup for iOS devices
|
||||
// This ensures mobile mode is applied correctly on page load
|
||||
Tracker.afterFlush(() => {
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
background-color: #fff;
|
||||
border: 0;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.23);
|
||||
color: #8c8c8c;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
height: 36px;
|
||||
margin-block: 4px 6px;
|
||||
margin-inline: 0 4px;
|
||||
|
||||
@@ -15,7 +15,7 @@ template(name="commentForm")
|
||||
a.js-cancel-reply(title="{{_ 'close' }}")
|
||||
i.fa.fa-times-thin
|
||||
form.js-new-comment-form
|
||||
+editor(class="js-new-comment-input")
|
||||
+editor(class="js-new-comment-input" labelKey="comment")
|
||||
| {{getUnsavedValue 'cardComment' currentCard._id}}
|
||||
.add-controls
|
||||
button.primary.confirm.clear.js-add-comment(type="submit") {{_ 'comment'}}
|
||||
@@ -40,7 +40,7 @@ template(name="comment")
|
||||
= parentComment.text
|
||||
|
||||
+inlinedForm(classNames='js-edit-comment')
|
||||
+editor(autofocus=true)
|
||||
+editor(autofocus=true labelKey="comment")
|
||||
= text
|
||||
.edit-controls
|
||||
button.primary(type="submit" title="{{_ 'edit'}}" aria-label="{{_ 'edit'}}")
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
/* Ordinary solid-colour themes. */
|
||||
|
||||
.board-color-nephritis {
|
||||
--board-theme-accent: #27ae60;
|
||||
--board-theme-dark: #1f8b4d;
|
||||
--board-theme-hover: #2cc66d;
|
||||
--board-theme-accent: #217d49;
|
||||
--board-theme-dark: #1c6c3f;
|
||||
--board-theme-hover: #1f7645;
|
||||
--board-theme-emphasis: #ae2775;
|
||||
--board-theme-emphasis-hover: #9d2369;
|
||||
--board-theme-emphasis-close: #8b1f5e;
|
||||
@@ -36,7 +36,10 @@
|
||||
}
|
||||
|
||||
.board-color-belize {
|
||||
--board-theme-accent: #2980b9;
|
||||
/* The default theme. The earlier blue #2980b9 gave the header's white text a ratio of 4.30 and
|
||||
its #f2f2f2 button labels 3.84, below the 4.5 WCAG 2.1 AA (1.4.3) asks of
|
||||
small text; this blue keeps the hue and gives 5.14 and 4.59. */
|
||||
--board-theme-accent: #2573a7;
|
||||
--board-theme-dark: #216694;
|
||||
--board-theme-hover: #2e90d0;
|
||||
--board-theme-emphasis: #b96229;
|
||||
@@ -47,7 +50,7 @@
|
||||
--board-theme-selected-hover-bg: #f8fbfd;
|
||||
--board-theme-bright: #459cd6;
|
||||
--board-theme-canvas-scrollbar: #2573a7f2;
|
||||
--board-theme-sidebar-scrollbar: #2980b9f2;
|
||||
--board-theme-sidebar-scrollbar: #2573a7f2;
|
||||
}
|
||||
|
||||
.board-color-wisteria {
|
||||
@@ -81,12 +84,12 @@
|
||||
}
|
||||
|
||||
.board-color-pumpkin {
|
||||
--board-theme-accent: #e67e22;
|
||||
--board-theme-dark: #be6415;
|
||||
--board-theme-hover: #e98b38;
|
||||
--board-theme-emphasis: #228ae6;
|
||||
--board-theme-emphasis-hover: #187dd5;
|
||||
--board-theme-emphasis-close: #156fbe;
|
||||
--board-theme-accent: #a75919;
|
||||
--board-theme-dark: #904d15;
|
||||
--board-theme-hover: #9d5417;
|
||||
--board-theme-emphasis: #196ec2;
|
||||
--board-theme-emphasis-hover: #1765b2;
|
||||
--board-theme-emphasis-close: #155ca3;
|
||||
--board-theme-checklist-bg: #f9e5d1;
|
||||
--board-theme-selected-bg: #fdf2e9;
|
||||
--board-theme-selected-hover-bg: #fefbf8;
|
||||
@@ -96,12 +99,12 @@
|
||||
}
|
||||
|
||||
.board-color-moderatepink {
|
||||
--board-theme-accent: #cd5a91;
|
||||
--board-theme-dark: #b53773;
|
||||
--board-theme-hover: #d26b9c;
|
||||
--board-theme-emphasis: #5acd96;
|
||||
--board-theme-emphasis-hover: #43c688;
|
||||
--board-theme-emphasis-close: #37b579;
|
||||
--board-theme-accent: #bd3a79;
|
||||
--board-theme-dark: #a33268;
|
||||
--board-theme-hover: #b23772;
|
||||
--board-theme-emphasis: #217b54;
|
||||
--board-theme-emphasis-hover: #1e714d;
|
||||
--board-theme-emphasis-close: #1c6747;
|
||||
--board-theme-checklist-bg: #f4dde8;
|
||||
--board-theme-selected-bg: #faeef4;
|
||||
--board-theme-selected-hover-bg: #fefafc;
|
||||
@@ -111,9 +114,9 @@
|
||||
}
|
||||
|
||||
.board-color-strongcyan {
|
||||
--board-theme-accent: #00aecc;
|
||||
--board-theme-dark: #008ba3;
|
||||
--board-theme-hover: #00c8eb;
|
||||
--board-theme-accent: #00798d;
|
||||
--board-theme-dark: #006879;
|
||||
--board-theme-hover: #007285;
|
||||
--board-theme-emphasis: #cc1e00;
|
||||
--board-theme-emphasis-hover: #b81b00;
|
||||
--board-theme-emphasis-close: #a31800;
|
||||
@@ -126,12 +129,12 @@
|
||||
}
|
||||
|
||||
.board-color-limegreen {
|
||||
--board-theme-accent: #4bbf6b;
|
||||
--board-theme-dark: #389d54;
|
||||
--board-theme-hover: #5dc57a;
|
||||
--board-theme-emphasis: #bf4b9f;
|
||||
--board-theme-emphasis-hover: #b13f91;
|
||||
--board-theme-emphasis-close: #9d3881;
|
||||
--board-theme-accent: #277c48;
|
||||
--board-theme-dark: #226b3e;
|
||||
--board-theme-hover: #257544;
|
||||
--board-theme-emphasis: #b34093;
|
||||
--board-theme-emphasis-hover: #a53b87;
|
||||
--board-theme-emphasis-close: #96367b;
|
||||
--board-theme-checklist-bg: #daf4de;
|
||||
--board-theme-selected-bg: #edf9f0;
|
||||
--board-theme-selected-hover-bg: #fafdfb;
|
||||
@@ -141,12 +144,12 @@
|
||||
}
|
||||
|
||||
.board-color-corteza {
|
||||
--board-theme-accent: #568ba2;
|
||||
--board-theme-dark: #456f82;
|
||||
--board-theme-hover: #6597ad;
|
||||
--board-theme-emphasis: #a26d56;
|
||||
--board-theme-emphasis-hover: #92624d;
|
||||
--board-theme-emphasis-close: #825745;
|
||||
--board-theme-accent: #4a738a;
|
||||
--board-theme-dark: #406377;
|
||||
--board-theme-hover: #466c82;
|
||||
--board-theme-emphasis: #94614f;
|
||||
--board-theme-emphasis-hover: #885949;
|
||||
--board-theme-emphasis-close: #7c5142;
|
||||
--board-theme-checklist-bg: #dce6ec;
|
||||
--board-theme-selected-bg: #eef3f6;
|
||||
--board-theme-selected-hover-bg: #fafcfc;
|
||||
@@ -901,10 +904,10 @@ THEME - Relax
|
||||
.board-list li.board-color-relax,
|
||||
.board-color-relax .setting-content .content-body .side-menu ul li.active,
|
||||
.board-color-relax .setting-content .content-body .side-menu ul li.active:hover {
|
||||
background-color: #27ae61;
|
||||
background-color: #217d4a;
|
||||
}
|
||||
.board-color-relax .is-selected .minicard {
|
||||
border-inline-start: 3px solid #27ae61;
|
||||
border-inline-start: 3px solid #217d4a;
|
||||
}
|
||||
.board-color-relax .minicard {
|
||||
border-radius: 7px;
|
||||
@@ -915,17 +918,17 @@ THEME - Relax
|
||||
.board-color-relax input[type=submit].primary,
|
||||
.board-color-relax .sidebar .sidebar-content .sidebar-btn,
|
||||
.board-color-relax .selected-calendar-picker button.primary {
|
||||
background-color: #1f8b4e;
|
||||
background-color: #1c6c40;
|
||||
border-radius: 7px;
|
||||
}
|
||||
.board-color-relax.pop-over .pop-over-list li a:not(.disabled):hover,
|
||||
.board-color-relax .sidebar .sidebar-content .sidebar-btn:hover,
|
||||
.board-color-relax .sidebar-list li a:hover {
|
||||
background-color: #2cc66f;
|
||||
background-color: #1f7646;
|
||||
}
|
||||
.board-color-relax#header ul li.current,
|
||||
.board-color-relax#header-quick-access ul li.current {
|
||||
border-bottom: 2px solid #2cc66f;
|
||||
border-bottom: 2px solid #1f7646;
|
||||
}
|
||||
.board-color-relax#header-quick-access {
|
||||
color: #fff;
|
||||
@@ -941,14 +944,14 @@ THEME - Relax
|
||||
background: #8b1f5d;
|
||||
}
|
||||
.board-color-relax .materialCheckBox.is-checked {
|
||||
border-bottom: 2px solid #27ae61;
|
||||
border-right: 2px solid #27ae61;
|
||||
border-bottom: 2px solid #217d4a;
|
||||
border-right: 2px solid #217d4a;
|
||||
}
|
||||
.board-color-relax .checklist-progress-bar {
|
||||
background-color: #d4f1dd !important;
|
||||
}
|
||||
.board-color-relax .checklist-progress-bar .checklist-progress {
|
||||
background-color: #27ae61 !important;
|
||||
background-color: #217d4a !important;
|
||||
}
|
||||
.board-color-relax .is-multiselection-active .multi-selection-checkbox.is-checked + .minicard {
|
||||
background: #e7faef;
|
||||
@@ -957,17 +960,17 @@ THEME - Relax
|
||||
background: #f8fdfa;
|
||||
}
|
||||
.board-color-relax .toggle-label:after {
|
||||
background-color: #1f8b4e;
|
||||
background-color: #1c6c40;
|
||||
}
|
||||
.board-color-relax .toggle-switch:checked ~ .toggle-label {
|
||||
background-color: #3dd37e;
|
||||
}
|
||||
.board-color-relax .toggle-switch:checked ~ .toggle-label:after {
|
||||
background-color: #1f8b4e;
|
||||
background-color: #1c6c40;
|
||||
}
|
||||
@media screen and (max-width: 800px) {
|
||||
.board-color-relax.pop-over .header {
|
||||
background: #27ae61;
|
||||
background: #217d4a;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
@@ -1554,7 +1557,7 @@ THEME - Clear Blue
|
||||
.board-backgrounds-list .board-color-clearblue.background-box,
|
||||
.public-board-row.board-color-clearblue,
|
||||
.board-list li.board-color-clearblue {
|
||||
background: linear-gradient(180deg, #499bea 0%, #00aecc 100%);
|
||||
background: linear-gradient(180deg, #499bea 0%, #00798d 100%);
|
||||
}
|
||||
.board-color-clearblue .is-selected .minicard {
|
||||
border-inline-start: 3px solid #499bea;
|
||||
@@ -1566,7 +1569,7 @@ THEME - Clear Blue
|
||||
}
|
||||
.board-color-clearblue#header ul li.current,
|
||||
.board-color-clearblue#header-quick-access ul li.current {
|
||||
border-bottom: 4px solid #00c8eb;
|
||||
border-bottom: 4px solid #007285;
|
||||
}
|
||||
.board-color-clearblue#header-quick-access {
|
||||
color: #fff;
|
||||
@@ -1585,14 +1588,14 @@ THEME - Clear Blue
|
||||
.board-color-clearblue#header-quick-access,
|
||||
.board-color-clearblue .setting-content .content-body .side-menu ul li.active,
|
||||
.board-color-clearblue .setting-content .content-body .side-menu ul li.active:hover {
|
||||
background-color: #00aecc;
|
||||
border-bottom: 1px solid #008ba3;
|
||||
background-color: #00798d;
|
||||
border-bottom: 1px solid #006879;
|
||||
border-top: 1px solid #00687a;
|
||||
}
|
||||
/* clearblue's bar is a colour SLIDE, and the slide has to be on the bar that is
|
||||
actually on screen. It was only ever on `#header #header-main-bar` - the
|
||||
inner element of the SECOND bar - because that is where the theme used to
|
||||
live. The first bar got the flat `background-color: #00aecc` from the rule
|
||||
live. The first bar got the flat `background-color: #00798d` from the rule
|
||||
above instead, which is exactly strongcyan's colour: picking clearblue
|
||||
painted the header strongcyan.
|
||||
`#header-quick-access` has no inner bar of its own, so the slide goes
|
||||
@@ -1601,7 +1604,7 @@ THEME - Clear Blue
|
||||
The All Boards left menu's selected row is named here too. Every other theme
|
||||
paints that row through `--theme-accent`, which is one colour and is exactly
|
||||
right for them; a slide is not a colour, so the variable cannot carry it and
|
||||
clearblue's row came out flat #00aecc while the Admin Panel's row beside it
|
||||
clearblue's row came out flat #00798d while the Admin Panel's row beside it
|
||||
slid. The Admin Panel's row has always been named in this rule; this is the
|
||||
All Boards row joining it. docs/Features/General/Theme/Change-Color.md */
|
||||
.board-color-clearblue#header #header-main-bar,
|
||||
@@ -1610,13 +1613,13 @@ THEME - Clear Blue
|
||||
.board-color-clearblue .setting-content .content-body .side-menu ul li.active:hover,
|
||||
.board-color-clearblue .boards-left-menu .menu-item.active a,
|
||||
.board-color-clearblue .boards-left-menu .menu-item.active a:hover {
|
||||
background: linear-gradient(180deg, #499bea 0%, #00aecc 100%);
|
||||
background: linear-gradient(180deg, #499bea 0%, #00798d 100%);
|
||||
}
|
||||
.board-color-clearblue#header #header-main-bar p {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.board-color-clearblue#header #header-main-bar .board-header-btn.emphasis {
|
||||
background: #00c8eb;
|
||||
background: #007285;
|
||||
}
|
||||
.board-color-clearblue#header #header-main-bar .board-header-btn.emphasis:hover,
|
||||
.board-color-clearblue#header #header-main-bar .board-header-btn.emphasis .board-header-btn-close {
|
||||
@@ -1648,7 +1651,7 @@ THEME - Clear Blue
|
||||
background-color: #197ddc;
|
||||
}
|
||||
.board-color-clearblue.board-wrapper {
|
||||
background: linear-gradient(135deg, #499bea 0%, #00aecc 100%);
|
||||
background: linear-gradient(135deg, #499bea 0%, #00798d 100%);
|
||||
}
|
||||
.board-color-clearblue .swimlane {
|
||||
background: none;
|
||||
@@ -1740,7 +1743,7 @@ THEME - Clear Blue
|
||||
}
|
||||
.board-color-clearblue .quiet,
|
||||
.board-color-clearblue .quiet a {
|
||||
color: rgba(0,0,0,0.4);
|
||||
color: rgba(0,0,0,0.68);
|
||||
}
|
||||
.board-color-clearblue .list-header .list-header-watch-icon {
|
||||
color: rgba(0,0,0,0.5);
|
||||
@@ -1767,7 +1770,7 @@ THEME - Clear Blue
|
||||
}
|
||||
.board-color-clearblue .sidebar-shadow {
|
||||
box-shadow: none;
|
||||
border-inline-start: 9px solid #00aecc;
|
||||
border-inline-start: 9px solid #00798d;
|
||||
}
|
||||
.board-color-clearblue .is-open .sidebar-shadow {
|
||||
box-shadow: -10px 0 8px rgba(0,0,0,0.3);
|
||||
@@ -1833,7 +1836,7 @@ THEME - Clear Blue
|
||||
|
||||
/* Apply scrollbar to sidebar content*/
|
||||
.board-color-clearblue .sidebar .sidebar-content {
|
||||
scrollbar-color: #00aecc #ffffff00;
|
||||
scrollbar-color: #00798d #ffffff00;
|
||||
}
|
||||
|
||||
/* Remove margins in between columns/fix spacing */
|
||||
@@ -1984,10 +1987,10 @@ THEME - Modern
|
||||
.board-list li.board-color-modern,
|
||||
.board-color-modern .setting-content .content-body .side-menu ul li.active,
|
||||
.board-color-modern .setting-content .content-body .side-menu ul li.active:hover {
|
||||
background-color: #2a80b8;
|
||||
background-color: #2372a4;
|
||||
}
|
||||
.board-color-modern .is-selected .minicard {
|
||||
border-inline-start: 3px solid #2a80b8;
|
||||
border-inline-start: 3px solid #2372a4;
|
||||
}
|
||||
.board-color-modern .minicard {
|
||||
border-radius: 7px;
|
||||
@@ -1998,40 +2001,40 @@ THEME - Modern
|
||||
.board-color-modern input[type=submit].primary,
|
||||
.board-color-modern .sidebar .sidebar-content .sidebar-btn,
|
||||
.board-color-modern .selected-calendar-picker button.primary {
|
||||
background-color: #226693;
|
||||
background-color: #1e628d;
|
||||
border-radius: 7px;
|
||||
}
|
||||
.board-color-modern.pop-over .pop-over-list li a:not(.disabled):hover,
|
||||
.board-color-modern .sidebar .sidebar-content .sidebar-btn:hover,
|
||||
.board-color-modern .sidebar-list li a:hover {
|
||||
background-color: #2f90cf;
|
||||
background-color: #216b9a;
|
||||
}
|
||||
.board-color-modern#header ul li.current,
|
||||
.board-color-modern#header-quick-access ul li.current {
|
||||
border-bottom: 2px solid #2f90cf;
|
||||
border-bottom: 2px solid #216b9a;
|
||||
}
|
||||
.board-color-modern#header-quick-access {
|
||||
color: #fff;
|
||||
}
|
||||
.board-color-modern#header #header-main-bar .board-header-btn.emphasis {
|
||||
background: #b8622a;
|
||||
background: #a45b23;
|
||||
}
|
||||
.board-color-modern#header #header-main-bar .board-header-btn.emphasis:hover,
|
||||
.board-color-modern#header #header-main-bar .board-header-btn.emphasis .board-header-btn-close {
|
||||
background: #a65826;
|
||||
background: #975420;
|
||||
}
|
||||
.board-color-modern#header #header-main-bar .board-header-btn.emphasis:hover .board-header-btn-close {
|
||||
background: #934e22;
|
||||
background: #8a4c1d;
|
||||
}
|
||||
.board-color-modern .materialCheckBox.is-checked {
|
||||
border-bottom: 2px solid #2a80b8;
|
||||
border-right: 2px solid #2a80b8;
|
||||
border-bottom: 2px solid #2372a4;
|
||||
border-right: 2px solid #2372a4;
|
||||
}
|
||||
.board-color-modern .checklist-progress-bar {
|
||||
background-color: #d1e7f5 !important;
|
||||
}
|
||||
.board-color-modern .checklist-progress-bar .checklist-progress {
|
||||
background-color: #2a80b8 !important;
|
||||
background-color: #2372a4 !important;
|
||||
}
|
||||
.board-color-modern .is-multiselection-active .multi-selection-checkbox.is-checked + .minicard {
|
||||
background: #e8f3fa;
|
||||
@@ -2040,17 +2043,17 @@ THEME - Modern
|
||||
background: #f8fbfd;
|
||||
}
|
||||
.board-color-modern .toggle-label:after {
|
||||
background-color: #226693;
|
||||
background-color: #1e628d;
|
||||
}
|
||||
.board-color-modern .toggle-switch:checked ~ .toggle-label {
|
||||
background-color: #469cd5;
|
||||
}
|
||||
.board-color-modern .toggle-switch:checked ~ .toggle-label:after {
|
||||
background-color: #226693;
|
||||
background-color: #1e628d;
|
||||
}
|
||||
@media screen and (max-width: 800px) {
|
||||
.board-color-modern.pop-over .header {
|
||||
background: #2a80b8;
|
||||
background: #2372a4;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
@@ -2379,7 +2382,7 @@ THEME - Modern Dark
|
||||
.board-color-moderndark button[type=submit].primary,
|
||||
.board-color-moderndark .board-color-modern input[type=submit].primary,
|
||||
.board-color-moderndark .selected-calendar-picker button.primary {
|
||||
background-color: #777;
|
||||
background-color: #6e6e6e;
|
||||
border-radius: 7px;
|
||||
}
|
||||
.board-color-moderndark .toggle-switch:checked~.toggle-label {
|
||||
@@ -3012,7 +3015,15 @@ THEME - Exodark
|
||||
.pop-over.board-color-exodark {
|
||||
background: #2b2b2b;
|
||||
color: #fff;
|
||||
--wekan-quiet-text: #b4b4b4;
|
||||
/* Card Settings opens in this popup: dark sections for its white text. */
|
||||
--card-settings-section-bg: rgba(255, 255, 255, 0.08);
|
||||
--card-settings-helper-bg: #3a3a46;
|
||||
--card-settings-notch-bg: #2c3340;
|
||||
}
|
||||
/* The popup's own links keep the light popup's black otherwise: black on
|
||||
#2b2b2b was 1.48:1 (#6863). */
|
||||
|
||||
.pop-over.board-color-exodark .header {
|
||||
background: #2b2b2b;
|
||||
color: #fff;
|
||||
@@ -3791,7 +3802,7 @@ THEME - Clean Light
|
||||
font-size: calc(16px * var(--wekan-ui-font-scale, 1));
|
||||
font-weight: 400;
|
||||
line-height: calc(24px * var(--wekan-ui-font-scale, 1));
|
||||
color: rgba(10, 10, 20, 0.5);
|
||||
color: rgba(10, 10, 20, 0.7);
|
||||
}
|
||||
|
||||
.board-color-cleanlight .card-details-header,
|
||||
@@ -4614,7 +4625,7 @@ THEME - Clean Light
|
||||
/* ===============
|
||||
THEME - cleargreen
|
||||
|
||||
A colour SLIDE, like clearblue: #8ad59f down to #4bbf6b.
|
||||
A colour SLIDE, like clearblue: #8ad59f down to #277c48.
|
||||
The base is limegreen's own colour, so the flat theme and the slide theme are
|
||||
the same colour family and a reader picking either gets what they expected.
|
||||
|
||||
@@ -4631,7 +4642,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
.board-backgrounds-list .board-color-cleargreen.background-box,
|
||||
.public-board-row.board-color-cleargreen,
|
||||
.board-list li.board-color-cleargreen {
|
||||
background: linear-gradient(180deg, #8ad59f 0%, #4bbf6b 100%);
|
||||
background: linear-gradient(180deg, #8ad59f 0%, #277c48 100%);
|
||||
}
|
||||
.board-color-cleargreen .is-selected .minicard {
|
||||
border-inline-start: 3px solid #8ad59f;
|
||||
@@ -4658,7 +4669,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
.board-color-cleargreen#header-quick-access,
|
||||
.board-color-cleargreen .setting-content .content-body .side-menu ul li.active,
|
||||
.board-color-cleargreen .setting-content .content-body .side-menu ul li.active:hover {
|
||||
background-color: #4bbf6b;
|
||||
background-color: #277c48;
|
||||
border-bottom: 1px solid #3c9956;
|
||||
border-top: 1px solid #2d7340;
|
||||
}
|
||||
@@ -4669,7 +4680,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
.board-color-cleargreen .setting-content .content-body .side-menu ul li.active:hover,
|
||||
.board-color-cleargreen .boards-left-menu .menu-item.active a,
|
||||
.board-color-cleargreen .boards-left-menu .menu-item.active a:hover {
|
||||
background: linear-gradient(180deg, #8ad59f 0%, #4bbf6b 100%);
|
||||
background: linear-gradient(180deg, #8ad59f 0%, #277c48 100%);
|
||||
}
|
||||
.board-color-cleargreen#header #header-main-bar p {
|
||||
margin-bottom: 6px;
|
||||
@@ -4707,7 +4718,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
background-color: #42a85e;
|
||||
}
|
||||
.board-color-cleargreen.board-wrapper {
|
||||
background: linear-gradient(135deg, #8ad59f 0%, #4bbf6b 100%);
|
||||
background: linear-gradient(135deg, #8ad59f 0%, #277c48 100%);
|
||||
}
|
||||
.board-color-cleargreen .swimlane {
|
||||
background: none;
|
||||
@@ -4799,7 +4810,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
}
|
||||
.board-color-cleargreen .quiet,
|
||||
.board-color-cleargreen .quiet a {
|
||||
color: rgba(0,0,0,0.4);
|
||||
color: rgba(0,0,0,0.68);
|
||||
}
|
||||
.board-color-cleargreen .list-header .list-header-watch-icon {
|
||||
color: rgba(0,0,0,0.5);
|
||||
@@ -4826,7 +4837,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
}
|
||||
.board-color-cleargreen .sidebar-shadow {
|
||||
box-shadow: none;
|
||||
border-inline-start: 9px solid #4bbf6b;
|
||||
border-inline-start: 9px solid #277c48;
|
||||
}
|
||||
.board-color-cleargreen .is-open .sidebar-shadow {
|
||||
box-shadow: -10px 0 8px rgba(0,0,0,0.3);
|
||||
@@ -4891,7 +4902,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
|
||||
/* Apply scrollbar to sidebar content*/
|
||||
.board-color-cleargreen .sidebar .sidebar-content {
|
||||
scrollbar-color: #4bbf6b #ffffff00;
|
||||
scrollbar-color: #277c48 #ffffff00;
|
||||
}
|
||||
|
||||
/* Remove margins in between columns/fix spacing */
|
||||
@@ -4910,7 +4921,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
/* ===============
|
||||
THEME - clearorange
|
||||
|
||||
A colour SLIDE, like clearblue: #efab6f down to #e67e22.
|
||||
A colour SLIDE, like clearblue: #efab6f down to #a75919.
|
||||
The base is pumpkin's own colour, so the flat theme and the slide theme are
|
||||
the same colour family and a reader picking either gets what they expected.
|
||||
|
||||
@@ -4927,7 +4938,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
.board-backgrounds-list .board-color-clearorange.background-box,
|
||||
.public-board-row.board-color-clearorange,
|
||||
.board-list li.board-color-clearorange {
|
||||
background: linear-gradient(180deg, #efab6f 0%, #e67e22 100%);
|
||||
background: linear-gradient(180deg, #efab6f 0%, #a75919 100%);
|
||||
}
|
||||
.board-color-clearorange .is-selected .minicard {
|
||||
border-inline-start: 3px solid #efab6f;
|
||||
@@ -4954,7 +4965,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
.board-color-clearorange#header-quick-access,
|
||||
.board-color-clearorange .setting-content .content-body .side-menu ul li.active,
|
||||
.board-color-clearorange .setting-content .content-body .side-menu ul li.active:hover {
|
||||
background-color: #e67e22;
|
||||
background-color: #a75919;
|
||||
border-bottom: 1px solid #b8651b;
|
||||
border-top: 1px solid #8a4c14;
|
||||
}
|
||||
@@ -4965,7 +4976,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
.board-color-clearorange .setting-content .content-body .side-menu ul li.active:hover,
|
||||
.board-color-clearorange .boards-left-menu .menu-item.active a,
|
||||
.board-color-clearorange .boards-left-menu .menu-item.active a:hover {
|
||||
background: linear-gradient(180deg, #efab6f 0%, #e67e22 100%);
|
||||
background: linear-gradient(180deg, #efab6f 0%, #a75919 100%);
|
||||
}
|
||||
.board-color-clearorange#header #header-main-bar p {
|
||||
margin-bottom: 6px;
|
||||
@@ -5003,7 +5014,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
background-color: #ca6f1e;
|
||||
}
|
||||
.board-color-clearorange.board-wrapper {
|
||||
background: linear-gradient(135deg, #efab6f 0%, #e67e22 100%);
|
||||
background: linear-gradient(135deg, #efab6f 0%, #a75919 100%);
|
||||
}
|
||||
.board-color-clearorange .swimlane {
|
||||
background: none;
|
||||
@@ -5095,7 +5106,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
}
|
||||
.board-color-clearorange .quiet,
|
||||
.board-color-clearorange .quiet a {
|
||||
color: rgba(0,0,0,0.4);
|
||||
color: rgba(0,0,0,0.68);
|
||||
}
|
||||
.board-color-clearorange .list-header .list-header-watch-icon {
|
||||
color: rgba(0,0,0,0.5);
|
||||
@@ -5122,7 +5133,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
}
|
||||
.board-color-clearorange .sidebar-shadow {
|
||||
box-shadow: none;
|
||||
border-inline-start: 9px solid #e67e22;
|
||||
border-inline-start: 9px solid #a75919;
|
||||
}
|
||||
.board-color-clearorange .is-open .sidebar-shadow {
|
||||
box-shadow: -10px 0 8px rgba(0,0,0,0.3);
|
||||
@@ -5187,7 +5198,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
|
||||
/* Apply scrollbar to sidebar content*/
|
||||
.board-color-clearorange .sidebar .sidebar-content {
|
||||
scrollbar-color: #e67e22 #ffffff00;
|
||||
scrollbar-color: #a75919 #ffffff00;
|
||||
}
|
||||
|
||||
/* Remove margins in between columns/fix spacing */
|
||||
@@ -5206,7 +5217,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
/* ===============
|
||||
THEME - clearpink
|
||||
|
||||
A colour SLIDE, like clearblue: #df94b8 down to #cd5a91.
|
||||
A colour SLIDE, like clearblue: #df94b8 down to #bd3a79.
|
||||
The base is moderatepink's own colour, so the flat theme and the slide theme are
|
||||
the same colour family and a reader picking either gets what they expected.
|
||||
|
||||
@@ -5223,7 +5234,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
.board-backgrounds-list .board-color-clearpink.background-box,
|
||||
.public-board-row.board-color-clearpink,
|
||||
.board-list li.board-color-clearpink {
|
||||
background: linear-gradient(180deg, #df94b8 0%, #cd5a91 100%);
|
||||
background: linear-gradient(180deg, #df94b8 0%, #bd3a79 100%);
|
||||
}
|
||||
.board-color-clearpink .is-selected .minicard {
|
||||
border-inline-start: 3px solid #df94b8;
|
||||
@@ -5250,7 +5261,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
.board-color-clearpink#header-quick-access,
|
||||
.board-color-clearpink .setting-content .content-body .side-menu ul li.active,
|
||||
.board-color-clearpink .setting-content .content-body .side-menu ul li.active:hover {
|
||||
background-color: #cd5a91;
|
||||
background-color: #bd3a79;
|
||||
border-bottom: 1px solid #a44874;
|
||||
border-top: 1px solid #7b3657;
|
||||
}
|
||||
@@ -5261,7 +5272,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
.board-color-clearpink .setting-content .content-body .side-menu ul li.active:hover,
|
||||
.board-color-clearpink .boards-left-menu .menu-item.active a,
|
||||
.board-color-clearpink .boards-left-menu .menu-item.active a:hover {
|
||||
background: linear-gradient(180deg, #df94b8 0%, #cd5a91 100%);
|
||||
background: linear-gradient(180deg, #df94b8 0%, #bd3a79 100%);
|
||||
}
|
||||
.board-color-clearpink#header #header-main-bar p {
|
||||
margin-bottom: 6px;
|
||||
@@ -5299,7 +5310,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
background-color: #b44f80;
|
||||
}
|
||||
.board-color-clearpink.board-wrapper {
|
||||
background: linear-gradient(135deg, #df94b8 0%, #cd5a91 100%);
|
||||
background: linear-gradient(135deg, #df94b8 0%, #bd3a79 100%);
|
||||
}
|
||||
.board-color-clearpink .swimlane {
|
||||
background: none;
|
||||
@@ -5391,7 +5402,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
}
|
||||
.board-color-clearpink .quiet,
|
||||
.board-color-clearpink .quiet a {
|
||||
color: rgba(0,0,0,0.4);
|
||||
color: rgba(0,0,0,0.68);
|
||||
}
|
||||
.board-color-clearpink .list-header .list-header-watch-icon {
|
||||
color: rgba(0,0,0,0.5);
|
||||
@@ -5418,7 +5429,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
}
|
||||
.board-color-clearpink .sidebar-shadow {
|
||||
box-shadow: none;
|
||||
border-inline-start: 9px solid #cd5a91;
|
||||
border-inline-start: 9px solid #bd3a79;
|
||||
}
|
||||
.board-color-clearpink .is-open .sidebar-shadow {
|
||||
box-shadow: -10px 0 8px rgba(0,0,0,0.3);
|
||||
@@ -5483,7 +5494,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
|
||||
/* Apply scrollbar to sidebar content*/
|
||||
.board-color-clearpink .sidebar .sidebar-content {
|
||||
scrollbar-color: #cd5a91 #ffffff00;
|
||||
scrollbar-color: #bd3a79 #ffffff00;
|
||||
}
|
||||
|
||||
/* Remove margins in between columns/fix spacing */
|
||||
@@ -5687,7 +5698,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
}
|
||||
.board-color-clearpurple .quiet,
|
||||
.board-color-clearpurple .quiet a {
|
||||
color: rgba(0,0,0,0.4);
|
||||
color: rgba(0,0,0,0.68);
|
||||
}
|
||||
.board-color-clearpurple .list-header .list-header-watch-icon {
|
||||
color: rgba(0,0,0,0.5);
|
||||
@@ -5983,7 +5994,7 @@ docs/Features/General/Theme/Change-Color.md
|
||||
}
|
||||
.board-color-clearred .quiet,
|
||||
.board-color-clearred .quiet a {
|
||||
color: rgba(0,0,0,0.4);
|
||||
color: rgba(0,0,0,0.68);
|
||||
}
|
||||
.board-color-clearred .list-header .list-header-watch-icon {
|
||||
color: rgba(0,0,0,0.5);
|
||||
@@ -6090,3 +6101,98 @@ docs/Features/General/Theme/Change-Color.md
|
||||
}
|
||||
|
||||
/* === END clearred THEME === */
|
||||
|
||||
/* The quiet text colour (#595959 on the light themes' greys, WCAG 1.4.3) is a
|
||||
variable, so the themes whose popups and sidebars are DARK can have a light
|
||||
one: a dark grey on a dark surface was nearly invisible (#6863). cleandark and
|
||||
moderndark are those; dark and exodark keep light popups and sidebars, where
|
||||
the dark grey is right. Popups carry the theme class themselves, so the
|
||||
variable reaches them. */
|
||||
:is(
|
||||
.board-color-cleandark,
|
||||
.board-color-moderndark
|
||||
) {
|
||||
--wekan-quiet-text: #b4b4b4;
|
||||
/* Card Settings: its sections are light grey blocks (cardAddFields.css), and
|
||||
these themes' text on them is white (1.07:1 in cleandark). */
|
||||
--card-settings-section-bg: rgba(255, 255, 255, 0.08);
|
||||
--card-settings-helper-bg: #3a3a46;
|
||||
--card-settings-notch-bg: #2c3340;
|
||||
}
|
||||
/* moderndark's surfaces are lighter greys (#545454): the quiet text needs more. */
|
||||
.board-color-moderndark {
|
||||
--wekan-quiet-text: #dcdcdc;
|
||||
}
|
||||
/* The board sidebar's "Keyboard shortcuts" row is outside .sidebar-content, so
|
||||
the white of its buttons does not reach it, and sidebar.css makes it black:
|
||||
black on these sidebars was 1.34:1. */
|
||||
:is(
|
||||
.board-color-cleandark,
|
||||
.board-color-moderndark
|
||||
) .sidebar .sidebar-shortcuts {
|
||||
color: #fff;
|
||||
}
|
||||
/* moderndark: grey (#808080) on its near-black sidebar was 4.02. */
|
||||
.board-color-moderndark :is(.js-open-board-menu, h3[data-fold], .activity-title) {
|
||||
color: var(--wekan-quiet-text);
|
||||
}
|
||||
/* dark: the swimlane header's text was black on its #2c3e50 bar (1.91:1). */
|
||||
.board-color-dark .swimlane-header {
|
||||
color: #fff;
|
||||
}
|
||||
/* dark and exodark: the board, the opened card and the sidebar are dark surfaces
|
||||
(#222, #2b2b2b, #2c3e50). Their popups differ: exodark's is dark too (above),
|
||||
dark's is light, so the dark theme's popups keep the dark quiet text. */
|
||||
:is(
|
||||
.board-color-dark,
|
||||
.board-color-exodark
|
||||
) :is(.board-canvas, .card-details, .sidebar) {
|
||||
--wekan-quiet-text: #b4b4b4;
|
||||
}
|
||||
/* A "quiet button" (Add custom field, Link fields to another card, ...) is #222 in
|
||||
forms.css, for a light surface; on a dark popup or sidebar it was 1.18:1 (#6863). */
|
||||
:is(
|
||||
.board-color-cleandark,
|
||||
.board-color-moderndark
|
||||
) .quiet-button,
|
||||
:is(
|
||||
.board-color-cleandark,
|
||||
.board-color-moderndark,
|
||||
.board-color-exodark
|
||||
).pop-over .quiet-button {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
/* The board sidebar of the dark themes: what is not a button or a heading
|
||||
(a form label, the list of swimlanes' lists and cards) kept the light
|
||||
sidebar's black: 1.34:1 on cleandark's #23232b (#6863). */
|
||||
:is(
|
||||
.board-color-cleandark,
|
||||
.board-color-moderndark
|
||||
) .sidebar .sidebar-content :is(label, .minilists, .minicards) {
|
||||
color: #fff;
|
||||
}
|
||||
/* Popup items and sidebar lists: popup.css forces `.pop-over-list li > a` to black
|
||||
with !important, which is right on a light surface. moderndark's popups and
|
||||
sidebar and exodark's popups are dark: black on #454545 was 2.19:1, on #2b2b2b
|
||||
1.48:1 (#6863). */
|
||||
:is(
|
||||
.board-color-moderndark,
|
||||
.board-color-exodark
|
||||
).pop-over .pop-over-list li > a,
|
||||
:is(
|
||||
.board-color-moderndark,
|
||||
.board-color-exodark
|
||||
).pop-over .pop-over-list li > a *,
|
||||
.board-color-moderndark .sidebar .pop-over-list li > a,
|
||||
.board-color-moderndark .sidebar .pop-over-list li > a * {
|
||||
color: #fff !important;
|
||||
}
|
||||
/* moderndark: the sidebar's headings were #808080 on #222 (4.02:1). */
|
||||
.board-color-moderndark .sidebar .sidebar-content h3 {
|
||||
color: var(--wekan-quiet-text);
|
||||
}
|
||||
/* moderndark: the selected minicard's title was #ccc on #666 (3.57:1). */
|
||||
.board-color-moderndark .is-selected > .minicard .minicard-title,
|
||||
.board-color-moderndark .is-selected > .minicard .minicard-title * {
|
||||
color: #fff;
|
||||
}
|
||||
@@ -173,7 +173,7 @@
|
||||
crossed it - which is the bug that had to be fixed in the Admin Panel's. */
|
||||
.boards-left-menu .menu-item.active a,
|
||||
.boards-left-menu .menu-item.active a:hover {
|
||||
background: var(--theme-accent, #2980b9);
|
||||
background: var(--theme-accent, #2573a7);
|
||||
}
|
||||
/* The Admin Panel puts the selected background on the LI, not the anchor, so
|
||||
the fill reaches the row's full width. Here the anchor is what fills, so it
|
||||
@@ -215,7 +215,7 @@
|
||||
docs/Features/Pages/Starred.md */
|
||||
.board-list .board-list-item-bookmark .board-list-item {
|
||||
border: 4px solid #fff;
|
||||
background: var(--theme-accent, #2980b9);
|
||||
background: var(--theme-accent, #2573a7);
|
||||
color: #fff;
|
||||
/* Room at the end for the unstar button, which sits over the tile. */
|
||||
padding-inline-end: 34px;
|
||||
@@ -481,7 +481,7 @@
|
||||
content: '';
|
||||
display: block;
|
||||
height: 30px;
|
||||
border: 2px dashed var(--theme-accent, #2980b9);
|
||||
border: 2px dashed var(--theme-accent, #2573a7);
|
||||
border-radius: 4px;
|
||||
background: rgba(41, 128, 185, 0.08);
|
||||
margin: 2px 0;
|
||||
@@ -694,7 +694,7 @@
|
||||
as the pointer crossed it. */
|
||||
.workspace-node.active > .workspace-node-content .js-select-space,
|
||||
.workspace-node.active > .workspace-node-content:hover .js-select-space {
|
||||
background: var(--theme-accent, #2980b9);
|
||||
background: var(--theme-accent, #2573a7);
|
||||
}
|
||||
.workspace-node.active > .workspace-node-content .js-select-space,
|
||||
.workspace-node.active > .workspace-node-content .js-select-space i {
|
||||
@@ -1046,7 +1046,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #999; /* Darker background for better text contrast */
|
||||
background-color: #767676; /* Grey with white text: 4.54, above the 4.5 of WCAG 1.4.3 (#999 gave 2.84) */
|
||||
border-radius: 3px;
|
||||
/* Same total vertical padding as .board-list-item (42px -> same tile height),
|
||||
but shifted up (top 24->18, bottom 18->24) so the "+ Add Board" text sits
|
||||
@@ -1661,7 +1661,7 @@
|
||||
|
||||
.js-board {
|
||||
display: block;
|
||||
background-color: #999; /* Default gray background if no color class is applied */
|
||||
background-color: #767676; /* Default gray background if no color class is applied */
|
||||
border-radius: 3px; /* Rounded corners for board items */
|
||||
overflow: hidden; /* Ensure children respect rounded corners */
|
||||
margin: 0; /* grid gap spaces the tiles now (#6523) */
|
||||
@@ -1724,7 +1724,7 @@
|
||||
chose, not a colour, and painting over it would be losing something rather
|
||||
than calming it down. */
|
||||
body.has-theme-board-tiles .board-list > li.js-board {
|
||||
background: var(--theme-accent-fill, var(--theme-accent, #2980b9));
|
||||
background: var(--theme-accent-fill, var(--theme-accent, #2573a7));
|
||||
}
|
||||
body.has-theme-board-tiles .board-list > li.js-board .board-list-item:not(.has-background-image) {
|
||||
background-color: transparent;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { ReactiveVar } from 'meteor/reactive-var';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import { ensureContrast } from '/models/lib/contrastColor';
|
||||
import { surfacesFor } from '/models/lib/uiFonts';
|
||||
import { activeThemeName } from '/client/lib/activeTheme';
|
||||
import {
|
||||
DEPENDENCY_ICON_CHOICES,
|
||||
DEPENDENCY_TYPES,
|
||||
@@ -138,13 +141,17 @@ Template.dependencyOverlay.onCreated(function () {
|
||||
const y2 = dependent.y + dependent.h / 2;
|
||||
const dx = Math.max(40, Math.abs(x2 - x1) / 2);
|
||||
const path = `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
|
||||
const markerId = markerIdForColor(dep.color);
|
||||
// The colour is the member's free choice (a colour wheel), drawn over the
|
||||
// board: made visible against the theme's surface - a line is a graphic
|
||||
// that says something, 3:1 (WCAG 1.4.11). The stored colour is untouched.
|
||||
const drawColor = ensureContrast(dep.color, surfacesFor(activeThemeName()), 3);
|
||||
const markerId = markerIdForColor(drawColor);
|
||||
if (meta.directed) {
|
||||
markerColors.add(dep.color);
|
||||
markerColors.add(drawColor);
|
||||
}
|
||||
lines.push({
|
||||
path,
|
||||
color: dep.color,
|
||||
color: drawColor,
|
||||
type: dep.type,
|
||||
icon: dep.icon,
|
||||
directed: meta.directed,
|
||||
|
||||
@@ -23,9 +23,9 @@
|
||||
* so the row keeps the table's white background: with the light text below that
|
||||
* is white on white, and only the row's emoji was visible. The All Boards tile
|
||||
* has the same problem and solves it the same way — `.board-list-item` falls
|
||||
* back to a grey (#999) so its white text always has something to sit on. */
|
||||
* back to a grey (#767676) so its white text always has something to sit on. */
|
||||
.public-board-row {
|
||||
background-color: #999;
|
||||
background-color: #767676;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
cursor: pointer;
|
||||
|
||||
@@ -87,7 +87,7 @@ template(name="attachmentGallery")
|
||||
.attachment-gallery
|
||||
|
||||
//- #6796: a small "+" like every other section's, not a full-width box.
|
||||
if canModifyCard
|
||||
if canAddAttachment
|
||||
a.card-label.add-label.add-attachment.js-add-attachment(title="{{_ 'add-attachment'}}" aria-label="{{_ 'add-attachment'}}")
|
||||
i.fa.fa-plus
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { attachmentMigrationManager } from '/client/lib/attachmentMigrationManag
|
||||
import Attachments from '/models/attachments';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import { buildAttachmentUploadConfig } from '/client/lib/attachmentUploadConfig';
|
||||
import { attachmentUploadsBlocked } from '/client/lib/attachmentUploadPolicy';
|
||||
const { cleanFileName } = require('/imports/lib/fileNameDisplay');
|
||||
const { attachmentTileCompact } = require('/models/lib/attachmentTile.cjs');
|
||||
import { formatDateTime } from '/imports/lib/dateUtils';
|
||||
@@ -338,8 +339,8 @@ function openPrevAttachment() {
|
||||
|
||||
function processTouch(){
|
||||
|
||||
xDist = touchEndCoords.x - touchStartCoords.x;
|
||||
yDist = touchEndCoords.y - touchStartCoords.y;
|
||||
const xDist = touchEndCoords.x - touchStartCoords.x;
|
||||
const yDist = touchEndCoords.y - touchStartCoords.y;
|
||||
|
||||
// Left swipe
|
||||
if (Math.abs(xDist) > Math.abs(yDist) && xDist < 0) {
|
||||
@@ -467,6 +468,10 @@ Template.attachmentGallery.onCreated(function () {
|
||||
});
|
||||
|
||||
Template.attachmentGallery.helpers({
|
||||
// #6870: the "+" button - a card the user may edit, unless uploads are blocked site-wide
|
||||
canAddAttachment() {
|
||||
return Utils.canModifyCard() && !attachmentUploadsBlocked();
|
||||
},
|
||||
// #6796: the small file button instead of the preview tile
|
||||
// (models/lib/attachmentTile.cjs).
|
||||
attachmentTileCompact(file) {
|
||||
@@ -672,6 +677,9 @@ export async function handleFileUpload(card, files) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// #6870: the administrator blocked uploads for the whole site; the server refuses them too
|
||||
if (attachmentUploadsBlocked()) return [];
|
||||
|
||||
// Check if board allows attachments
|
||||
const board = card.board();
|
||||
if (!board || !board.allowsAttachments) {
|
||||
|
||||
@@ -37,12 +37,12 @@
|
||||
}
|
||||
.card-settings-pane-collapse {
|
||||
padding: 2px 4px;
|
||||
color: #808080;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.card-settings-pane-collapse:hover,
|
||||
.card-settings-pane-collapse:focus-visible { color: #2980b9; }
|
||||
.card-settings-pane-collapse:focus-visible { color: #2573a7; }
|
||||
.card-settings-pane-title {
|
||||
margin: 0;
|
||||
font-size: calc(17px * var(--wekan-ui-font-scale, 1));
|
||||
@@ -113,7 +113,9 @@ body.card-settings-splitting-active {
|
||||
|
||||
/* A section: the checklist's grey block, its title line and its items. */
|
||||
.card-settings-inline-section {
|
||||
background: #f7f7f7;
|
||||
/* The checklist's grey block; a dark theme has its own (#6863: white text on
|
||||
this light grey was 1.07:1 in cleandark). */
|
||||
background: var(--card-settings-section-bg, #f7f7f7);
|
||||
border-radius: 3px;
|
||||
margin-block-end: 8px;
|
||||
padding: 4px 8px 6px;
|
||||
@@ -139,19 +141,19 @@ body.card-settings-splitting-active {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.card-settings-inline-section-title.js-card-settings-section-handle { cursor: grab; }
|
||||
.card-settings-inline-section-count { color: #808080; white-space: nowrap; }
|
||||
.card-settings-inline-section-count { color: var(--wekan-quiet-text, #595959); white-space: nowrap; }
|
||||
.card-settings-inline-collapse,
|
||||
.card-settings-inline-section-move,
|
||||
.card-settings-inline-menu {
|
||||
padding: 2px 4px;
|
||||
color: #808080;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.card-settings-inline-collapse:hover,
|
||||
.card-settings-inline-collapse:focus-visible,
|
||||
.card-settings-inline-menu:hover,
|
||||
.card-settings-inline-menu:focus-visible { color: #2980b9; }
|
||||
.card-settings-inline-menu:focus-visible { color: #2573a7; }
|
||||
/* The section's own checkbox, left of its title: the rows' look. A section
|
||||
hidden whole on this side keeps its rows' ticks, drawn faded. */
|
||||
.card-settings-section-side { flex: 0 0 auto; }
|
||||
@@ -171,11 +173,11 @@ body.card-settings-splitting-active {
|
||||
min-width: 28px;
|
||||
min-height: 28px;
|
||||
border-radius: 3px;
|
||||
color: #808080;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
text-decoration: none;
|
||||
}
|
||||
.card-settings-add-custom-field-button:hover,
|
||||
.card-settings-add-custom-field-button:focus-visible { background: rgba(0, 0, 0, 0.06); color: #2980b9; }
|
||||
.card-settings-add-custom-field-button:focus-visible { background: rgba(0, 0, 0, 0.06); color: #2573a7; }
|
||||
/* Collapsed: the items hidden, not removed, so their drag stays bound. */
|
||||
.card-settings-inline-section.is-collapsed .card-settings-inline-items,
|
||||
.card-settings-inline-section.is-collapsed .card-settings-add-custom-field { display: none; }
|
||||
@@ -189,7 +191,7 @@ body.card-settings-splitting-active {
|
||||
border-bottom: 0;
|
||||
padding-inline-start: 22px;
|
||||
}
|
||||
.card-settings-inline .card-settings-line.ui-sortable-helper { background: #fff; }
|
||||
.card-settings-inline .card-settings-line.ui-sortable-helper { background: var(--card-settings-helper-bg, #fff); }
|
||||
.card-settings-inline .materialCheckBox { transition: none; }
|
||||
.card-settings-inline .card-field-order-toggle.is-animating .materialCheckBox { transition: 0.2s; }
|
||||
/* The hamburgers' checkboxes, likewise: only the one just clicked animates. */
|
||||
@@ -232,7 +234,7 @@ body.card-settings-splitting-active {
|
||||
.card-settings-inline-menu.is-open,
|
||||
.card-settings-add-custom-field-button.is-open {
|
||||
background: rgba(41, 128, 185, 0.15);
|
||||
color: #2980b9;
|
||||
color: #2573a7;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.card-settings-inline-panel {
|
||||
@@ -257,7 +259,7 @@ body.card-settings-splitting-active {
|
||||
border-inline-start: 1px solid rgba(41, 128, 185, 0.45);
|
||||
border-block-start: 1px solid rgba(41, 128, 185, 0.45);
|
||||
transform: rotate(45deg);
|
||||
background-color: #eef4f9;
|
||||
background-color: var(--card-settings-notch-bg, #eef4f9);
|
||||
}
|
||||
/* The + is at the start of its line: the notch is under it there. */
|
||||
.card-settings-inline-panel-create::before {
|
||||
@@ -270,11 +272,11 @@ body.card-settings-splitting-active {
|
||||
position: absolute;
|
||||
inset-block-start: 4px;
|
||||
inset-inline-end: 6px;
|
||||
color: #808080;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
padding: 2px 4px;
|
||||
}
|
||||
.card-settings-inline-panel-close:hover,
|
||||
.card-settings-inline-panel-close:focus-visible { color: #2980b9; }
|
||||
.card-settings-inline-panel-close:focus-visible { color: #2573a7; }
|
||||
.card-settings-section-action-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
background-color: #fff;
|
||||
border: 0;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.23);
|
||||
color: #8c8c8c;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
height: 36px;
|
||||
margin: 4px 4px 6px 0;
|
||||
width: 92%;
|
||||
|
||||
@@ -3,5 +3,5 @@ template(name="descriptionForm")
|
||||
.new-description.js-new-description(
|
||||
class="{{#if descriptionFormIsOpen}}is-open{{/if}}")
|
||||
form.js-new-description-form
|
||||
+editor(class="js-new-description-input" autofocus="autofocus")
|
||||
+editor(class="js-new-description-input" autofocus="autofocus" labelKey="description")
|
||||
| {{getUnsavedValue 'cardDescription' _id getDescription}}
|
||||
@@ -185,6 +185,23 @@ body.desktop-mode .card-details:not(.card-details-popup):not(.card-details-colla
|
||||
bottom: 8px;
|
||||
}
|
||||
|
||||
/* A phone in desktop mode: the header bar wraps to several rows (up to five with
|
||||
the board controls open), far taller than the 8px the floating window docks
|
||||
at, so the card's own top row (caret, number, menu, X) sat among the header's
|
||||
rows and was only seen by dragging the card down. Below 801px the card starts
|
||||
BELOW the header, at the height Utils publishes as --wekan-header-height.
|
||||
Only a card WITHOUT an inline top matches (`:not([style*="top"])`), so the
|
||||
place a finger dragged it to is an inline top and is kept when the finger is
|
||||
lifted - this rule never competes with it. */
|
||||
@media screen and (max-width: 800px) {
|
||||
body.desktop-mode .card-details:not(.card-details-popup):not([style*="top"]),
|
||||
/* a card opened as a popup (search result, table, Roadmap bar) docks the same way */
|
||||
body.desktop-mode .card-details.card-details-popup:not([style*="top"]),
|
||||
body.desktop-mode .card-details.card-details-maximized:not(.card-details-popup):not(.card-details-collapsed) {
|
||||
top: calc(var(--wekan-header-height, 0px) + 8px) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* #4822: the MAXIMIZED card must be anchored to the VIEWPORT (position: fixed
|
||||
with viewport insets), never laid out inside — or measured from — the
|
||||
scrolled swimlane/board container. Historically the maximized pane was an
|
||||
@@ -250,6 +267,24 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
color: #b3b3b3;
|
||||
display: inline-block;
|
||||
margin-inline-end: 6px;
|
||||
/* Above the title's edit zone (z-index 1, over the whole title): a press on the
|
||||
card number moves the window like the rest of the header row; only the title
|
||||
itself edits. */
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* The areas that move the window by a finger. Without touch-action: none the browser
|
||||
takes a finger drag for a scroll and cancels the pointer, so the window never moves
|
||||
(the page behind it scrolls instead). Only in desktop mode, where the card is a
|
||||
window; the full-screen card of mobile mode is not moved. */
|
||||
body.desktop-mode .card-details .card-details-header,
|
||||
body.desktop-mode .card-details .card-details-header .card-drag-handle {
|
||||
touch-action: none;
|
||||
}
|
||||
body.card-window-dragging {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
/* Collapse toggle triangle */
|
||||
@@ -366,43 +401,43 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* The title is two things at once, split like a minicard's: the LEADING half
|
||||
opens the editor, the trailing half drags the window. With drag handles OFF
|
||||
there is no handle, so the title bar is what the window is moved by - and a
|
||||
heading that is entirely an edit target leaves nothing to take hold of.
|
||||
/* #6839: the whole title edits - every word of it and the space beside a
|
||||
short last line, which is what anybody clicks to change the title. The window
|
||||
is moved from the empty space ABOVE the title, in the row of the collapse
|
||||
caret and the menu (cardDetails.js), or by the drag handle when drag handles
|
||||
are on. It was split like a minicard's - the trailing half dragged - and a
|
||||
click on the right of the title did nothing anybody expected.
|
||||
|
||||
An overlay rather than a split of the text, because the text is a `.viewer`
|
||||
and cutting it in two would cut the sentence in two. `inset-inline-start`, so
|
||||
the edit half is the left in English and the right in Arabic from one rule.
|
||||
The drag handler steps aside for this zone (cardDetails.js). */
|
||||
An overlay rather than a link around the text, because the text is a
|
||||
`.viewer` with its own links. */
|
||||
.card-details .card-details-header .card-details-title .card-details-title-edit-zone {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
inset-inline-start: 0;
|
||||
/* Half of the heading - and the heading is stretched to the row below, so
|
||||
this is half of the BAR however long the title happens to be. */
|
||||
width: 50%;
|
||||
width: 100%;
|
||||
cursor: text;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* The heading fills the row rather than shrinking to its text, so "half the
|
||||
title" and "half the bar" are the same half however long the title is. A
|
||||
heading only as wide as its words leaves the empty space beside it belonging
|
||||
to nothing - and that space is where anybody would grab to move the window.
|
||||
It stays inline-block so the complete-toggle beside it keeps its place; the
|
||||
buttons floating at the trailing edge take their room out of this width. */
|
||||
/* The heading fills the row rather than shrinking to its text, so the edit
|
||||
zone covers the space beside a short title too. It stays inline-block so
|
||||
the complete-toggle beside it keeps its place. */
|
||||
.card-details .card-details-header .card-details-title {
|
||||
min-width: 60%;
|
||||
}
|
||||
|
||||
/* With drag handles ON the handle is the only drag source, so the whole title
|
||||
is the edit target - the same rule the minicard follows, from the same
|
||||
question: `card-details-with-handle` is set by the template from
|
||||
isTouchScreenOrShowDesktopDragHandles, which is Utils.showDragHandles(). */
|
||||
.card-details.card-details-with-handle .card-details-header .card-details-title .card-details-title-edit-zone {
|
||||
width: 100%;
|
||||
/* The empty part of the header row is what moves the window when there is no
|
||||
drag handle; with one, the handle alone does. */
|
||||
.card-details:not(.card-details-with-handle) .card-details-header {
|
||||
cursor: move;
|
||||
}
|
||||
.card-details .card-details-header .card-details-title,
|
||||
.card-details .card-details-header a {
|
||||
cursor: auto;
|
||||
}
|
||||
.card-details .card-details-header a {
|
||||
cursor: pointer;
|
||||
}
|
||||
.card-details .card-details-header .linked-card-location {
|
||||
font-style: italic;
|
||||
@@ -536,6 +571,19 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
resize: both;
|
||||
}
|
||||
|
||||
/* A card opened as a POPUP (Session popupCardId: from a search result, a table
|
||||
or a Roadmap/Gantt bar) docks at the very top of the window like every other
|
||||
opened card, and covers the header bars, as the .pop-over[data-popup=
|
||||
"cardDetailsPopup"] rule near the end of this file says it must. The 97px
|
||||
above is the room the header takes for a card that is NOT docked by the
|
||||
desktop-mode rules (those skip popups), and it opened the popup BELOW the
|
||||
header: collapsed, it was a bar floating over the swimlane. Mobile mode keeps
|
||||
its own full-window rule (top: 0 !important). */
|
||||
.card-details.card-details-popup {
|
||||
top: 8px;
|
||||
z-index: 2001;
|
||||
}
|
||||
|
||||
/* Override for mobile mode even on larger screens */
|
||||
body.mobile-mode .card-details {
|
||||
width: 100vw !important;
|
||||
@@ -1096,7 +1144,7 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
|
||||
}
|
||||
.card-details-item-location .card-location-remove {
|
||||
margin-inline-start: 8px;
|
||||
color: #8c8c8c;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
cursor: pointer;
|
||||
}
|
||||
.card-details-item-location .card-location-remove:hover {
|
||||
@@ -1212,8 +1260,8 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
|
||||
and Activities - has one rule above it and a caret in its heading that
|
||||
collapses it. The rule comes from the same template as the caret, so eleven
|
||||
sections cannot end up with ten separators.
|
||||
The heading is a button: it says so to a screen reader (role/tabindex in the
|
||||
template) and it looks like one to a mouse. */
|
||||
The heading holds a real button (cardSectionHeader in the template) that a
|
||||
screen reader and the keyboard reach, and it looks like one to a mouse. */
|
||||
/* The GROUP is the full-width row of the card, and the fields inside it share
|
||||
rows within it - which is what puts Assignee and Creator beside Members
|
||||
rather than under it. The class that made an individual field full width is
|
||||
@@ -1248,11 +1296,32 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
|
||||
changes length, so it gets a fixed box. `text-align: center` rather than a
|
||||
side, because the glyph itself differs between the open and closed states
|
||||
(down, and right or left) and a fixed side would make it jump. */
|
||||
.card-details h3.card-details-item-title.js-toggle-card-section > .fa:first-child {
|
||||
.card-details h3.card-details-item-title.js-toggle-card-section .card-details-section-toggle > .fa:first-child {
|
||||
width: 12px;
|
||||
text-align: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
/* The button inside the heading looks like the heading: no button chrome, the
|
||||
heading's own size, weight and colour, the caret, icon and label in a row. */
|
||||
.card-details h3.card-details-item-title .card-details-section-toggle {
|
||||
align-items: center;
|
||||
background: none;
|
||||
border: 0;
|
||||
border-radius: 3px;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
font: inherit;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
text-align: start;
|
||||
width: auto;
|
||||
}
|
||||
.card-details h3.card-details-item-title .card-details-section-toggle:focus-visible {
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.card-details h3.card-details-item-title.js-toggle-card-section:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
@@ -49,10 +49,9 @@ template(name="cardDetails")
|
||||
a.card-details-menu.js-open-card-details-menu(title="{{_ 'cardDetailsActionsPopup-title'}}")
|
||||
i.fa.fa-bars
|
||||
//- The handle appears only when drag handles are ON, like the
|
||||
minicard's. With them off the window is dragged by its own title bar
|
||||
- the trailing half of the title - so a handle would be a second way
|
||||
to do what the bar already does; with them on it is the ONLY way, and
|
||||
the title is all edit target.
|
||||
minicard's. With them off the window is dragged by the empty space
|
||||
of its header row, so a handle would be a second way to do what the
|
||||
bar already does; with them on it is the ONLY way.
|
||||
client/components/cards/minicard.jade
|
||||
if canModifyCard
|
||||
if isTouchScreenOrShowDesktopDragHandles
|
||||
@@ -80,12 +79,12 @@ template(name="cardDetails")
|
||||
if currentBoard.allowsDueComplete
|
||||
a.card-details-complete-toggle.js-toggle-due-complete(title="{{#if getDueComplete}}{{_ 'card-mark-incomplete'}}{{else}}{{_ 'card-mark-complete'}}{{/if}}")
|
||||
.materialCheckBox(class="{{#if getDueComplete}}is-checked{{/if}}")
|
||||
//- The title is BOTH: the leading half opens the editor and the
|
||||
trailing half drags the window, exactly as a minicard's title splits
|
||||
between editing and dragging the card. `js-card-title-drag-handle`
|
||||
stays on the whole heading - the drag handler is what steps aside for
|
||||
the edit zone - so a reader who may not write can still drag the
|
||||
window by any part of it, which is what that class was for.
|
||||
//- #6839: the whole title opens the editor. The window is moved from
|
||||
the empty space of the header row above it, or by the drag handle
|
||||
when drag handles are on. `js-card-title-drag-handle` stays on the
|
||||
whole header - the drag handler is what steps aside for the edit zone
|
||||
- so a reader who may not write, who has no edit zone, can still drag
|
||||
the window by the title.
|
||||
if showLabelsAboveTitleOnCard
|
||||
.card-details-labels-above-title
|
||||
+minicardLabels(show=labelsShownAboveTitle labels=labels hideText=hiddenCardLabelText)
|
||||
@@ -1056,15 +1055,22 @@ template(name="cardSectionHeader")
|
||||
sits under a rule of the layout's own.
|
||||
unless noRule
|
||||
hr.card-details-section-rule
|
||||
h3.card-details-item-title.js-toggle-card-section(data-section="{{section}}" role="button" tabindex="0")
|
||||
i.fa(class="{{sectionCaret section}}")
|
||||
if icon
|
||||
i.fa(class="{{icon}}")
|
||||
| {{_ label}}
|
||||
//- An optional count after the name - "Attachments (3)" - for a section
|
||||
whose size is worth knowing before it is opened.
|
||||
if count
|
||||
| ({{count}})
|
||||
//- A real heading with a real button in it (the WAI disclosure pattern): the h3
|
||||
keeps the outline, the button is what the keyboard and a screen reader reach
|
||||
and says whether the section is open. The section menu and the layout toggle
|
||||
are beside the button, not inside it - a button must not contain controls
|
||||
(WCAG 4.1.2; axe nested-interactive and aria-allowed-role). A click anywhere
|
||||
on the heading still folds the section: the handler is on the h3.
|
||||
h3.card-details-item-title.js-toggle-card-section(data-section="{{section}}")
|
||||
button.card-details-section-toggle(type="button" aria-expanded="{{#if isSectionOpen section}}true{{else}}false{{/if}}")
|
||||
i.fa(class="{{sectionCaret section}}")
|
||||
if icon
|
||||
i.fa(class="{{icon}}")
|
||||
| {{_ label}}
|
||||
//- An optional count after the name - "Attachments (3)" - for a section
|
||||
whose size is worth knowing before it is opened.
|
||||
if count
|
||||
| ({{count}})
|
||||
//- Custom Fields puts its layout selector on the heading row, between the
|
||||
title and hamburger. A collapsed section hides actions for content that
|
||||
is not visible, so the selector only exists while this section is open.
|
||||
|
||||
@@ -38,26 +38,7 @@ import {
|
||||
datePickerRendered,
|
||||
datePickerHelpers,
|
||||
} from '/client/lib/datepicker';
|
||||
import {
|
||||
formatDateTime,
|
||||
formatDate,
|
||||
formatTime,
|
||||
getISOWeek,
|
||||
isValidDate,
|
||||
isBefore,
|
||||
isAfter,
|
||||
isSame,
|
||||
add,
|
||||
subtract,
|
||||
startOf,
|
||||
endOf,
|
||||
format,
|
||||
parseDate,
|
||||
now,
|
||||
createDate,
|
||||
fromNow,
|
||||
calendar
|
||||
} from '/imports/lib/dateUtils';
|
||||
import { formatDateTime, add, now } from '/imports/lib/dateUtils';
|
||||
import Cards from '/models/cards';
|
||||
import Boards from '/models/boards';
|
||||
import Checklists from '/models/checklists';
|
||||
@@ -100,6 +81,7 @@ const editingLocationId = new ReactiveVar(null);
|
||||
// Shared with Admin Panel, which links the places accounts log in from the same
|
||||
// way - models/lib/mapLink.js.
|
||||
const { mapLinkFor, GCJ } = require('/models/lib/mapLink');
|
||||
const { cardHeaderPress, dragThreshold, passedThreshold, clampWindow } = require('/models/lib/windowDrag');
|
||||
|
||||
|
||||
|
||||
@@ -701,9 +683,12 @@ Template.cardDetails.onDestroyed(function () {
|
||||
|
||||
// #6686: see the comment in onCreated above. Only close a popup that is
|
||||
// still showing THIS card's data at the base of its stack - a popup opened
|
||||
// for an unrelated card/context must not be touched.
|
||||
// for an unrelated card/context must not be touched. #6840: and only when
|
||||
// this card is no longer the open one - closed, or another card opened. A
|
||||
// card whose details are merely rendered again is still open, and its popup
|
||||
// (the Due date one `d` just opened) must not flash shut.
|
||||
const openedCardId = this.openedCardId;
|
||||
if (openedCardId && Popup.isOpen()) {
|
||||
if (openedCardId && Popup.isOpen() && Utils.getCurrentCardId() !== openedCardId) {
|
||||
const baseStackEntry = Popup._stack?.[0];
|
||||
if (baseStackEntry?.dataContext?._id === openedCardId) {
|
||||
Popup.close();
|
||||
@@ -1206,18 +1191,68 @@ Template.cardSectionHeader.events({
|
||||
if (!section) return;
|
||||
cardSectionState.set(section, !isCardSectionOpen(section));
|
||||
},
|
||||
// A heading that can be operated with the mouse can be operated with the
|
||||
// keyboard: it carries role="button" and tabindex, so Enter and Space are
|
||||
// what a screen reader user presses.
|
||||
'keydown .js-toggle-card-section'(event) {
|
||||
if (event.target !== event.currentTarget) return;
|
||||
if (event.key !== 'Enter' && event.key !== ' ') return;
|
||||
event.preventDefault();
|
||||
const section = event.currentTarget.dataset.section;
|
||||
if (section) cardSectionState.set(section, !isCardSectionOpen(section));
|
||||
},
|
||||
});
|
||||
|
||||
// Moves the card window under a finger or a mouse. `press` is cardHeaderPress's answer.
|
||||
// A press on a control waits until the pointer has really moved, so a tap still
|
||||
// collapses, closes or opens the menu; once it moved, the click that follows the drag
|
||||
// is swallowed.
|
||||
function beginCardWindowDrag(jqEvent, press) {
|
||||
// Blaze hands over a jQuery event, which does not copy `isPrimary`; the browser's
|
||||
// own event has it.
|
||||
const event = jqEvent.originalEvent || jqEvent;
|
||||
if (!press.start || !event.isPrimary) return;
|
||||
if (event.pointerType === 'mouse' && event.button !== 0) return;
|
||||
|
||||
const $card = $(event.target).closest('.card-details');
|
||||
if (!$card.length) return;
|
||||
|
||||
const startX = event.clientX;
|
||||
const startY = event.clientY;
|
||||
const offset = $card.offset();
|
||||
const startLeft = offset.left - window.scrollX;
|
||||
const startTop = offset.top - window.scrollY;
|
||||
const threshold = dragThreshold(event.pointerType, press.deferred);
|
||||
const pointerId = event.pointerId;
|
||||
let moving = false;
|
||||
|
||||
const onMove = (e) => {
|
||||
if (e.pointerId !== pointerId) return;
|
||||
const dx = e.clientX - startX;
|
||||
const dy = e.clientY - startY;
|
||||
if (!moving) {
|
||||
if (!passedThreshold(dx, dy, threshold)) return;
|
||||
moving = true;
|
||||
// #6465: where the user put it wins over where the minicard is, so a later
|
||||
// resize re-places every other window but only clamps this one into view.
|
||||
markCardDetailsUserMoved($card);
|
||||
document.body.classList.add('card-window-dragging');
|
||||
}
|
||||
e.preventDefault();
|
||||
const placed = clampWindow(startLeft + dx, startTop + dy, $card.outerWidth(),
|
||||
{ width: window.innerWidth, height: window.innerHeight });
|
||||
$card.css({ left: `${placed.left}px`, top: `${placed.top}px` });
|
||||
};
|
||||
|
||||
const onEnd = (e) => {
|
||||
if (e.pointerId !== pointerId) return;
|
||||
document.removeEventListener('pointermove', onMove);
|
||||
document.removeEventListener('pointerup', onEnd);
|
||||
document.removeEventListener('pointercancel', onEnd);
|
||||
document.body.classList.remove('card-window-dragging');
|
||||
if (moving && press.deferred) {
|
||||
// The drag started on a link or a button: its click is not a tap.
|
||||
const swallow = (click) => { click.preventDefault(); click.stopPropagation(); };
|
||||
document.addEventListener('click', swallow, { capture: true, once: true });
|
||||
setTimeout(() => document.removeEventListener('click', swallow, { capture: true }), 400);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('pointermove', onMove, { passive: false });
|
||||
document.addEventListener('pointerup', onEnd);
|
||||
document.addEventListener('pointercancel', onEnd);
|
||||
}
|
||||
|
||||
Template.cardDetails.events({
|
||||
// #1591 folded each FIELD by its own title, storing a class per card in the
|
||||
// profile. The section carets replaced it: one handle per section, on the
|
||||
@@ -1247,85 +1282,31 @@ Template.cardDetails.events({
|
||||
Users.setPublicCardCollapsed(!currentState);
|
||||
}
|
||||
},
|
||||
'mousedown .js-card-drag-handle'(event) {
|
||||
event.preventDefault();
|
||||
const $card = $(event.target).closest('.card-details');
|
||||
// #6465: where the user put it wins over where the minicard is, so a later
|
||||
// resize re-places every other window but only clamps this one into view.
|
||||
markCardDetailsUserMoved($card);
|
||||
const startX = event.clientX;
|
||||
const startY = event.clientY;
|
||||
const startLeft = $card.offset().left;
|
||||
const startTop = $card.offset().top;
|
||||
|
||||
const onMouseMove = (e) => {
|
||||
const deltaX = e.clientX - startX;
|
||||
const deltaY = e.clientY - startY;
|
||||
$card.css({
|
||||
left: startLeft + deltaX + 'px',
|
||||
top: startTop + deltaY + 'px'
|
||||
});
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
$(document).off('mousemove', onMouseMove);
|
||||
$(document).off('mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
$(document).on('mousemove', onMouseMove);
|
||||
$(document).on('mouseup', onMouseUp);
|
||||
// The opened card's window is moved by a finger as well as a mouse, so these are
|
||||
// Pointer Events (a finger sends no mouse events while it moves, which left the card
|
||||
// fixed on an iPad while the page behind it scrolled) and the areas that start a
|
||||
// drag are `touch-action: none` (cardDetails.css). With drag handles ON the handle
|
||||
// is the only thing that moves the window; with them off the header row moves it,
|
||||
// except the title (it edits). A link or button in the row - the collapse caret, the
|
||||
// menu, the card number's neighbours - still does its job when tapped, and moves the
|
||||
// window when dragged. models/lib/windowDrag.js
|
||||
'pointerdown .js-card-drag-handle'(event) {
|
||||
beginCardWindowDrag(event, { start: true, deferred: false });
|
||||
},
|
||||
'mousedown .js-card-title-drag-handle'(event) {
|
||||
// The title bar drags the window. Don't interfere with text selection.
|
||||
if (
|
||||
$(event.target).closest(
|
||||
'a, input, textarea, button, select, option, .js-card-details-title',
|
||||
).length > 0
|
||||
) {
|
||||
// #6641: while the editor replaces the header, a textarea press still
|
||||
// bubbles to this drag bar. Leave it to the browser so mouse dragging
|
||||
// selects text instead of moving the card window.
|
||||
return;
|
||||
}
|
||||
// ...and don't drag from the half that EDITS. The title is split like a
|
||||
// minicard's: the leading half opens the editor, the trailing half is what
|
||||
// you take hold of. With drag handles ON the zone covers the whole title
|
||||
// (cardDetails.css), so the window is dragged by its handle alone - which is
|
||||
// what turning handles on means.
|
||||
if ($(event.target).closest('.card-details-title-edit-zone').length > 0) {
|
||||
return;
|
||||
}
|
||||
// The drag handle has its own handler; without this both would run and the
|
||||
// window would be moved twice by one press.
|
||||
if ($(event.target).closest('.js-card-drag-handle').length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
const $card = $(event.target).closest('.card-details');
|
||||
// #6465: same as the drag handle above — a dragged window keeps its place.
|
||||
markCardDetailsUserMoved($card);
|
||||
const startX = event.clientX;
|
||||
const startY = event.clientY;
|
||||
const startLeft = $card.offset().left;
|
||||
const startTop = $card.offset().top;
|
||||
|
||||
const onMouseMove = (e) => {
|
||||
const deltaX = e.clientX - startX;
|
||||
const deltaY = e.clientY - startY;
|
||||
$card.css({
|
||||
left: startLeft + deltaX + 'px',
|
||||
top: startTop + deltaY + 'px'
|
||||
});
|
||||
};
|
||||
|
||||
const onMouseUp = () => {
|
||||
$(document).off('mousemove', onMouseMove);
|
||||
$(document).off('mouseup', onMouseUp);
|
||||
};
|
||||
|
||||
$(document).on('mousemove', onMouseMove);
|
||||
$(document).on('mouseup', onMouseUp);
|
||||
'pointerdown .js-card-title-drag-handle'(event) {
|
||||
const target = event.target;
|
||||
const closest = selector => (target.closest ? target.closest(selector) : null);
|
||||
const press = cardHeaderPress({
|
||||
onEditable: !!closest('input, textarea, select, option, .js-card-details-title'),
|
||||
onTitleEditZone: !!closest('.card-details-title-edit-zone'),
|
||||
// The handle has its own handler above; this one steps aside for it.
|
||||
onHandle: false,
|
||||
cardHasHandle: !!closest('.js-card-drag-handle')
|
||||
|| !!(closest('.card-details') && closest('.card-details').querySelector('.js-card-drag-handle')),
|
||||
onControl: !!closest('a, button'),
|
||||
});
|
||||
if (closest('.js-card-drag-handle')) return;
|
||||
beginCardWindowDrag(event, press);
|
||||
},
|
||||
'click .js-close-card-details'(event) {
|
||||
event.preventDefault();
|
||||
@@ -2392,7 +2373,7 @@ const filterMembers = (filterTerm) => {
|
||||
.map(({ member }) => member);
|
||||
}
|
||||
return members;
|
||||
}
|
||||
};
|
||||
|
||||
Template.editCardRequesterForm.onRendered(function () {
|
||||
autosize(this.$('.js-edit-card-requester'));
|
||||
|
||||
@@ -9,5 +9,5 @@ template(name="cardPomodoro")
|
||||
button.js-stop-pomodoro.pomodoro-btn.negate(type="button") {{_ 'stop'}}
|
||||
else
|
||||
if canControlPomodoro
|
||||
input.js-pomodoro-work-minutes.pomodoro-minutes(type="number" min="1" value="{{workMinutesInput}}")
|
||||
input.js-pomodoro-work-minutes.pomodoro-minutes(type="number" min="1" value="{{workMinutesInput}}" aria-label="{{_ 'pomodoro-work'}} ({{_ 'minutes'}})")
|
||||
button.js-start-pomodoro.pomodoro-btn.primary(type="button") {{_ 'pomodoro-start'}}
|
||||
@@ -1,5 +1,5 @@
|
||||
.js-add-checklist {
|
||||
color: #8c8c8c;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
}
|
||||
textarea.js-add-checklist-item,
|
||||
textarea.js-edit-checklist-item {
|
||||
@@ -11,7 +11,7 @@ textarea.js-edit-checklist-item {
|
||||
.delete-text,
|
||||
.js-delete-checklist-item,
|
||||
.js-convert-checklist-item-to-card {
|
||||
color: #8c8c8c;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
text-decoration: underline;
|
||||
word-wrap: break-word;
|
||||
float: inline-end;
|
||||
@@ -139,7 +139,7 @@ textarea.js-edit-checklist-item {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.checklist-item .item-title.is-checked {
|
||||
color: #8c8c8c;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
font-style: italic;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
@@ -167,11 +167,11 @@ textarea.js-edit-checklist-item {
|
||||
.checklist-item .checklist-item-due-date-add {
|
||||
align-self: center;
|
||||
margin-inline-start: 6px;
|
||||
color: #8c8c8c;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
text-decoration: none;
|
||||
}
|
||||
.checklist-item .checklist-item-due-date-add:hover {
|
||||
color: #2980b9;
|
||||
color: #2573a7;
|
||||
}
|
||||
.js-delete-checklist-item,
|
||||
.js-convert-checklist-item-to-card {
|
||||
@@ -235,7 +235,7 @@ textarea.js-edit-checklist-item {
|
||||
|
||||
.checklist-title .checklist-collapse:hover,
|
||||
.checklist-title .checklist-collapse:focus-visible {
|
||||
color: #2980b9;
|
||||
color: #2573a7;
|
||||
}
|
||||
|
||||
.checklist-title .checklist-due-date-add {
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
}
|
||||
.ui-font-btn.active,
|
||||
.ui-font-size-btn.active {
|
||||
background: #2980b9;
|
||||
border-color: #2980b9;
|
||||
background: #2573a7;
|
||||
border-color: #2573a7;
|
||||
color: #fff;
|
||||
}
|
||||
/* The "Unset" colour reset links look like buttons. */
|
||||
@@ -155,6 +155,7 @@
|
||||
}
|
||||
.card-label-green {
|
||||
background-color: #3cb500;
|
||||
color: #000;
|
||||
}
|
||||
.card-label-green:hover {
|
||||
color: #000;
|
||||
@@ -169,6 +170,7 @@
|
||||
}
|
||||
.card-label-red {
|
||||
background-color: #eb4646;
|
||||
color: #000;
|
||||
}
|
||||
.card-label-purple {
|
||||
background-color: #a632db;
|
||||
@@ -182,6 +184,7 @@
|
||||
}
|
||||
.card-label-sky {
|
||||
background-color: #00c2e0;
|
||||
color: #000;
|
||||
}
|
||||
.card-label-black {
|
||||
background-color: #4d4d4d;
|
||||
@@ -213,6 +216,7 @@
|
||||
}
|
||||
.card-label-magenta {
|
||||
background-color: #f0f;
|
||||
color: #000;
|
||||
}
|
||||
.card-label-gold {
|
||||
background-color: #ffd700;
|
||||
@@ -223,6 +227,7 @@
|
||||
}
|
||||
.card-label-gray {
|
||||
background-color: #808080;
|
||||
color: #000;
|
||||
}
|
||||
.card-label-saddlebrown {
|
||||
background-color: #8b4513;
|
||||
|
||||
@@ -374,7 +374,12 @@
|
||||
flex-wrap: wrap;
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
margin-inline-end: 84px;
|
||||
/* #6834: room for the drag handle beside it (20px, 1px from the edge), and
|
||||
no more. It reserved 84px - a third of a 250px minicard - for nothing:
|
||||
the avatars float AFTER this row and `clear: both` starts it below the
|
||||
menu, so a due date "1405-08-10 12:00 (1 day left)" was broken over three
|
||||
lines beside an empty strip. */
|
||||
margin-inline-end: 24px;
|
||||
clear: both;
|
||||
}
|
||||
.minicard .date {
|
||||
@@ -1006,7 +1011,7 @@
|
||||
.minicard-list-name {
|
||||
clear: inline-start;
|
||||
font-size: 0.75em;
|
||||
color: #8c8c8c;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
margin-top: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1018,7 +1023,7 @@
|
||||
.minicard-swimlane-name {
|
||||
clear: inline-start;
|
||||
font-size: 0.75em;
|
||||
color: #8c8c8c;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
margin-top: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -419,18 +419,27 @@ template(name="minicard")
|
||||
.minicard-location
|
||||
i.fa.fa-map-marker
|
||||
| {{#if name}}{{name}}{{else}}{{address}}{{/if}}
|
||||
//- #6872: the + button on the opened card picks board members into
|
||||
requesters / assigners; the free-text name is optional. Show both,
|
||||
as the opened card does, or a picked member never reached here.
|
||||
if $eq field "requestedBy"
|
||||
unless $eq currentBoard.allowsRequestedByOnMinicard false
|
||||
if getRequestedBy
|
||||
if hasRequestedByOnMinicard
|
||||
.minicard-requested-by(title="{{_ 'requested-by'}}")
|
||||
i.fa.fa-shopping-cart
|
||||
| {{ getRequestedBy }}
|
||||
each userId in getRequesters
|
||||
+userAvatar(userId=userId noRemove=true)
|
||||
if getRequestedBy
|
||||
span {{ getRequestedBy }}
|
||||
if $eq field "assignedBy"
|
||||
unless $eq currentBoard.allowsAssignedByOnMinicard false
|
||||
if getAssignedBy
|
||||
if hasAssignedByOnMinicard
|
||||
.minicard-assigned-by(title="{{_ 'assigned-by'}}")
|
||||
i.fa.fa-user-plus
|
||||
| {{ getAssignedBy }}
|
||||
each userId in getAssigners
|
||||
+userAvatar(userId=userId noRemove=true)
|
||||
if getAssignedBy
|
||||
span {{ getAssignedBy }}
|
||||
if $eq field "flowtime"
|
||||
if currentBoard.allowsFlowtimeOnMinicard
|
||||
if isFlowActive
|
||||
|
||||
@@ -261,6 +261,13 @@ Template.minicard.helpers({
|
||||
isWatching() {
|
||||
return this.getRealCard().findWatcher(Meteor.userId());
|
||||
},
|
||||
// #6872: a picked member (requesters / assigners) or a free-text name.
|
||||
hasRequestedByOnMinicard() {
|
||||
return !!this.getRequestedBy() || (this.getRequesters() || []).length > 0;
|
||||
},
|
||||
hasAssignedByOnMinicard() {
|
||||
return !!this.getAssignedBy() || (this.getAssigners() || []).length > 0;
|
||||
},
|
||||
|
||||
showMembers() {
|
||||
const board = minicardBoardOf(this);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
.js-add-subtask {
|
||||
color: #8c8c8c;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
}
|
||||
textarea.js-add-subtask-item,
|
||||
textarea.js-edit-subtask-item {
|
||||
@@ -12,7 +12,7 @@ textarea.js-edit-subtask-item {
|
||||
.subtask-title .js-delete-subtask,
|
||||
.subtask-title .js-view-subtask,
|
||||
.js-delete-subtask-item {
|
||||
color: #8c8c8c;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
text-decoration: underline;
|
||||
word-wrap: break-word;
|
||||
flex-shrink: 0;
|
||||
@@ -163,7 +163,7 @@ textarea.js-edit-subtask-item {
|
||||
padding-inline-start: 10px;
|
||||
}
|
||||
.subtasks-item .item-title.is-checked {
|
||||
color: #8c8c8c;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
font-style: italic;
|
||||
}
|
||||
.subtasks-item .item-title .viewer p {
|
||||
@@ -186,7 +186,7 @@ textarea.js-edit-subtask-item {
|
||||
instead of disappearing — the same dimmed/italic treatment a checked
|
||||
checklist item gets (.subtasks-item .item-title.is-checked above). */
|
||||
.subtask.is-completed .subtask-title .title {
|
||||
color: #8c8c8c;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
font-style: italic;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
@@ -199,7 +199,7 @@ textarea.js-edit-subtask-item {
|
||||
.subtasks-hide-completed-toggle {
|
||||
display: inline-block;
|
||||
margin: 0.2em 0 0.5em 1.33em;
|
||||
color: #8c8c8c;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
cursor: pointer;
|
||||
}
|
||||
.subtasks-hide-completed-toggle:hover {
|
||||
@@ -224,7 +224,7 @@ textarea.js-edit-subtask-item {
|
||||
flex-shrink: 0;
|
||||
margin: 0 0.5em;
|
||||
padding-top: 6px;
|
||||
color: #8c8c8c;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
font-size: 0.85em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
@@ -251,5 +251,5 @@ textarea.js-edit-subtask-item {
|
||||
}
|
||||
.subtask-title.is-done .title {
|
||||
text-decoration: line-through;
|
||||
color: #8c8c8c;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
}
|
||||
@@ -28,4 +28,4 @@ template(name="cardWhiteboard")
|
||||
svg.card-whiteboard-surface.js-whiteboard-surface(viewBox="0 0 {{width}} {{height}}" preserveAspectRatio="xMidYMid meet" class="{{#if canDraw}}can-draw{{/if}}")
|
||||
rect(width=width height=height fill="#ffffff")
|
||||
each strokes
|
||||
path(d=pathD stroke=color stroke-width=width fill="none" stroke-linecap="round" stroke-linejoin="round")
|
||||
path(d=pathD stroke=color stroke-width=strokeWidth fill="none" stroke-linecap="round" stroke-linejoin="round")
|
||||
@@ -116,7 +116,11 @@ Template.cardWhiteboard.helpers({
|
||||
const current = tpl.current.get();
|
||||
return [...saved, ...pending, ...(current ? [current] : [])]
|
||||
.filter(s => s && Array.isArray(s.points) && s.points.length)
|
||||
.map(s => ({ color: s.color, width: s.width, pathD: pathD(s.points) }));
|
||||
// strokeWidth, not width: inside `each strokes` Spacebars looks a name up
|
||||
// among this template's helpers before the stroke's own fields, and the
|
||||
// width helper is the drawing's (800) - every line was drawn 800 wide and
|
||||
// covered the whiteboard.
|
||||
.map(s => ({ color: s.color, strokeWidth: s.width, pathD: pathD(s.points) }));
|
||||
},
|
||||
saving() { return Template.instance().saving.get(); },
|
||||
message() { return Template.instance().message.get(); },
|
||||
|
||||
@@ -102,6 +102,29 @@
|
||||
padding-inline: 4px;
|
||||
}
|
||||
|
||||
.selected-calendar-heading .selected-calendar-date {
|
||||
margin-inline-start: 4px;
|
||||
/* #6838: a word, not an arrow - it may be wider than the arrows beside it. */
|
||||
.selected-calendar-heading .js-calendar-today {
|
||||
white-space: nowrap;
|
||||
padding-inline: 6px;
|
||||
}
|
||||
/* #6853: year, month and day drop-downs under the calendar. */
|
||||
.selected-calendar-selects {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-block-start: 6px;
|
||||
}
|
||||
.selected-calendar-selects select,
|
||||
.selected-calendar-selects input {
|
||||
flex: 1;
|
||||
box-sizing: border-box;
|
||||
height: auto;
|
||||
margin-bottom: 0;
|
||||
min-width: 0;
|
||||
padding-inline: 4px;
|
||||
}
|
||||
.selected-calendar-selects .js-calendar-year { flex: 1.4; }
|
||||
.calendar-date-input .selected-calendar-selects select:focus-visible,
|
||||
.calendar-date-input .selected-calendar-selects input:focus-visible {
|
||||
outline: 2px solid currentColor;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
@@ -20,8 +20,9 @@ template(name="calendarDateInput")
|
||||
button.primary.js-calendar-previous(type="button" aria-label="{{_ 'previous'}} {{_ 'month'}}") ‹
|
||||
span(aria-live="polite")
|
||||
| {{monthLabel}}
|
||||
if inline
|
||||
span.selected-calendar-date {{headingDateText}}
|
||||
//- #6838: back to today's month from wherever the arrows went. It only
|
||||
//- shows the month; the date stays as it is until a day is clicked.
|
||||
button.primary.js-calendar-today(type="button" aria-label="{{_ 'today'}}") {{_ 'today'}}
|
||||
button.primary.js-calendar-next(type="button" aria-label="{{_ 'next'}} {{_ 'month'}}") ›
|
||||
button.primary.js-calendar-year-next(type="button" aria-label="{{_ 'next'}} {{_ 'predicate-year'}}") »
|
||||
table
|
||||
@@ -34,5 +35,16 @@ template(name="calendarDateInput")
|
||||
tr
|
||||
each days
|
||||
td(class="{{#if this.today}}is-today-cell{{/if}}")
|
||||
if this
|
||||
unless this.empty
|
||||
button.primary.js-calendar-day(type="button" class="{{#if today}}is-today{{/if}}" data-date=iso title=title aria-label=title aria-pressed=selected aria-current=ariaCurrent tabindex=tabIndex) {{label}}
|
||||
//- #6853: any year, month and day by a pick, under the calendar.
|
||||
.selected-calendar-selects
|
||||
input.js-calendar-year(type="number" step="1" inputmode="numeric" value=yearValue aria-label="{{_ 'predicate-year'}}")
|
||||
select.js-calendar-month(aria-label="{{_ 'month'}}")
|
||||
each monthOptions
|
||||
option(value=value selected=selected) {{label}}
|
||||
select.js-calendar-day-select(aria-label="{{_ 'day'}}")
|
||||
if noDaySelected
|
||||
option(value="" selected disabled) –
|
||||
each dayOptions
|
||||
option(value=value selected=selected) {{label}}
|
||||
@@ -6,7 +6,10 @@ import { TAPi18n } from '/imports/i18n';
|
||||
import { formatDate } from '/imports/lib/dateUtils';
|
||||
import { formatDateForDisplay, dateDisplayPreferences } from '/client/lib/dateDisplay';
|
||||
const { CALENDAR_SYSTEMS, nativeCalendarParts } = require('/imports/lib/calendarSystems');
|
||||
const { calendarMonthDays, calendarMonthRange, shiftedDay, shiftCalendarMonth, shiftCalendarYear } = require('/imports/lib/calendarMonth');
|
||||
const {
|
||||
calendarMonthDays, calendarMonthRange, shiftedDay, shiftCalendarMonth, shiftCalendarYear,
|
||||
calendarSignedYear, dateInCalendarYear, calendarYearMonths, sameDayInMonth,
|
||||
} = require('/imports/lib/calendarMonth');
|
||||
|
||||
function fromInput(value) {
|
||||
if (!value) return null;
|
||||
@@ -53,9 +56,45 @@ Template.calendarDateInput.helpers({
|
||||
useNativeInput() { return !this.inline && dateDisplayPreferences().calendarSystem === 'gregorian'; },
|
||||
inputType() { return this.withTime ? 'datetime-local' : 'date'; },
|
||||
selectedDateText() { return formatDateForDisplay(Template.instance().selectedDate.get(), false); },
|
||||
headingDateText() {
|
||||
// #6853: the year, month and day drop-downs under the calendar. Each option
|
||||
// carries a date as its value, found in the selected calendar system, so any
|
||||
// past, present or future year, month and day is one pick away.
|
||||
// The year as a number any year can be typed or stepped to, also one before
|
||||
// the era (0 is 1 BC) or thousands of years away.
|
||||
yearValue() { return calendarSignedYear(Template.instance().shownDate.get(), selectedSystem().intl); },
|
||||
monthOptions() {
|
||||
const tpl = Template.instance();
|
||||
return formatDateForDisplay(tpl.selectedDate.get() || tpl.shownDate.get(), false);
|
||||
const calendar = selectedSystem().intl;
|
||||
const language = TAPi18n.getLanguage() || 'en';
|
||||
return calendarYearMonths(tpl.shownDate.get(), calendar).map(month => {
|
||||
let label;
|
||||
try {
|
||||
label = new Intl.DateTimeFormat(language, { calendar, month: 'long', numberingSystem: 'latn' }).format(month.date);
|
||||
} catch (error) { label = formatDateForDisplay(month.date, false); }
|
||||
return { _id: formatDate(month.date), value: formatDate(month.date), label, selected: month.selected };
|
||||
});
|
||||
},
|
||||
dayOptions() {
|
||||
const tpl = Template.instance();
|
||||
const calendar = selectedSystem().intl;
|
||||
const { start, end } = calendarMonthRange(tpl.shownDate.get(), calendar);
|
||||
const selected = tpl.selectedDate.get();
|
||||
const selectedIso = selected ? formatDate(selected) : '';
|
||||
const days = [];
|
||||
for (let day = shiftedDay(start, 0); day < end; day = shiftedDay(day, 1)) {
|
||||
days.push({
|
||||
_id: formatDate(day), value: formatDate(day),
|
||||
label: nativeCalendarParts(day, calendar).day, selected: formatDate(day) === selectedIso,
|
||||
});
|
||||
}
|
||||
return days;
|
||||
},
|
||||
noDaySelected() {
|
||||
const tpl = Template.instance();
|
||||
const selected = tpl.selectedDate.get();
|
||||
if (!selected) return true;
|
||||
const { start, end } = calendarMonthRange(tpl.shownDate.get(), selectedSystem().intl);
|
||||
return selected < start || selected >= end;
|
||||
},
|
||||
expanded() { return !!Template.instance().data.inline || Template.instance().expanded.get(); },
|
||||
calendarName() { return TAPi18n.__(selectedSystem().labelKey); },
|
||||
@@ -63,8 +102,11 @@ Template.calendarDateInput.helpers({
|
||||
const date = Template.instance().shownDate.get();
|
||||
try {
|
||||
return new Intl.DateTimeFormat(TAPi18n.getLanguage() || 'en', {
|
||||
calendar: selectedSystem().intl, month: 'long',
|
||||
...(Template.instance().data.inline ? {} : { year: 'numeric' }), numberingSystem: 'latn',
|
||||
// #6853: the year is always shown, also in the popup: month and year
|
||||
// above the calendar, never the YYYY-MM-DD of the selected day.
|
||||
calendar: selectedSystem().intl, month: 'long', year: 'numeric', numberingSystem: 'latn',
|
||||
// A year before the era needs its era, or 3000 BC reads as 3000.
|
||||
...(calendarSignedYear(date, selectedSystem().intl) <= 0 ? { era: 'short' } : {}),
|
||||
}).format(date);
|
||||
} catch (error) { return formatDateForDisplay(date, false); }
|
||||
},
|
||||
@@ -86,9 +128,17 @@ Template.calendarDateInput.helpers({
|
||||
// the browser's local day, the same local calendar day formatDate() gives
|
||||
// every other cell, so it is the cell the user means by "today".
|
||||
const todayIso = formatDate(new Date());
|
||||
// #6838: every row and every cell - an empty one too - has its own _id.
|
||||
// Blaze matches list items by _id; without one, going from one month to
|
||||
// another reused cells by position and the empty cells, all alike, were
|
||||
// moved around: October came back as "29, empty, 30, empty", without the
|
||||
// 31st. The month is part of each id, so a new month is new cells.
|
||||
const month = days.find(Boolean);
|
||||
const monthId = month ? formatDate(month) : 'empty';
|
||||
const rows = [];
|
||||
for (let index = 0; index < days.length; index += 7) {
|
||||
rows.push({ days: days.slice(index, index + 7).map(date => date && ({
|
||||
rows.push({ _id: `${monthId}-week-${index / 7}`, days: days.slice(index, index + 7).map((date, cell) => (date ? {
|
||||
_id: formatDate(date),
|
||||
iso: formatDate(date),
|
||||
today: formatDate(date) === todayIso,
|
||||
// null omits the attribute: only today's button carries aria-current.
|
||||
@@ -97,7 +147,7 @@ Template.calendarDateInput.helpers({
|
||||
title: formatDateForDisplay(date, false),
|
||||
selected: tpl.selectedDate.get() && formatDate(tpl.selectedDate.get()) === formatDate(date),
|
||||
tabIndex: focusDate && formatDate(focusDate) === formatDate(date) ? 0 : -1,
|
||||
})) });
|
||||
} : { _id: `${monthId}-empty-${index + cell}`, empty: true })) });
|
||||
}
|
||||
return rows;
|
||||
},
|
||||
@@ -121,8 +171,40 @@ Template.calendarDateInput.events({
|
||||
event.preventDefault();
|
||||
tpl.shownDate.set(calendarMonthRange(tpl.shownDate.get(), selectedSystem().intl).end);
|
||||
},
|
||||
'click .js-calendar-today'(event, tpl) {
|
||||
event.preventDefault();
|
||||
tpl.shownDate.set(new Date());
|
||||
Tracker.afterFlush(() => tpl.find('.js-calendar-day.is-today')?.focus());
|
||||
},
|
||||
'click .js-calendar-year-previous'(event, tpl) { event.preventDefault(); tpl.shownDate.set(shiftCalendarYear(tpl.shownDate.get(), -1, selectedSystem().intl)); },
|
||||
'click .js-calendar-year-next'(event, tpl) { event.preventDefault(); tpl.shownDate.set(shiftCalendarYear(tpl.shownDate.get(), 1, selectedSystem().intl)); },
|
||||
// #6853: a pick in a drop-down selects that date and shows its month.
|
||||
'change .js-calendar-year'(event, tpl) {
|
||||
const input = event.currentTarget;
|
||||
const target = dateInCalendarYear(tpl.shownDate.get(), Number(input.value), selectedSystem().intl);
|
||||
if (!target || input.value.trim() === '') {
|
||||
// A year that is not a whole number, or beyond what a date can hold:
|
||||
// the field goes back to the year being shown.
|
||||
input.value = calendarSignedYear(tpl.shownDate.get(), selectedSystem().intl);
|
||||
return;
|
||||
}
|
||||
tpl.shownDate.set(target); tpl.focusedDate.set(target); writeValue(tpl, target);
|
||||
},
|
||||
// Enter in the year field applies the year, not the popup's Save.
|
||||
'keydown .js-calendar-year'(event) {
|
||||
if (event.key !== 'Enter') return;
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
event.currentTarget.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
},
|
||||
'change .js-calendar-month'(event, tpl) {
|
||||
const target = sameDayInMonth(tpl.selectedDate.get() || tpl.shownDate.get(), fromInput(event.currentTarget.value), selectedSystem().intl);
|
||||
tpl.shownDate.set(target); tpl.focusedDate.set(target); writeValue(tpl, target);
|
||||
},
|
||||
'change .js-calendar-day-select'(event, tpl) {
|
||||
const target = fromInput(event.currentTarget.value);
|
||||
if (!target) return;
|
||||
tpl.shownDate.set(target); tpl.focusedDate.set(target); writeValue(tpl, target);
|
||||
},
|
||||
'click .js-calendar-day'(event, tpl) {
|
||||
event.preventDefault();
|
||||
writeValue(tpl, fromInput(event.currentTarget.dataset.date));
|
||||
|
||||
@@ -192,9 +192,11 @@ body.mobile-mode [id^="swimlane-"] .list[style*="--list-width"]:not(.list-collap
|
||||
min-height: 30px !important;
|
||||
height: auto !important;
|
||||
padding: 28px 21px 6px !important;
|
||||
/* Make sure the background covers the full height */
|
||||
background-color: #e4e4e4 !important;
|
||||
border-bottom: 8px solid #e4e4e4 !important;
|
||||
/* Make sure the background covers the full height. Not !important: it beat
|
||||
every theme's own list header (moderndark's #6a6a6a, exodark's #222), and
|
||||
left their light text on this grey at 1.09:1 and 1.27:1 (#6863). */
|
||||
background-color: #e4e4e4;
|
||||
border-bottom: 8px solid #e4e4e4;
|
||||
/* Use original display for consistent button positioning */
|
||||
display: block !important;
|
||||
position: relative !important;
|
||||
@@ -470,7 +472,7 @@ form.inlined-form .list-composer {
|
||||
|
||||
.list.list-composer .open-list-composer,
|
||||
.list .list-composer .open-list-composer {
|
||||
color: #8c8c8c;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
}
|
||||
.list.list-composer .list-name-input,
|
||||
.list .list-composer .list-name-input {
|
||||
@@ -534,7 +536,7 @@ form.inlined-form .list-composer {
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: #8c8c8c;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
font-weight: bold;
|
||||
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
|
||||
vertical-align: middle;
|
||||
@@ -722,7 +724,7 @@ form.inlined-form .list-composer {
|
||||
}
|
||||
|
||||
.list-header .cardCount {
|
||||
color: #8c8c8c;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
|
||||
font-weight: bold;
|
||||
}
|
||||
@@ -781,7 +783,7 @@ form.inlined-form .list-composer {
|
||||
}
|
||||
.list-body .open-minicard-composer {
|
||||
border-radius: 2px;
|
||||
color: #8c8c8c;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
display: block;
|
||||
/* Smaller than a card title, because it is not a card: it is the affordance
|
||||
for making one. At the inherited 16px it was the largest text in the list
|
||||
|
||||
@@ -74,7 +74,7 @@
|
||||
selected thing. The fill is the slide where the theme has one. */
|
||||
.theme-color-picker ul.pop-over-list.theme-scope-row > li.active > a,
|
||||
.theme-color-picker ul.pop-over-list.theme-scope-row > li.active > a:hover {
|
||||
background: var(--theme-accent-fill, var(--theme-accent, #2980b9));
|
||||
background: var(--theme-accent-fill, var(--theme-accent, #2573a7));
|
||||
color: #fff !important;
|
||||
}
|
||||
.theme-color-picker ul.pop-over-list.theme-scope-row > li.active > a i {
|
||||
|
||||
@@ -39,7 +39,7 @@ template(name="dueCards")
|
||||
else
|
||||
.global-search-results-list-wrapper
|
||||
.no-results
|
||||
h3 {{_ 'dueCards-noResults-title'}}
|
||||
h2 {{_ 'dueCards-noResults-title'}}
|
||||
p {{_ 'dueCards-noResults-description'}}
|
||||
|
||||
template(name="dueCardsViewChangePopup")
|
||||
|
||||
@@ -7,6 +7,7 @@ template(name="editor")
|
||||
dir="auto"
|
||||
class="{{class}}"
|
||||
id=id
|
||||
aria-label=labelText
|
||||
autofocus=autofocus
|
||||
placeholder="{{_ 'comment-placeholder'}}")
|
||||
+Template.contentBlock
|
||||
|
||||
@@ -45,6 +45,16 @@ function syncMarkdownUrlSchemes(setting) {
|
||||
return schemes;
|
||||
}
|
||||
|
||||
// WCAG 4.1.2: a field has a name. The including template passes the KEY of its
|
||||
// name (labelKey="comment"), and the editor translates it, as every included
|
||||
// template does (tests/jadeInclusionArgsNoMustache.test.cjs).
|
||||
Template.editor.helpers({
|
||||
labelText() {
|
||||
const data = Template.currentData() || {};
|
||||
return data.labelKey ? TAPi18n.__(data.labelKey) : null;
|
||||
},
|
||||
});
|
||||
|
||||
const specialHandles = [
|
||||
{userId: 'board_members', username: 'board_members'},
|
||||
{userId: 'card_members', username: 'card_members'},
|
||||
|
||||
@@ -106,6 +106,8 @@ code {
|
||||
}
|
||||
.list-title {
|
||||
background-color: #a9a9a9;
|
||||
/* White on this grey was 2.35; black is 8.94 (WCAG 1.4.3). */
|
||||
color: #000;
|
||||
}
|
||||
.global-search-footer {
|
||||
border: none;
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Session } from 'meteor/session';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { Template } from 'meteor/templating';
|
||||
import { activeAccent, activeFill } from '/models/lib/themeAccents';
|
||||
import { readableAccent } from '/models/lib/contrastColor';
|
||||
|
||||
// #5778 + docs/Theme/Theme.md: apply the theme color to the whole UI (All Boards,
|
||||
// Search, Admin Panel, My Cards, etc.) via a `board-color-<name>` class on <body>,
|
||||
@@ -99,7 +100,8 @@ Meteor.startup(() => {
|
||||
document.body.classList.remove('has-custom-theme-color');
|
||||
}
|
||||
if (c2) {
|
||||
root.style.setProperty('--theme-accent-2', c2);
|
||||
// Made readable like the first end: white text is drawn across the slide.
|
||||
root.style.setProperty('--theme-accent-2', readableAccent(c2));
|
||||
document.body.classList.add('has-custom-theme-slide');
|
||||
} else {
|
||||
root.style.removeProperty('--theme-accent-2');
|
||||
|
||||
@@ -17,9 +17,21 @@
|
||||
#header {
|
||||
color: #fff;
|
||||
transition: background-color 0.4s;
|
||||
background: #2980b9;
|
||||
background: #2573a7;
|
||||
z-index: 17;
|
||||
}
|
||||
/* An opened card covers the header bars, in every browser. iOS WebKit - iPad Safari and
|
||||
iPad Chrome, which is WebKit too - makes every SCROLLING element (#content, the board
|
||||
wrapper) a stacking context of its own. The card is inside them, so its z-index (2001)
|
||||
is only compared there, while the header bar (#header-quick-access, z-index 1000, a
|
||||
flex item of body and so a stacking context itself) is painted over all of it: the
|
||||
card's top row - the collapse caret, the card number, the menu, maximize and X - sat
|
||||
BEHIND the header bar. While a card is open the header drops to the level of the
|
||||
content, which comes after it in the page and so paints over it; a browser without that
|
||||
trap shows no difference. */
|
||||
body:has(.card-details) #header-quick-access {
|
||||
z-index: 0;
|
||||
}
|
||||
#header #header-main-bar {
|
||||
/* Flex row (was float-based) so the right button group — which ends with the
|
||||
sidebar hamburger — is always pinned to the right edge at every window width,
|
||||
@@ -247,7 +259,7 @@
|
||||
float: inline-start;
|
||||
display: block;
|
||||
line-height: calc(28px * var(--wekan-ui-font-scale, 1));
|
||||
color: #27ae60;
|
||||
color: #217d49;
|
||||
margin: 0 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -298,7 +310,7 @@ body:not(.board-view) #header #header-main-bar h1 {
|
||||
of colour now, so the header reads as one header. The per-theme rules do
|
||||
the same, by naming this bar in the selector list the main bar already has
|
||||
(boardColors.css) rather than by restating each hex a second time. */
|
||||
background: #2980b9;
|
||||
background: #2573a7;
|
||||
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
|
||||
display: flex;
|
||||
z-index: 1000;
|
||||
@@ -745,7 +757,7 @@ body.mobile-mode #header-quick-access .allBoards {
|
||||
color: #fff !important;
|
||||
/* The active theme, like the rest of the bar; the stock blue where no theme
|
||||
has published an accent. */
|
||||
background: var(--theme-accent, #2980b9) !important;
|
||||
background: var(--theme-accent, #2573a7) !important;
|
||||
}
|
||||
|
||||
#header-quick-access .mobile-mode-toggle .board-header-btn.mobile-active i.mobile-icon .fa,
|
||||
|
||||
@@ -44,7 +44,9 @@ template(name="header")
|
||||
that do the same job. Board admins only - everybody else gets the
|
||||
plain title. The `is-editable` class also gives the element a minimum
|
||||
width, so a board whose title is EMPTY still has an area to click.
|
||||
span.header-page-title(title="{{headerTitleFullPath}}"
|
||||
//- role="heading" aria-level="1": every page has one level-one heading, and this
|
||||
//- is the page's name (WCAG 1.3.1 / 2.4.6; axe page-has-heading-one).
|
||||
span.header-page-title(title="{{headerTitleFullPath}}" role="heading" aria-level="1"
|
||||
class="{{#if canEditBoardTitle}}js-edit-board-title is-editable{{/if}}")
|
||||
if headerTitleKey
|
||||
| {{_ headerTitleKey}}
|
||||
@@ -67,6 +69,19 @@ template(name="header")
|
||||
div.header-logo
|
||||
img(src="{{pathFor '/logo-header.png'}}" alt="{{headerLogoAlt}}" title="{{headerLogoAlt}}")
|
||||
|
||||
//- Full screen, to the right of the logo (iPad Safari, desktop and Android
|
||||
browsers). Only drawn where the browser has the Fullscreen API and the page
|
||||
may use it: not on iPhone, not in an iframe without allowfullscreen. The
|
||||
label is the existing "Maximize" one. It stays before the first
|
||||
.js-header-collapsible-icon, so "hide the icons" does not hide it.
|
||||
models/lib/fullscreen.js
|
||||
if fullscreenSupported
|
||||
a.board-header-btn.js-toggle-fullscreen(href="#" role="button" title="{{#if isFullscreen}}{{_ 'minimize-window'}}{{else}}{{_ 'maximize-window'}}{{/if}}" aria-label="{{#if isFullscreen}}{{_ 'minimize-window'}}{{else}}{{_ 'maximize-window'}}{{/if}}" aria-pressed="{{#if isFullscreen}}true{{else}}false{{/if}}")
|
||||
if isFullscreen
|
||||
i.fa.fa-compress
|
||||
else
|
||||
i.fa.fa-expand
|
||||
|
||||
// Mobile/desktop mode toggle (swapped with zoom)
|
||||
// #6680 follow-up: .js-header-collapsible-icon marks every icon the new
|
||||
// .js-toggle-header-icons-collapsed button (beside Home) hides as a
|
||||
@@ -119,7 +134,8 @@ template(name="header")
|
||||
//- One GROUP: the two adjacent buttons are about the same thing - how
|
||||
many items you have starred, and whether this one is among them.
|
||||
.header-star-group.js-header-collapsible-icon
|
||||
a.board-header-btn.js-open-starred-boards(title="{{_ 'allboards.starred'}}")
|
||||
//- aria-label: WCAG 2.5.3 Label in Name - the visible text is the star count.
|
||||
a.board-header-btn.js-open-starred-boards(title="{{_ 'allboards.starred'}}" aria-label="{{starredBoardsCount}} {{_ 'allboards.starred'}}")
|
||||
i.fa.fa-caret-down
|
||||
span.board-star-counter {{starredBoardsCount}}
|
||||
|
||||
@@ -245,7 +261,8 @@ template(name="header")
|
||||
if appIsOffline
|
||||
+offlineWarning
|
||||
|
||||
if currentUser.isBoardMember
|
||||
//- #6854: on Home and All Boards too, not only on a board.
|
||||
if announcementForUser
|
||||
if hasAnnouncement
|
||||
.announcement(role="status" aria-live="polite")
|
||||
p
|
||||
|
||||
@@ -7,6 +7,7 @@ import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
|
||||
import Announcements, {
|
||||
announcementVersion,
|
||||
shouldShowAnnouncement,
|
||||
showAnnouncementToUser,
|
||||
} from '/models/announcements';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
// What this bar calls the page you are on. models/lib/pageTitles.js
|
||||
@@ -58,6 +59,7 @@ function customPageTitle(routeName) {
|
||||
// Drag-to-scroll on the two top header bars (they are not scroll containers
|
||||
// themselves, so the drag is forwarded to the board canvas / page scroller).
|
||||
import '/client/lib/headerDragscroll';
|
||||
const fullscreen = require('/models/lib/fullscreen');
|
||||
|
||||
Meteor.subscribe('user-admin');
|
||||
Meteor.subscribe('boards');
|
||||
@@ -69,6 +71,10 @@ Meteor.subscribe('setting');
|
||||
Meteor.subscribe('announcements');
|
||||
Template.header.onCreated(function () {
|
||||
const templateInstance = this;
|
||||
// Full screen: whether the browser offers it here, and whether the page is in it.
|
||||
// Both are set when the header is drawn (onRendered), where there is a document.
|
||||
templateInstance.fullscreenSupported = new ReactiveVar(false);
|
||||
templateInstance.isFullscreen = new ReactiveVar(false);
|
||||
templateInstance.currentSetting = new ReactiveVar();
|
||||
templateInstance.isLoading = new ReactiveVar(false);
|
||||
templateInstance.subscribe('setting');
|
||||
@@ -82,6 +88,14 @@ Template.header.onCreated(function () {
|
||||
|
||||
});
|
||||
Template.header.onRendered(function () {
|
||||
// The Fullscreen API, where it exists and the page may use it (models/lib/fullscreen.js).
|
||||
// The state follows the browser's own events, so leaving with Esc, or with the
|
||||
// browser's own control, changes the icon too.
|
||||
this.fullscreenSupported.set(fullscreen.isSupported(document));
|
||||
this.isFullscreen.set(fullscreen.isFullscreen(document));
|
||||
this.onFullscreenChange = () => this.isFullscreen.set(fullscreen.isFullscreen(document));
|
||||
fullscreen.CHANGE_EVENTS.forEach(name => document.addEventListener(name, this.onFullscreenChange));
|
||||
|
||||
this.autorun(() => {
|
||||
Meteor.userId();
|
||||
FlowRouter.watchPathChange();
|
||||
@@ -91,7 +105,19 @@ Template.header.onRendered(function () {
|
||||
});
|
||||
});
|
||||
|
||||
Template.header.onDestroyed(function () {
|
||||
if (this.onFullscreenChange) {
|
||||
fullscreen.CHANGE_EVENTS.forEach(name => document.removeEventListener(name, this.onFullscreenChange));
|
||||
}
|
||||
});
|
||||
|
||||
Template.header.helpers({
|
||||
fullscreenSupported() {
|
||||
return Template.instance().fullscreenSupported.get();
|
||||
},
|
||||
isFullscreen() {
|
||||
return Template.instance().isFullscreen.get();
|
||||
},
|
||||
logoSettingsReady() {
|
||||
return Template.instance().subscriptionsReady();
|
||||
},
|
||||
@@ -316,6 +342,14 @@ Template.header.helpers({
|
||||
return ['waiting', 'failed', 'offline'].includes(status.status);
|
||||
},
|
||||
|
||||
announcementForUser() {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
return showAnnouncementToUser({
|
||||
userId: user && user._id,
|
||||
board: Utils.getCurrentBoard(),
|
||||
});
|
||||
},
|
||||
|
||||
hasAnnouncement() {
|
||||
const announcement = Announcements.findOne();
|
||||
if (!announcement || !announcement.enabled) {
|
||||
@@ -356,6 +390,13 @@ Template.header.helpers({
|
||||
});
|
||||
|
||||
Template.header.events({
|
||||
// Full screen on and off. The request is made inside this click, which is the
|
||||
// user gesture browsers require; a refusal is not an error (models/lib/fullscreen.js).
|
||||
'click .js-toggle-fullscreen'(event) {
|
||||
event.preventDefault();
|
||||
fullscreen.toggle(document);
|
||||
},
|
||||
|
||||
// The starred boards, by name. They were listed inline in this bar; the bar
|
||||
// is one row now and the names are in the popup. docs/Features/Header-Bar/Header.md
|
||||
// Titled "Starred Boards", from the key the app already has for that phrase,
|
||||
|
||||
@@ -7,7 +7,7 @@ template(name="historyRecoveryNotice")
|
||||
.history-recovery-notice.js-history-recovery-notice(role="status" aria-live="polite")
|
||||
p {{_ pending.messageKey}}
|
||||
p.quiet {{_ 'history-request-hint'}}
|
||||
button.primary.js-history-retry(type="button" disabled=busy) {{_ 'history-request-retry'}}
|
||||
button.primary.js-history-retry(type="button" disabled=retryDisabled) {{_ 'history-request-retry'}}
|
||||
button.js-history-forget(type="button" disabled=busy) {{_ 'history-request-forget'}}
|
||||
if checkpoint
|
||||
.history-recovery-notice.history-checkpoint-notice.js-history-checkpoint-notice(role="status" aria-live="polite" class=checkpointPlacement)
|
||||
|
||||
@@ -52,6 +52,13 @@ Template.historyRecoveryNotice.helpers({
|
||||
busy() {
|
||||
return Template.instance().busy.get();
|
||||
},
|
||||
// undoRedoLast does nothing for someone who may not write on the board, and
|
||||
// right after a reload that is everyone until the board and its members
|
||||
// arrive: the notice shows first, and a Retry clicked then was dropped
|
||||
// without a word. It is enabled once the retry can act.
|
||||
retryDisabled() {
|
||||
return Template.instance().busy.get() || !Utils.canModifyBoard();
|
||||
},
|
||||
});
|
||||
|
||||
async function resolve(tpl, action) {
|
||||
|
||||
@@ -4,7 +4,8 @@ template(name="shortcutsModalTitle")
|
||||
| {{_ 'keyboard-shortcuts'}}
|
||||
|
||||
template(name="keyboardShortcuts")
|
||||
.wrapper.shortcuts-list
|
||||
//- tabindex: the list scrolls when the window is short, and a scrolling region must be reachable by keyboard (WCAG 2.1.1; axe scrollable-region-focusable).
|
||||
.wrapper.shortcuts-list(tabindex="0" role="region" aria-label="{{_ 'keyboard-shortcuts'}}")
|
||||
each mapping
|
||||
.shortcuts-list-item
|
||||
.shortcuts-list-item-keys
|
||||
|
||||
@@ -49,6 +49,20 @@ summary:focus-visible,
|
||||
box-shadow: 0 0 0 4px rgba(21, 101, 192, 0.35);
|
||||
border-radius: 2px;
|
||||
}
|
||||
/* #6755: a member who turned off "Show focus outline" (Member Settings / Change
|
||||
* Settings) sees no ring: the outline and its halo, for keyboard focus and for the
|
||||
* fallback below. Field borders and the colour a button takes on focus are not the
|
||||
* ring and stay. The ring is an accessibility feature, so it is on unless hidden. */
|
||||
body.has-hide-focus-outline :focus-visible,
|
||||
body.has-hide-focus-outline a:focus,
|
||||
body.has-hide-focus-outline button:focus,
|
||||
body.has-hide-focus-outline [tabindex]:focus {
|
||||
outline: none !important;
|
||||
outline-offset: 0 !important;
|
||||
}
|
||||
body.has-hide-focus-outline :is(a, button, input, select, textarea, summary, [tabindex], [role="button"], [contenteditable="true"]):focus-visible {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
/* Browsers that do not support :focus-visible fall back to :focus so keyboard
|
||||
* users are never left without an indicator. */
|
||||
.no-focus-visible-support a:focus,
|
||||
@@ -378,14 +392,14 @@ h6 {
|
||||
}
|
||||
.quiet,
|
||||
.quiet a {
|
||||
color: #8c8c8c;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
}
|
||||
.error,
|
||||
.error a {
|
||||
color: #eb3800;
|
||||
}
|
||||
.no-items-message {
|
||||
color: #666;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
margin: 36px 0;
|
||||
text-align: center;
|
||||
}
|
||||
@@ -1133,6 +1147,77 @@ a:not(.disabled).is-active i.fa {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
/* The administrator's login page background colour (layouts.js sets the two
|
||||
custom properties and the class while the sign-in layout is shown). The
|
||||
foreground is white on a dark colour and black on a light one. */
|
||||
body.has-login-background {
|
||||
background: var(--login-background);
|
||||
}
|
||||
body.has-login-background .textBelowCustomLoginLogo,
|
||||
body.has-login-background .textBelowCustomLoginLogo a {
|
||||
color: var(--login-foreground);
|
||||
}
|
||||
body.has-login-background hr {
|
||||
border-color: var(--login-foreground);
|
||||
opacity: 0.4;
|
||||
}
|
||||
/* The text under the login logo: one column, real headings, space between
|
||||
paragraphs. It has no card or background of its own, so the page colour shows
|
||||
through. */
|
||||
.textBelowCustomLoginLogo > * {
|
||||
width: 100%;
|
||||
max-width: 860px;
|
||||
box-sizing: border-box;
|
||||
padding: 0 16px;
|
||||
}
|
||||
.textBelowCustomLoginLogo h1,
|
||||
.textBelowCustomLoginLogo h2,
|
||||
.textBelowCustomLoginLogo h3,
|
||||
.support-page-content h1,
|
||||
.support-page-content h2,
|
||||
.support-page-content h3 {
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
margin: 1em 0 0.5em;
|
||||
}
|
||||
.textBelowCustomLoginLogo h1,
|
||||
.support-page-content h1 { font-size: 1.8em; }
|
||||
.textBelowCustomLoginLogo h2,
|
||||
.support-page-content h2 { font-size: 1.45em; }
|
||||
.textBelowCustomLoginLogo h3,
|
||||
.support-page-content h3 { font-size: 1.2em; }
|
||||
.textBelowCustomLoginLogo p,
|
||||
.textBelowCustomLoginLogo ul,
|
||||
.textBelowCustomLoginLogo ol,
|
||||
.support-page-content p,
|
||||
.support-page-content ul,
|
||||
.support-page-content ol {
|
||||
margin: 0 0 1em;
|
||||
}
|
||||
/* The support page: a centred column instead of text flush against the window's
|
||||
left edge. */
|
||||
.support-page {
|
||||
padding: 24px 16px;
|
||||
}
|
||||
.support-page-content {
|
||||
box-sizing: border-box;
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 28px;
|
||||
background: #fff;
|
||||
color: #1a1a1a;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.support-page {
|
||||
padding: 8px;
|
||||
}
|
||||
.support-page-content {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
.auth-layout .auth-dialog {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
@@ -27,11 +27,12 @@ template(name="userFormsLayout")
|
||||
section.auth-layout
|
||||
if logoSettingsReady
|
||||
if currentSetting.hideLogo
|
||||
h1.at-form-landing-logo
|
||||
//- A spacer, not a heading: an empty h1 is announced as an empty heading.
|
||||
div.at-form-landing-logo(aria-hidden="true")
|
||||
br
|
||||
br
|
||||
unless currentSetting.hideLogo
|
||||
h1.at-form-landing-logo
|
||||
div.at-form-landing-logo(aria-hidden="true")
|
||||
if currentSetting.customLoginLogoImageUrl
|
||||
if currentSetting.customLoginLogoLinkUrl
|
||||
a(href="{{currentSetting.customLoginLogoLinkUrl}}")
|
||||
@@ -49,13 +50,15 @@ template(name="userFormsLayout")
|
||||
+viewer
|
||||
| {{currentSetting.textBelowCustomLoginLogo}}
|
||||
hr
|
||||
section.auth-layout
|
||||
//- role="main": the sign-in and register form is the page's main landmark
|
||||
(WCAG 1.3.1 / 2.4.1), so the title, the links and the language picker are in one.
|
||||
section.auth-layout(role="main")
|
||||
section.auth-dialog
|
||||
if isLoading
|
||||
+loader
|
||||
else
|
||||
// ARIA live region for error messages
|
||||
div#login-error-message(role="alert" aria-live="assertive" style="color: #d32f2f; margin-bottom: 1em;")
|
||||
div#login-error-message(role="alert" aria-live="assertive" style="color: #d32f2f; margin-bottom: 1em;")= loginErrorText
|
||||
// Second sign-in step for accounts-2fa (issue #3058). Hidden unless a
|
||||
// password login comes back with the "no-2fa-code" / "invalid-2fa-code"
|
||||
// error - see decideTwoFactorLoginStep() in
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { TAPi18n } from '/imports/i18n';
|
||||
const { OAUTH_PROVIDERS, providerByKey } = require('/models/lib/oauthProviders');
|
||||
const { loginOriginMismatch, offersProviderLogin } = require('/models/lib/loginOriginMismatch');
|
||||
const { loginBackgroundStyle } = require('/models/lib/loginBackground');
|
||||
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
|
||||
import { Accounts } from 'meteor/accounts-base';
|
||||
import Users from '/models/users';
|
||||
@@ -303,6 +304,7 @@ Template.userFormsLayout.onDestroyed(function () {
|
||||
});
|
||||
|
||||
Template.userFormsLayout.helpers({
|
||||
loginErrorText() { return loginErrorText.get(); },
|
||||
passkeysSupported() { return require('/client/lib/passkeys').passkeysSupported(); },
|
||||
passkeyMessage() { return Template.instance().passkeyMessage.get(); },
|
||||
windowsSsoOffered() { return Meteor.settings?.public?.windowsSsoEnabled === true; },
|
||||
@@ -557,20 +559,23 @@ Tracker.autorun(() => {
|
||||
}
|
||||
});
|
||||
|
||||
// The message in #login-error-message, rendered by the template as text. Not
|
||||
// written into the element: it is inside `if isLoading`, and when that flips
|
||||
// Blaze draws a new, empty element - a SAML popup error shown just before
|
||||
// then was gone (saml-popup-error.e2e.js, Firefox). Text, never HTML.
|
||||
const loginErrorText = new ReactiveVar('');
|
||||
function showLoginError(err) {
|
||||
const errorDiv = document.getElementById('login-error-message');
|
||||
if (!errorDiv) return;
|
||||
if (!err) {
|
||||
errorDiv.textContent = '';
|
||||
loginErrorText.set('');
|
||||
return;
|
||||
}
|
||||
const reason = err.reason || err.message || '';
|
||||
// #2460: an account its member locked to SQRL says so in their language.
|
||||
const translated = ['oauth-account-conflict', 'sqrl-only', 'sqrl-hardlock']
|
||||
.find(key => err.error === key || reason === key);
|
||||
errorDiv.textContent = translated
|
||||
loginErrorText.set(translated
|
||||
? TAPi18n.__(translated)
|
||||
: reason || String(err.error || err);
|
||||
: reason || String(err.error || err));
|
||||
}
|
||||
|
||||
Template.defaultLayout.events({
|
||||
@@ -668,6 +673,56 @@ Template.userFormsLayout.onDestroyed(function () {
|
||||
this.removeAuthenticationSubmit?.();
|
||||
});
|
||||
|
||||
// The administrator's login page background colour (Admin Panel -> Lay-out).
|
||||
// It is put on <body> only while the sign-in layout is shown, as two custom
|
||||
// properties and a class, and taken off again when it closes so the pages after
|
||||
// sign-in keep their own background. loginBackgroundStyle accepts a hex colour
|
||||
// only, so the value can never carry CSS of its own.
|
||||
const LOGIN_BACKGROUND_PROPERTIES = ['--login-background', '--login-foreground'];
|
||||
|
||||
function clearLoginBackground() {
|
||||
document.body.classList.remove('has-login-background');
|
||||
LOGIN_BACKGROUND_PROPERTIES.forEach(name => document.body.style.removeProperty(name));
|
||||
}
|
||||
|
||||
Template.userFormsLayout.onRendered(function () {
|
||||
this.autorun(() => {
|
||||
const setting = this.currentSetting.get();
|
||||
const style = loginBackgroundStyle(setting && setting.loginBackgroundColor);
|
||||
if (!style) {
|
||||
clearLoginBackground();
|
||||
return;
|
||||
}
|
||||
document.body.style.setProperty('--login-background', style.background);
|
||||
document.body.style.setProperty('--login-foreground', style.foreground);
|
||||
document.body.classList.add('has-login-background');
|
||||
});
|
||||
});
|
||||
|
||||
Template.userFormsLayout.onDestroyed(clearLoginBackground);
|
||||
|
||||
// The form's title ("Sign In", "Create an Account") is an h3 of the useraccounts
|
||||
// package and the first heading of the page: a level-one heading, whatever the
|
||||
// package draws it as (WCAG 1.3.1; axe page-has-heading-one and heading-order).
|
||||
// The package redraws the form when the user moves between sign in and register,
|
||||
// so the title is marked again whenever the dialog changes.
|
||||
Template.userFormsLayout.onRendered(function () {
|
||||
const dialog = this.find('.auth-dialog');
|
||||
if (!dialog) return;
|
||||
const mark = () => {
|
||||
dialog.querySelectorAll('.at-title h1, .at-title h2, .at-title h3').forEach(heading => {
|
||||
if (heading.getAttribute('aria-level') !== '1') heading.setAttribute('aria-level', '1');
|
||||
});
|
||||
};
|
||||
mark();
|
||||
this.titleObserver = new MutationObserver(mark);
|
||||
this.titleObserver.observe(dialog, { childList: true, subtree: true });
|
||||
});
|
||||
|
||||
Template.userFormsLayout.onDestroyed(function () {
|
||||
if (this.titleObserver) this.titleObserver.disconnect();
|
||||
});
|
||||
|
||||
function getAuthenticationMethod(
|
||||
settings,
|
||||
match,
|
||||
|
||||
@@ -111,7 +111,7 @@
|
||||
/* Disabled = the same themed outline, faded. Not a grey block. */
|
||||
/* A disabled pager still has to look like the THEME. It is filled with the
|
||||
theme colour, and dimming it is what takes the theme back out: at 0.4 the
|
||||
belize blue #2980b9 arrived on the page's grey as rgb(122,170,202), a pale
|
||||
belize blue #2573a7 arrived on the page's grey as rgb(122,170,202), a pale
|
||||
wash with no family resemblance to the header above it - and on a one-page
|
||||
list, where BOTH arrows are disabled, that is the whole pager.
|
||||
|
||||
|
||||
@@ -39,6 +39,10 @@
|
||||
first row of a popup, and it must match the space beside it: `.content` has 18px of
|
||||
side padding, so 6 + 12 = 18 makes the top gap and the left gutter the same. */
|
||||
.pop-over .header {
|
||||
/* A popup is moved by its title bar (popup.js, Pointer Events). Without this a
|
||||
finger drag on it is taken for a scroll and the pointer is cancelled, so only a
|
||||
mouse could move it. */
|
||||
touch-action: none;
|
||||
height: calc(41px * var(--wekan-ui-font-scale, 1));
|
||||
position: relative;
|
||||
margin-bottom: 6px;
|
||||
@@ -807,7 +811,7 @@ html[dir='rtl'] .pop-over > button.js-date-popup-resize > span {
|
||||
above pins every row's text to `#000 !important`; the icon is named separately
|
||||
for the same reason. */
|
||||
.pop-over-list li > a:not(.disabled):hover {
|
||||
background-color: var(--theme-accent, #2980b9);
|
||||
background-color: var(--theme-accent, #2573a7);
|
||||
color: #fff !important;
|
||||
}
|
||||
.pop-over-list li > a:not(.disabled):hover i,
|
||||
@@ -822,7 +826,7 @@ html[dir='rtl'] .pop-over > button.js-date-popup-resize > span {
|
||||
background: #fff;
|
||||
}
|
||||
.pop-over-list li > a .sub-name {
|
||||
color: #8c8c8c;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
display: block;
|
||||
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
|
||||
font-weight: 400;
|
||||
@@ -867,7 +871,7 @@ html[dir='rtl'] .pop-over > button.js-date-popup-resize > span {
|
||||
background-color: #2e85b8;
|
||||
}
|
||||
.pop-over-list li > a.disabled {
|
||||
color: #8c8c8c;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
cursor: default;
|
||||
}
|
||||
.pop-over-list li > a.disabled .vis-icon {
|
||||
@@ -878,7 +882,7 @@ html[dir='rtl'] .pop-over > button.js-date-popup-resize > span {
|
||||
}
|
||||
.pop-over-list li > a.disabled:hover .sub-name,
|
||||
.pop-over-list li > a.disabled:hover .quiet {
|
||||
color: #8c8c8c;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
}
|
||||
.pop-over-list li > a.disabled:active {
|
||||
background: none;
|
||||
@@ -972,7 +976,7 @@ html[dir='rtl'] .pop-over > button.js-date-popup-resize > span {
|
||||
user's own theme, the board's colour or the site theme - whichever is in
|
||||
force. The fallback is the stock WeKan blue, so an unthemed instance is
|
||||
unchanged. */
|
||||
background: var(--theme-accent, #2980b9);
|
||||
background: var(--theme-accent, #2573a7);
|
||||
height: calc(48px * var(--wekan-ui-font-scale, 1));
|
||||
padding: 0px 0px;
|
||||
border: 0px;
|
||||
@@ -1441,14 +1445,24 @@ body.mobile-mode .pop-over .content-wrapper {
|
||||
button (popup.js MOVABLE_POPUPS / MAXIMIZABLE_POPUPS), placed like the opened
|
||||
card's: the handle before the title, the button beside the close button. */
|
||||
.pop-over .header .popup-drag-handle {
|
||||
touch-action: none;
|
||||
float: inline-start;
|
||||
padding: 12px 0 12px 12px;
|
||||
cursor: move;
|
||||
color: #888;
|
||||
}
|
||||
.pop-over .header:has(.popup-drag-handle) {
|
||||
/* #6839: the title bar moves every popup while drag handles are off; with
|
||||
them on, the handle does, and the bar is an ordinary title. */
|
||||
.pop-over .header {
|
||||
cursor: move;
|
||||
}
|
||||
.pop-over .header:has(.popup-drag-handle) {
|
||||
cursor: auto;
|
||||
}
|
||||
.pop-over .header a,
|
||||
.pop-over .header button {
|
||||
cursor: pointer;
|
||||
}
|
||||
.pop-over .header .popup-maximize-btn {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
|
||||
@@ -29,8 +29,11 @@ function resizeDatePopup(element, width, height) {
|
||||
Object.entries(styles).forEach(([key, value]) => element.style.setProperty(key, value, 'important'));
|
||||
}
|
||||
|
||||
// Popups dragged by their title bar (or the drag handle beside it), as the
|
||||
// opened card is. A date popup is movable too, found by its .edit-date.
|
||||
// #6839: EVERY popup is moved like the opened card: by its title bar when drag
|
||||
// handles are off, by the drag handle before its title when they are on - the
|
||||
// handle alone, then, as the card's. These are the popups that may also GROW
|
||||
// past the window while open (Drawing replacing a menu), so they are kept
|
||||
// inside it as they grow; so is any popup the user has dragged.
|
||||
const MOVABLE_POPUPS = new Set(['boardViewSettingsPopup', 'cardDrawingPopup', 'boardCardSettingsPopup']);
|
||||
// Popups with the opened card's maximize / minimize button.
|
||||
const MAXIMIZABLE_POPUPS = new Set(['cardDrawingPopup', 'boardCardSettingsPopup']);
|
||||
@@ -66,7 +69,7 @@ Popup.template.onCreated(function () {
|
||||
|
||||
Popup.template.helpers({
|
||||
popupMovable() {
|
||||
return MOVABLE_POPUPS.has(this.popupName);
|
||||
return true;
|
||||
},
|
||||
popupMaximizable() {
|
||||
return MAXIMIZABLE_POPUPS.has(this.popupName);
|
||||
@@ -87,21 +90,43 @@ Popup.template.events({
|
||||
'pointerdown .header'(evt, tpl) {
|
||||
const element = evt.currentTarget.closest('.pop-over');
|
||||
// A maximized popup has no place to be dragged to.
|
||||
// With drag handles on, the handle is the only thing that moves it.
|
||||
const handle = element.querySelector('.js-popup-drag-handle');
|
||||
if (evt.button !== 0 || element.classList.contains('pop-over--maximized') ||
|
||||
(!element.querySelector('.edit-date') && !MOVABLE_POPUPS.has(element.dataset.popup)) ||
|
||||
(handle && !evt.target.closest('.js-popup-drag-handle')) ||
|
||||
evt.target.closest('a, button, input, select')) return;
|
||||
evt.preventDefault(); evt.stopPropagation();
|
||||
const bounds = element.getBoundingClientRect();
|
||||
// Pin it where it is before it moves. A popup is placed by its margins, by
|
||||
// inset-inline-end (the member menu, at the right edge) or by left/top, so
|
||||
// `left: bounds.left` alone put some of them elsewhere - one jumped across
|
||||
// the window as the drag began. Set it, see where it went, and correct.
|
||||
const place = (left, top) => {
|
||||
element.style.setProperty('inset-inline-start', 'auto', 'important');
|
||||
element.style.setProperty('inset-inline-end', 'auto', 'important');
|
||||
element.style.setProperty('left', `${left}px`, 'important');
|
||||
element.style.setProperty('top', `${top}px`, 'important');
|
||||
};
|
||||
place(bounds.left, bounds.top);
|
||||
const placed = element.getBoundingClientRect();
|
||||
const left = bounds.left + (bounds.left - placed.left);
|
||||
const top = bounds.top + (bounds.top - placed.top);
|
||||
place(left, top);
|
||||
element.dataset.userMoved = 'true';
|
||||
tpl._dateMove = { element, pointerId: evt.pointerId, x: evt.clientX, y: evt.clientY,
|
||||
left: bounds.left, top: bounds.top, width: bounds.width, height: bounds.height };
|
||||
left, top, width: bounds.width, height: bounds.height,
|
||||
// The clamp keeps the popup's VISIBLE box 12px inside the window.
|
||||
dx: bounds.left - left, dy: bounds.top - top };
|
||||
evt.currentTarget.setPointerCapture(evt.pointerId);
|
||||
},
|
||||
'pointermove .header'(evt, tpl) {
|
||||
const drag = tpl._dateMove;
|
||||
if (!drag || drag.pointerId !== evt.pointerId) return;
|
||||
evt.preventDefault(); evt.stopPropagation();
|
||||
const left = Math.max(12, Math.min(window.innerWidth - drag.width - 12, drag.left + evt.clientX - drag.x));
|
||||
const top = Math.max(12, Math.min(window.innerHeight - drag.height - 12, drag.top + evt.clientY - drag.y));
|
||||
const boxLeft = Math.max(12, Math.min(window.innerWidth - drag.width - 12, drag.left + drag.dx + evt.clientX - drag.x));
|
||||
const boxTop = Math.max(12, Math.min(window.innerHeight - drag.height - 12, drag.top + drag.dy + evt.clientY - drag.y));
|
||||
const left = boxLeft - drag.dx;
|
||||
const top = boxTop - drag.dy;
|
||||
// Dragging uses physical pointer coordinates in either text direction.
|
||||
drag.element.style.setProperty('inset-inline-start', 'auto', 'important');
|
||||
drag.element.style.setProperty('inset-inline-end', 'auto', 'important');
|
||||
@@ -221,7 +246,8 @@ Popup.template.onRendered(function () {
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
const element = this._popupElement;
|
||||
this._boundsObserver = new ResizeObserver(() => {
|
||||
if (!MOVABLE_POPUPS.has(element.dataset.popup) || element.classList.contains('pop-over--maximized')) return;
|
||||
if (!(MOVABLE_POPUPS.has(element.dataset.popup) || element.dataset.userMoved)
|
||||
|| element.classList.contains('pop-over--maximized')) return;
|
||||
const box = element.getBoundingClientRect();
|
||||
const moved = keptInside(box, window.innerWidth, window.innerHeight);
|
||||
if (!moved) return;
|
||||
|
||||
@@ -14,10 +14,10 @@
|
||||
.header
|
||||
a.back-btn.js-back-view(class="{{#unless hasPopupParent}}is-hidden{{/unless}}" aria-label="{{_ 'go-back'}}")
|
||||
i.fa.fa-caret-left(aria-hidden="true")
|
||||
//- A movable popup (popup.js MOVABLE_POPUPS) is dragged by its title bar
|
||||
or this handle, like the opened card - and, like the card's, the handle
|
||||
is there only while drag handles are on; a maximizable one has the
|
||||
card's maximize / minimize button beside its close button.
|
||||
//- #6839: every popup is moved like the opened card - by its title bar
|
||||
while drag handles are off, by this handle alone while they are on
|
||||
(popup.js); a maximizable one has the card's maximize / minimize button
|
||||
beside its close button.
|
||||
if popupMovable
|
||||
if isTouchScreenOrShowDesktopDragHandles
|
||||
span.popup-drag-handle.js-popup-drag-handle(title="{{_ 'drag-to-move'}}")
|
||||
|
||||
@@ -43,3 +43,6 @@ template(name="themeColorPicker")
|
||||
input.js-theme-wheel(type="color" data-index="{{index}}" value="{{value}}" title="{{_ 'custom-color'}}")
|
||||
.theme-preview
|
||||
span.background-box(class="{{previewClass}}" style="{{previewStyle}}")
|
||||
//- WCAG 1.4.3: a colour that white text cannot be read on is darkened; this says so.
|
||||
if customAdjusted
|
||||
p.theme-custom-adjusted.js-theme-custom-adjusted {{_ 'custom-color'}}: {{customAdjusted}} (4.5:1)
|
||||
@@ -2,7 +2,7 @@ import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { ReactiveVar } from 'meteor/reactive-var';
|
||||
import { TAPi18n } from '/imports/i18n';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import { isHexColor, toHex } from '/models/lib/contrastColor';
|
||||
import { isHexColor, toHex, readableAccent } from '/models/lib/contrastColor';
|
||||
import { BOARD_COLORS } from '/models/metadata/colors';
|
||||
import {
|
||||
THEME_CATEGORY_ORDER,
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
// 3. the user's own override
|
||||
// See docs/Features/General/Theme/Change-Color.md for the whole design.
|
||||
|
||||
const DEFAULT_WHEEL = ['#2980b9', '#6dd5fa']; // stock flat accent / second slide stop
|
||||
const DEFAULT_WHEEL = ['#2573a7', '#6dd5fa']; // stock flat accent / second slide stop
|
||||
|
||||
function readCurrent(scope) {
|
||||
if (scope === 'board') {
|
||||
@@ -138,7 +138,10 @@ Template.themeColorPicker.helpers({
|
||||
const tpl = Template.instance();
|
||||
const cat = customCategory(tpl);
|
||||
if (!allowsCustomColor(cat)) return '';
|
||||
const cc = tpl.customColors.get();
|
||||
// The colours as the theme applies them: made readable under white text
|
||||
// (models/lib/themeAccents.js activeAccent), so the preview is what the header
|
||||
// will be, not what the wheel showed.
|
||||
const cc = tpl.customColors.get().map(c => (c ? readableAccent(c) : c));
|
||||
if (!cc.some(Boolean)) return '';
|
||||
if (customColorCount(cat) === 2 && cc[0] && cc[1]) {
|
||||
return `background: linear-gradient(135deg, ${cc[0]}, ${cc[1]}) !important;`;
|
||||
@@ -146,6 +149,13 @@ Template.themeColorPicker.helpers({
|
||||
if (cc[0]) return `background: ${cc[0]} !important;`;
|
||||
return '';
|
||||
},
|
||||
// The colours that had to change to be readable, for the note under the preview.
|
||||
customAdjusted() {
|
||||
const tpl = Template.instance();
|
||||
const chosen = tpl.customColors.get().filter(Boolean).map(c => toHex(c)).filter(Boolean);
|
||||
const changed = chosen.map(c => readableAccent(c)).filter((c, i) => c !== chosen[i]);
|
||||
return changed.join(' ');
|
||||
},
|
||||
});
|
||||
|
||||
// Collect the custom colors to save: [] unless the user actually engaged a wheel, in
|
||||
|
||||
@@ -61,9 +61,20 @@ html.has-ui-font-size {
|
||||
* follow this anyway; the ones with a colour of their own - a red alert, a
|
||||
* green tick - keep saying what they mean. */
|
||||
body.has-ui-text-color,
|
||||
body.has-ui-text-color *:not(.fa) {
|
||||
body.has-ui-text-color *:not(.fa):not(
|
||||
#header, #header *,
|
||||
#header-quick-access, #header-quick-access *,
|
||||
.card-label, .card-label *,
|
||||
button.primary, button.primary *
|
||||
) {
|
||||
color: var(--wekan-ui-text-color) !important;
|
||||
}
|
||||
/* The elements left out of the rule above sit on a COLOUR of their own - the
|
||||
* theme's header bars, a label's colour, a primary button's accent - with a text
|
||||
* colour that is chosen to read on it (4.5:1, WCAG 1.4.3). The member's colour is
|
||||
* made legible on the page's surfaces (models/lib/uiFonts.js readableUiTextColor),
|
||||
* which is not the same as legible on those: #626262 on the blue header was
|
||||
* 1.18:1. */
|
||||
|
||||
/* There was a "text background color" here (#4759), a colour painted behind the
|
||||
* text. It is REMOVED. A background needs elements to sit on, and neither choice
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { Tracker } from 'meteor/tracker';
|
||||
import { fontFamilyValue, fontSizeValue, fontScaleValue, colorValue } from '/models/lib/uiFonts';
|
||||
import { fontFamilyValue, fontSizeValue, fontScaleValue, readableUiTextColor } from '/models/lib/uiFonts';
|
||||
import { activeThemeName } from '/client/lib/activeTheme';
|
||||
|
||||
// #4759: apply the user's chosen UI font AND font-size preset to the whole UI by
|
||||
// setting CSS variables on :root and toggling marker classes on <body> (uiFont.css
|
||||
@@ -37,6 +38,17 @@ Meteor.startup(() => {
|
||||
}
|
||||
}
|
||||
|
||||
// #6755: a member who hid the focus outline (Member Settings / Change Settings):
|
||||
// a class on <body>, which layouts.css turns into no ring.
|
||||
Tracker.autorun(() => {
|
||||
const user = Meteor.user();
|
||||
try {
|
||||
document.body.classList.toggle('has-hide-focus-outline', !!(user && user.profile && user.profile.hideFocusOutline === true));
|
||||
} catch (_) {
|
||||
// document.body may not exist yet in exotic embeddings; ignore.
|
||||
}
|
||||
});
|
||||
|
||||
Tracker.autorun(() => {
|
||||
const user = Meteor.user();
|
||||
const profile = (user && user.profile) || {};
|
||||
@@ -50,7 +62,9 @@ Meteor.startup(() => {
|
||||
// of it that happened to be written in rem. No class goes with it - a
|
||||
// variable that is not set falls back to 1 in the calc itself.
|
||||
toggle('--wekan-ui-font-scale', null, fontScaleValue(profile.uiFontSize));
|
||||
toggle('--wekan-ui-text-color', 'has-ui-text-color', colorValue(profile.uiTextColor));
|
||||
// The chosen colour, made legible on the surface of the theme in use (4.5:1,
|
||||
// WCAG 1.4.3): a colour that already reads is applied as chosen.
|
||||
toggle('--wekan-ui-text-color', 'has-ui-text-color', readableUiTextColor(profile.uiTextColor, activeThemeName()));
|
||||
// There was a `--wekan-ui-bg-color` here, for "text background color". It is
|
||||
// removed: a colour behind the text needs elements to sit on, and there is no
|
||||
// set of them that looks right - on the boxes it painted the page, on the text
|
||||
|
||||
@@ -22,7 +22,7 @@ section#notifications-drawer {
|
||||
padding-top: 36px;
|
||||
}
|
||||
section#notifications-drawer a:hover {
|
||||
color: #2980b9 !important;
|
||||
color: #2573a7 !important;
|
||||
}
|
||||
section#notifications-drawer .header {
|
||||
position: fixed;
|
||||
@@ -47,7 +47,7 @@ section#notifications-drawer .header .notification-menu-toggle {
|
||||
line-height: calc(24px * var(--wekan-ui-font-scale, 1));
|
||||
}
|
||||
section#notifications-drawer .header .notification-menu-toggle:hover {
|
||||
color: #2980b9;
|
||||
color: #2573a7;
|
||||
}
|
||||
section#notifications-drawer .header .notification-menu {
|
||||
position: absolute;
|
||||
@@ -90,7 +90,7 @@ section#notifications-drawer .header .notification-menu .menu-item .check-icon {
|
||||
min-width: 20px;
|
||||
margin-inline-end: 8px;
|
||||
text-align: center;
|
||||
color: #2980b9;
|
||||
color: #2573a7;
|
||||
font-weight: bold;
|
||||
}
|
||||
section#notifications-drawer .header .notification-menu .menu-item .menu-icon {
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
}
|
||||
/* Drag-and-drop workflow editor */
|
||||
.rules-workflow .workflow-help {
|
||||
color: #727479;
|
||||
color: var(--wekan-quiet-text, #5a5c61);
|
||||
font-size: calc(13px * var(--wekan-ui-font-scale, 1));
|
||||
margin-bottom: 9px;
|
||||
}
|
||||
@@ -247,6 +247,33 @@
|
||||
.rules-back .js-goback .fa-arrow-left {
|
||||
color: #fff !important;
|
||||
}
|
||||
/* #6852: Back to the board, at the top of the Rules page. */
|
||||
.rules-back-to-board {
|
||||
margin-block: 8px;
|
||||
margin-inline: 14px;
|
||||
}
|
||||
/* A link, so it opens the board as a link does (also in a new tab), drawn as
|
||||
the primary buttons around it: forms.css styles only button.primary. */
|
||||
.rules-back-to-board .js-rules-back-to-board {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 20px;
|
||||
border-radius: 3px;
|
||||
background: var(--theme-accent, #005377);
|
||||
box-shadow: 0 1px 0 #4d4d4d;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
text-decoration: none;
|
||||
}
|
||||
.rules-back-to-board .js-rules-back-to-board:hover,
|
||||
.rules-back-to-board .js-rules-back-to-board:focus {
|
||||
background: var(--theme-accent, #004766);
|
||||
color: #fff;
|
||||
}
|
||||
.rules-back-to-board .js-rules-back-to-board .fa-arrow-left {
|
||||
color: #fff !important;
|
||||
}
|
||||
.flex,
|
||||
.triggers-content .triggers-body .triggers-side-menu ul li a {
|
||||
display: -webkit-box;
|
||||
@@ -257,7 +284,7 @@
|
||||
display: flex;
|
||||
}
|
||||
.triggers-content {
|
||||
color: #727479;
|
||||
color: var(--wekan-quiet-text, #5a5c61);
|
||||
background: #dedede;
|
||||
}
|
||||
.triggers-content .triggers-body {
|
||||
@@ -410,7 +437,7 @@
|
||||
}
|
||||
.rules-import-export .rules-ie-hint {
|
||||
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
|
||||
color: #727479;
|
||||
color: var(--wekan-quiet-text, #5a5c61);
|
||||
margin-bottom: 0.3em;
|
||||
}
|
||||
.rules-import-export .rules-ie-textarea {
|
||||
|
||||
@@ -6,6 +6,15 @@
|
||||
template(name="rulesMain")
|
||||
.wrapper
|
||||
.rules-page(class=currentBoard.colorClass)
|
||||
//- #6852: the way back to the board. The trigger, action and rule
|
||||
details pages have their own Back, to the rules list, so this one is
|
||||
shown only where there is none - two Backs going to two places would
|
||||
be one too many.
|
||||
if showBackToBoard
|
||||
.rules-back-to-board
|
||||
a.js-rules-back-to-board(href="{{pathFor 'board' id=currentBoard._id slug=currentBoard.slug}}" title="{{currentBoard.title}}")
|
||||
i.fa.fa-arrow-left
|
||||
| {{_ 'back'}}
|
||||
//- #2217: who may edit sees every view; a member a board shows its Rules
|
||||
to reads the rules list and rule details, with no control that
|
||||
changes a rule; anybody else sees nothing (client/lib/rulesAccess.js).
|
||||
|
||||
@@ -44,6 +44,10 @@ Template.rulesMain.helpers({
|
||||
rulesCurrentTab() {
|
||||
return Template.instance().rulesCurrentTab;
|
||||
},
|
||||
// #6852: every Rules page but the three that already go Back to the list.
|
||||
showBackToBoard() {
|
||||
return !['trigger', 'action', 'ruleDetails'].includes(Template.instance().rulesCurrentTab.get());
|
||||
},
|
||||
ruleName() {
|
||||
return Template.instance().ruleName;
|
||||
},
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
.admin-audit-log-search { margin: 0.5rem 0; max-width: 24rem; }
|
||||
.admin-audit-log-table { width: 100%; border-collapse: collapse; }
|
||||
.admin-audit-log-table th, .admin-audit-log-table td {
|
||||
padding: 0.3rem 0.5rem;
|
||||
border-bottom: 1px solid rgba(128, 128, 128, 0.3);
|
||||
text-align: start;
|
||||
vertical-align: top;
|
||||
}
|
||||
.admin-audit-args { white-space: pre-wrap; word-break: break-all; font-size: 0.85em; }
|
||||
.admin-audit-failed .admin-audit-error { color: #c0392b; }
|
||||
.admin-audit-log-pager { margin-top: 0.5rem; display: flex; gap: 0.5rem; align-items: center; }
|
||||
@@ -0,0 +1,39 @@
|
||||
template(name="adminAuditLog")
|
||||
//- Admin Panel / People / Audit log (models/lib/adminAudit.js): every
|
||||
//- administrative action of a site administrator, newest first. The
|
||||
//- arguments are shown as stored - passwords and secrets never are.
|
||||
.admin-audit-log
|
||||
p {{_ 'admin-audit-log-info'}}
|
||||
.admin-audit-log-search
|
||||
input.wekan-form-control.js-admin-audit-search(type="search" placeholder="{{_ 'search'}}" value=search)
|
||||
if loading
|
||||
+spinner
|
||||
else
|
||||
table.admin-audit-log-table
|
||||
thead
|
||||
tr
|
||||
th {{_ 'admin-audit-log-when'}}
|
||||
th {{_ 'admin-audit-log-who'}}
|
||||
th {{_ 'admin-audit-log-action'}}
|
||||
th {{_ 'admin-audit-log-details'}}
|
||||
th IP
|
||||
tbody
|
||||
each rows
|
||||
tr(class="{{#unless ok}}admin-audit-failed{{/unless}}")
|
||||
td {{when at}}
|
||||
td {{actorUsername}}
|
||||
td
|
||||
code {{method}}
|
||||
unless ok
|
||||
|
|
||||
span.admin-audit-error {{error}}
|
||||
td
|
||||
code.admin-audit-args {{args}}
|
||||
td {{ip}}
|
||||
else
|
||||
tr
|
||||
td(colspan="5") {{_ 'admin-audit-log-empty'}}
|
||||
.admin-audit-log-pager
|
||||
button.js-admin-audit-prev(type="button" disabled=noPrev) ‹
|
||||
span {{page}} / {{pages}} ({{total}})
|
||||
button.js-admin-audit-next(type="button" disabled=noNext) ›
|
||||
@@ -0,0 +1,47 @@
|
||||
import { ReactiveVar } from 'meteor/reactive-var';
|
||||
import { Template } from 'meteor/templating';
|
||||
|
||||
// Admin Panel / People / Audit log: pages of server/adminAudit.js's trail.
|
||||
Template.adminAuditLog.onCreated(function () {
|
||||
this.page = new ReactiveVar(1);
|
||||
this.search = new ReactiveVar('');
|
||||
this.result = new ReactiveVar({ rows: [], total: 0, page: 1, perPage: 50 });
|
||||
this.loading = new ReactiveVar(true);
|
||||
this.autorun(() => {
|
||||
const options = { page: this.page.get(), search: this.search.get() };
|
||||
this.loading.set(true);
|
||||
Meteor.call('adminAuditLogPage', options, (error, result) => {
|
||||
this.loading.set(false);
|
||||
if (!error && result) this.result.set(result);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Template.adminAuditLog.helpers({
|
||||
loading() { return Template.instance().loading.get(); },
|
||||
rows() { return Template.instance().result.get().rows; },
|
||||
total() { return Template.instance().result.get().total; },
|
||||
page() { return Template.instance().page.get(); },
|
||||
search() { return Template.instance().search.get(); },
|
||||
pages() {
|
||||
const { total, perPage } = Template.instance().result.get();
|
||||
return Math.max(1, Math.ceil(total / perPage));
|
||||
},
|
||||
noPrev() { return Template.instance().page.get() <= 1; },
|
||||
noNext() {
|
||||
const { total, perPage } = Template.instance().result.get();
|
||||
return Template.instance().page.get() >= Math.ceil(total / perPage);
|
||||
},
|
||||
when(at) { return at ? new Date(at).toLocaleString() : ''; },
|
||||
});
|
||||
|
||||
let searchTimer = null;
|
||||
Template.adminAuditLog.events({
|
||||
'input .js-admin-audit-search'(event, tpl) {
|
||||
const value = event.currentTarget.value;
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => { tpl.page.set(1); tpl.search.set(value); }, 300);
|
||||
},
|
||||
'click .js-admin-audit-prev'(event, tpl) { tpl.page.set(Math.max(1, tpl.page.get() - 1)); },
|
||||
'click .js-admin-audit-next'(event, tpl) { tpl.page.set(tpl.page.get() + 1); },
|
||||
});
|
||||
@@ -58,6 +58,8 @@ template(name="adminProblems")
|
||||
+eventStreamReport(stream="api")
|
||||
else if isPane 'report-office'
|
||||
+officeReport
|
||||
else if isPane 'report-password-length'
|
||||
+passwordLengthReport
|
||||
else if tablePageData
|
||||
+tablePage(tablePageData)
|
||||
|
||||
|
||||
@@ -321,6 +321,9 @@ const PROBLEMS_MENU = [
|
||||
// Security, and the two sit in the same menu.
|
||||
{ id: 'report-security', icon: 'fa-shield', labelKey: 'securityReportTitle' },
|
||||
{ id: 'report-impersonation', icon: 'fa-user-secret', labelKey: 'impersonationReportTitle' },
|
||||
// Who signs in with a password shorter than PASSWORD_MIN_LENGTH, and the two
|
||||
// settings that decide it (passwordLengthReport.js).
|
||||
{ id: 'report-password-length', icon: 'fa-key', labelKey: 'password-length-report-title' },
|
||||
{ id: 'features-performance', icon: 'fa-tachometer', labelKey: 'features-performance', emoji: true },
|
||||
{ id: 'report-speed', icon: 'fa-tachometer', labelKey: 'speedReportTitle' },
|
||||
{ id: 'report-tests', icon: 'fa-flask', labelKey: 'testsReportTitle' },
|
||||
@@ -538,6 +541,8 @@ const SELF_LOADING_PANES = [
|
||||
'report-security', 'report-speed', 'report-tests', 'report-cpu', 'report-ram', 'report-disk',
|
||||
'report-instrumentation',
|
||||
'report-database', 'report-integrity', 'report-office', 'report-api', 'report-mail',
|
||||
// passwordLengthReport.js loads its own rows and settings through methods.
|
||||
'report-password-length',
|
||||
];
|
||||
|
||||
function openReportPane(tmpl, targetID) {
|
||||
|
||||
@@ -281,6 +281,17 @@ template(name="attachments")
|
||||
.materialCheckBox#s3-force-path-style(class="{{#if cloudValue.s3.forcePathStyle}}is-checked{{/if}}")
|
||||
span {{_ 's3-force-path-style'}}
|
||||
small.form-text.text-muted {{_ 's3-force-path-style-description'}}
|
||||
//- Encryption at rest (models/lib/s3Encryption.js).
|
||||
.form-group
|
||||
label(for="s3-sse") {{_ 's3-sse'}}
|
||||
select.wekan-form-control#s3-sse
|
||||
option(value="" selected="{{s3SseSelected ''}}") {{_ 's3-sse-none'}}
|
||||
option(value="AES256" selected="{{s3SseSelected 'AES256'}}") SSE-S3 (AES256)
|
||||
option(value="aws:kms" selected="{{s3SseSelected 'aws:kms'}}") SSE-KMS (aws:kms)
|
||||
small.form-text.text-muted {{_ 's3-sse-description'}}
|
||||
.form-group
|
||||
label(for="s3-sse-kms-key-id") {{_ 's3-sse-kms-key-id'}}
|
||||
input.wekan-form-control#s3-sse-kms-key-id(type="text" value="{{cloudValue.s3.sseKmsKeyId}}" placeholder="arn:aws:kms:eu-north-1:111122223333:key/…")
|
||||
|
||||
.form-group
|
||||
button.js-test-cloud-connection.btn.btn-secondary(data-provider="s3") {{_ 'test-cloud-connection'}}
|
||||
|
||||
@@ -32,6 +32,8 @@ const CLOUD_FIELD_IDS = {
|
||||
accessKeyId: '#s3-access-key',
|
||||
secretAccessKey: '#s3-secret-key',
|
||||
forcePathStyle: '#s3-force-path-style',
|
||||
serverSideEncryption: '#s3-sse',
|
||||
sseKmsKeyId: '#s3-sse-kms-key-id',
|
||||
},
|
||||
azure: {
|
||||
enabled: '#azure-enabled',
|
||||
@@ -858,6 +860,11 @@ Template.attachments.helpers({
|
||||
CLOUD_PROVIDERS.forEach(p => { out[p] = cloudConfigFromSettings(tpl, p).read !== false; });
|
||||
return out;
|
||||
},
|
||||
// The S3 server-side encryption option that is stored (models/lib/s3Encryption.js).
|
||||
s3SseSelected(mode) {
|
||||
const stored = cloudConfigFromSettings(Template.instance(), 's3').serverSideEncryption || '';
|
||||
return stored === mode ? 'selected' : null;
|
||||
},
|
||||
cloudValue() {
|
||||
const tpl = Template.instance();
|
||||
const s3 = cloudConfigFromSettings(tpl, 's3');
|
||||
@@ -872,6 +879,8 @@ Template.attachments.helpers({
|
||||
bucket: s3.bucket || '',
|
||||
accessKeyId: s3.accessKeyId || '',
|
||||
forcePathStyle: s3.forcePathStyle !== false,
|
||||
serverSideEncryption: s3.serverSideEncryption || '',
|
||||
sseKmsKeyId: s3.sseKmsKeyId || '',
|
||||
},
|
||||
azure: {
|
||||
accountName: azure.accountName || '',
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
}
|
||||
|
||||
.migration-progress-header {
|
||||
/* WeKan blue (matches the main header #2980b9) */
|
||||
background: linear-gradient(135deg, #2980b9 0%, #1a5276 100%);
|
||||
/* WeKan blue (matches the main header #2573a7) */
|
||||
background: linear-gradient(135deg, #2573a7 0%, #1a5276 100%);
|
||||
color: white;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
@@ -87,7 +87,7 @@
|
||||
|
||||
.migration-progress-overall-fill {
|
||||
/* WeKan blue */
|
||||
background: linear-gradient(90deg, #2980b9, #3498db);
|
||||
background: linear-gradient(90deg, #2573a7, #3498db);
|
||||
height: 100%;
|
||||
border-radius: 10px;
|
||||
transition: width 0.3s ease;
|
||||
@@ -138,7 +138,7 @@
|
||||
|
||||
.migration-progress-step-fill {
|
||||
/* WeKan blue */
|
||||
background: linear-gradient(90deg, #3498db, #2980b9);
|
||||
background: linear-gradient(90deg, #3498db, #2573a7);
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
transition: width 0.3s ease;
|
||||
@@ -156,7 +156,7 @@
|
||||
padding: 15px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
border-inline-start: 4px solid #2980b9;
|
||||
border-inline-start: 4px solid #2573a7;
|
||||
}
|
||||
|
||||
.migration-progress-status-label {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
//- Admin Panel / Problems / Password length (docs/Features/Admin-Panel/Problems/Password-length.md):
|
||||
//- the minimum, whether it is enforced, and who signs in with a shorter
|
||||
//- password - through the shared table page (docs/Features/General/Table.md).
|
||||
template(name="passwordLengthReport")
|
||||
ul.setting-detail.password-length-settings
|
||||
li
|
||||
label(for="password-min-length-input") {{_ 'password-min-length'}}
|
||||
input#password-min-length-input.js-password-min-length(type="number" min="1" max="256" value="{{minLength}}")
|
||||
p.description {{_ 'password-min-length-description'}}
|
||||
li
|
||||
a.flex.js-toggle-password-min-length-enforce
|
||||
.materialCheckBox(class="{{#if enforce}}is-checked{{/if}}")
|
||||
span {{_ 'password-min-length-enforce'}}
|
||||
p.description {{_ 'password-min-length-enforce-description'}}
|
||||
li
|
||||
button.primary.js-save-password-length(type="button") {{_ 'save'}}
|
||||
if saved
|
||||
span.password-length-saved {{_ 'password-length-saved'}}
|
||||
if saveError
|
||||
p.error.password-length-error= saveError
|
||||
if fromEnvironment
|
||||
p.description {{_ 'password-length-from-environment'}}
|
||||
+tablePage(tablePageData)
|
||||
@@ -0,0 +1,157 @@
|
||||
import { ReactiveVar } from 'meteor/reactive-var';
|
||||
import { Template } from 'meteor/templating';
|
||||
import { formatDateForDisplay } from '/client/lib/dateDisplay';
|
||||
import { buildFilters, buildHeader, buildRows, pageInfo } from '/models/lib/tablePage';
|
||||
import { errorMessage } from '/client/lib/errorMessage';
|
||||
|
||||
// Admin Panel / Problems / Password length: the two settings and the table of
|
||||
// accounts that sign in with a password shorter than the minimum
|
||||
// (server/lib/shortPasswordLogins.js; docs/Features/Admin-Panel/Problems/Password-length.md).
|
||||
const { addressValue, locationText, locationFlag } = require('/models/lib/addressReportColumns');
|
||||
const { DEFAULT_FILTER } = require('/models/lib/shortPasswordLogins');
|
||||
|
||||
const PER_PAGE = 25;
|
||||
const formatDate = date => (date ? formatDateForDisplay(date) : '');
|
||||
|
||||
// One cell per family: the address, then the city when a CDN in front of
|
||||
// WeKan said where it is, and the country's flag before both.
|
||||
const addressColumn = (labelKey, family) => ({
|
||||
labelKey,
|
||||
nowrap: true,
|
||||
value: row => [addressValue(row, family), locationText(row, family)].filter(Boolean).join(' '),
|
||||
flag: row => locationFlag(row, family),
|
||||
});
|
||||
|
||||
const COLUMNS = [
|
||||
{ labelKey: 'username', value: row => row.username || row.userId },
|
||||
{ labelKey: 'password-length-first-login', nowrap: true, value: row => formatDate(row.firstAt) },
|
||||
addressColumn('event-ipv4', 'ipv4'),
|
||||
addressColumn('event-ipv6', 'ipv6'),
|
||||
{ labelKey: 'password-length-last-login', nowrap: true, value: row => formatDate(row.at) },
|
||||
{ labelKey: 'password-length-login-count', align: 'end', value: row => row.count || 0 },
|
||||
{ labelKey: 'password-length-fixed', nowrap: true, value: row => formatDate(row.fixedAt) },
|
||||
];
|
||||
|
||||
const FILTER_OPTIONS = [
|
||||
{ value: 'short', labelKey: 'password-length-filter-short' },
|
||||
{ value: 'fixed', labelKey: 'password-length-filter-fixed' },
|
||||
{ value: 'all', labelKey: 'password-length-filter-all' },
|
||||
];
|
||||
|
||||
Template.passwordLengthReport.onCreated(function () {
|
||||
this.rows = new ReactiveVar([]);
|
||||
this.total = new ReactiveVar(0);
|
||||
this.page = new ReactiveVar(1);
|
||||
this.search = new ReactiveVar('');
|
||||
this.filter = new ReactiveVar(DEFAULT_FILTER);
|
||||
this.sources = new ReactiveVar(null);
|
||||
// The working copy of the two settings, until Save.
|
||||
this.minLength = new ReactiveVar(8);
|
||||
this.enforce = new ReactiveVar(false);
|
||||
this.saved = new ReactiveVar(false);
|
||||
this.error = new ReactiveVar('');
|
||||
|
||||
this.load = () => {
|
||||
Meteor.call('shortPasswordLoginsPage', {
|
||||
filter: this.filter.get(),
|
||||
search: this.search.get() || undefined,
|
||||
limit: PER_PAGE,
|
||||
skip: (this.page.get() - 1) * PER_PAGE,
|
||||
}, (error, result) => {
|
||||
if (error) return;
|
||||
this.rows.set((result && result.rows) || []);
|
||||
this.total.set((result && result.total) || 0);
|
||||
});
|
||||
};
|
||||
this.loadSettings = () => {
|
||||
Meteor.call('getAuthConfigSources', 'login', (error, result) => {
|
||||
if (error || !result) return;
|
||||
this.sources.set(result.sources || {});
|
||||
this.minLength.set(result.sources?.passwordMinLength?.value || 8);
|
||||
this.enforce.set(result.sources?.passwordMinLengthEnforce?.value === true);
|
||||
});
|
||||
};
|
||||
this.load();
|
||||
this.loadSettings();
|
||||
});
|
||||
|
||||
Template.passwordLengthReport.helpers({
|
||||
minLength() { return Template.instance().minLength.get(); },
|
||||
enforce() { return Template.instance().enforce.get(); },
|
||||
saved() { return Template.instance().saved.get(); },
|
||||
saveError() { return Template.instance().error.get(); },
|
||||
// An environment variable set on the host is overridden by a save here; say
|
||||
// so, rather than let the admin wonder which one is in effect.
|
||||
fromEnvironment() {
|
||||
const sources = Template.instance().sources.get() || {};
|
||||
return ['passwordMinLength', 'passwordMinLengthEnforce'].some(key => sources[key]?.source === 'env');
|
||||
},
|
||||
tablePageData() {
|
||||
const t = Template.instance();
|
||||
const rows = t.rows.get() || [];
|
||||
const info = pageInfo(t.total.get(), t.page.get(), PER_PAGE);
|
||||
return {
|
||||
descKey: 'password-length-report-desc',
|
||||
emptyKey: 'password-length-no-results',
|
||||
searchTerm: t.search.get(),
|
||||
filters: buildFilters([{
|
||||
id: 'password-length-status', labelKey: 'status', current: t.filter.get(), options: FILTER_OPTIONS,
|
||||
}]),
|
||||
header: buildHeader(COLUMNS),
|
||||
rows: buildRows(rows, COLUMNS),
|
||||
rowCount: rows.length,
|
||||
totalCount: t.total.get(),
|
||||
page: info.page,
|
||||
totalPages: info.totalPages,
|
||||
hasPrev: info.hasPrev,
|
||||
hasNext: info.hasNext,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
Template.passwordLengthReport.events({
|
||||
'input .js-password-min-length'(event, tmpl) {
|
||||
tmpl.minLength.set(parseInt(event.currentTarget.value, 10) || '');
|
||||
tmpl.saved.set(false);
|
||||
},
|
||||
'click .js-toggle-password-min-length-enforce'(event, tmpl) {
|
||||
event.preventDefault();
|
||||
tmpl.enforce.set(!tmpl.enforce.get());
|
||||
tmpl.saved.set(false);
|
||||
},
|
||||
'click .js-save-password-length'(event, tmpl) {
|
||||
event.preventDefault();
|
||||
Meteor.call('saveAuthConfigSettings', 'login', {
|
||||
passwordMinLength: tmpl.minLength.get() || null,
|
||||
passwordMinLengthEnforce: tmpl.enforce.get(),
|
||||
}, error => {
|
||||
tmpl.error.set(error ? errorMessage(error) : '');
|
||||
if (error) return;
|
||||
tmpl.saved.set(true);
|
||||
tmpl.loadSettings();
|
||||
});
|
||||
},
|
||||
'change .js-table-page-filter'(event, tmpl) {
|
||||
tmpl.filter.set(event.currentTarget.value || DEFAULT_FILTER);
|
||||
tmpl.page.set(1);
|
||||
tmpl.load();
|
||||
},
|
||||
'click .js-table-page-prev'(event, tmpl) {
|
||||
event.preventDefault();
|
||||
if (tmpl.page.get() > 1) { tmpl.page.set(tmpl.page.get() - 1); tmpl.load(); }
|
||||
},
|
||||
'click .js-table-page-next'(event, tmpl) {
|
||||
event.preventDefault();
|
||||
const info = pageInfo(tmpl.total.get(), tmpl.page.get(), PER_PAGE);
|
||||
if (info.hasNext) { tmpl.page.set(tmpl.page.get() + 1); tmpl.load(); }
|
||||
},
|
||||
'keydown .js-table-page-search'(event, tmpl) {
|
||||
if (event.keyCode === 13 && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
tmpl.search.set(event.currentTarget.value || '');
|
||||
tmpl.page.set(1);
|
||||
tmpl.load();
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -36,6 +36,8 @@ template(name="people")
|
||||
+authProviderSettings(loginOptionsData)
|
||||
//- Windows SSO, which works only on a Windows server.
|
||||
+authProviderSettings(windowsSsoData)
|
||||
//- Strict-Transport-Security, sent when ROOT_URL is https.
|
||||
+authProviderSettings(httpSecurityData)
|
||||
else if samlSetting
|
||||
+authProviderSettings(samlSettingsData)
|
||||
else if authConfigSection
|
||||
@@ -56,6 +58,8 @@ template(name="people")
|
||||
+peopleGeneral(peopleTablePageData)
|
||||
else if lockedUsersSetting.get
|
||||
+lockedUsersGeneral
|
||||
else if auditLogSetting.get
|
||||
+adminAuditLog
|
||||
else if rolesSetting.get
|
||||
+rolesGeneral
|
||||
else if templatesSetting.get
|
||||
|
||||
@@ -142,6 +142,7 @@ Template.people.onCreated(function () {
|
||||
this.teamSetting = new ReactiveVar(false);
|
||||
this.peopleSetting = new ReactiveVar(false);
|
||||
this.lockedUsersSetting = new ReactiveVar(false);
|
||||
this.auditLogSetting = new ReactiveVar(false);
|
||||
this.rolesSetting = new ReactiveVar(false);
|
||||
this.templatesSetting = new ReactiveVar(false);
|
||||
// #5850: Admin Panel > People > Domains tab. The domains table itself
|
||||
@@ -434,6 +435,7 @@ Template.people.onCreated(function () {
|
||||
this.teamSetting.set('team-setting' === targetID);
|
||||
this.peopleSetting.set('people-setting' === targetID);
|
||||
this.lockedUsersSetting.set('locked-users-setting' === targetID);
|
||||
this.auditLogSetting.set('audit-log-setting' === targetID);
|
||||
this.rolesSetting.set('roles-setting' === targetID);
|
||||
this.templatesSetting.set('templates-setting' === targetID);
|
||||
this.domainSetting.set('domains-setting' === targetID);
|
||||
@@ -551,6 +553,9 @@ function peopleMenu(user) {
|
||||
{ id: 'team-setting', icon: 'fa-users', labelKey: 'teams' },
|
||||
{ id: 'people-setting', icon: 'fa-user', labelKey: 'people' },
|
||||
{ id: 'locked-users-setting', icon: 'fa-lock', labelKey: 'accounts-lockout-locked-users', iconWrapCls: 'text-red' },
|
||||
// What site administrators did (models/lib/adminAudit.js). Site admins only:
|
||||
// tenantAdminPeopleMenu keeps a per-tenant admin's menu to its own panes.
|
||||
{ id: 'audit-log-setting', icon: 'fa-list-alt', labelKey: 'admin-audit-log' },
|
||||
{ id: 'roles-setting', icon: 'fa-key', labelKey: 'roles' },
|
||||
{ id: 'templates-setting', icon: 'fa-clone', labelKey: 'shared-templates' },
|
||||
{ id: 'registration-setting', icon: 'fa-key', labelKey: 'login', emoji: true },
|
||||
@@ -581,6 +586,15 @@ function peopleMenu(user) {
|
||||
return items;
|
||||
}
|
||||
|
||||
// A password the server refused (server/lib/passwordPolicy.js), in the
|
||||
// reader's language; the popup that sent it has already closed.
|
||||
function passwordErrorMessage(error) {
|
||||
const key = error && error.error;
|
||||
if (key === 'password-too-short') return TAPi18n.__('password-too-short', { min: error.details?.min });
|
||||
if (['password-pwned', 'password-same-as-username'].includes(key)) return TAPi18n.__(key);
|
||||
return error.reason || error.message;
|
||||
}
|
||||
|
||||
// People panes drawn from models/lib/authConfigCatalog.js, and the data the
|
||||
// catalog form (authProviderSettings) needs for one section.
|
||||
const AUTH_CONFIG_PANES = {
|
||||
@@ -899,6 +913,8 @@ Template.people.helpers({
|
||||
// The Login pane's own login settings (PASSWORD_LOGIN_ENABLED, ...).
|
||||
loginOptionsData() { return authConfigData('login'); },
|
||||
windowsSsoData() { return authConfigData('windowsSso'); },
|
||||
// HSTS (HSTS_MAX_AGE, ...), below the login settings.
|
||||
httpSecurityData() { return authConfigData('httpSecurity'); },
|
||||
ldapPane() { return Template.instance().activeMenuId.get() === 'ldap-setting'; },
|
||||
authenticationSection() {
|
||||
return {
|
||||
@@ -927,6 +943,9 @@ Template.people.helpers({
|
||||
lockedUsersSetting() {
|
||||
return Template.instance().lockedUsersSetting;
|
||||
},
|
||||
auditLogSetting() {
|
||||
return Template.instance().auditLogSetting;
|
||||
},
|
||||
rolesSetting() {
|
||||
return Template.instance().rolesSetting;
|
||||
},
|
||||
@@ -2010,7 +2029,7 @@ Template.modifyTeamsUsers.events({
|
||||
currentUser = ReactiveCache.getUser(selectedUserChkBoxUserIds[i]);
|
||||
// Copy, so the cached minimongo document is not mutated in place.
|
||||
userTms = (currentUser.teams || []).slice();
|
||||
currUserTeamIndex = userTms.findIndex(function(t){ return t.teamId == selectedEltValueId});
|
||||
currUserTeamIndex = userTms.findIndex(function(t){ return t.teamId == selectedEltValueId;});
|
||||
if(currUserTeamIndex == -1){
|
||||
userTms.push({
|
||||
"teamId": selectedEltValueId,
|
||||
@@ -2029,7 +2048,7 @@ Template.modifyTeamsUsers.events({
|
||||
for(let i = 0; i < selectedUserChkBoxUserIds.length; i++){
|
||||
currentUser = ReactiveCache.getUser(selectedUserChkBoxUserIds[i]);
|
||||
userTms = (currentUser.teams || []).slice();
|
||||
currUserTeamIndex = userTms.findIndex(function(t){ return t.teamId == selectedEltValueId});
|
||||
currUserTeamIndex = userTms.findIndex(function(t){ return t.teamId == selectedEltValueId;});
|
||||
if(currUserTeamIndex != -1){
|
||||
userTms.splice(currUserTeamIndex, 1);
|
||||
}
|
||||
@@ -2317,7 +2336,7 @@ Template.editUserPopup.events({
|
||||
userTms.push({
|
||||
"teamId": userTeamsIdsList[i],
|
||||
"teamDisplayName": userTeamsList[i],
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2330,7 +2349,7 @@ Template.editUserPopup.events({
|
||||
userOrganizations.push({
|
||||
"orgId": userOrgsIdsList[i],
|
||||
"orgDisplayName": userOrgsList[i],
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2352,7 +2371,9 @@ Template.editUserPopup.events({
|
||||
});
|
||||
|
||||
if (isChangePassword) {
|
||||
Meteor.call('setPassword', password, this.userId);
|
||||
Meteor.call('setPassword', password, this.userId, error => {
|
||||
if (error) alert(passwordErrorMessage(error));
|
||||
});
|
||||
}
|
||||
|
||||
if (isChangeEmailVerified) {
|
||||
@@ -2532,7 +2553,7 @@ const UpdateUserOrgsOrTeamsElement = function(isNewUser = false){
|
||||
}
|
||||
selectedElt.value = "-1";
|
||||
selectedElt.style.display = "none";
|
||||
}
|
||||
};
|
||||
|
||||
Template.newOrgPopup.events({
|
||||
submit(event, templateInstance) {
|
||||
@@ -2612,7 +2633,7 @@ Template.newUserPopup.events({
|
||||
userTms.push({
|
||||
"teamId": userTeamsIdsList[i],
|
||||
"teamDisplayName": userTeamsList[i],
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2624,7 +2645,7 @@ Template.newUserPopup.events({
|
||||
userOrganizations.push({
|
||||
"orgId": userOrgsIdsList[i],
|
||||
"orgDisplayName": userOrgsList[i],
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2651,6 +2672,8 @@ Template.newUserPopup.events({
|
||||
} else if (errorElement === 'email-already-taken') {
|
||||
usernameMessageElement.hide();
|
||||
emailMessageElement.show();
|
||||
} else if (String(errorElement).startsWith('password-')) {
|
||||
alert(passwordErrorMessage(error));
|
||||
}
|
||||
} else {
|
||||
usernameMessageElement.hide();
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
height: 100%;
|
||||
}
|
||||
.setting-content {
|
||||
color: #727479;
|
||||
color: var(--wekan-quiet-text, #5a5c61);
|
||||
background: #dedede;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -99,7 +99,7 @@
|
||||
selected entry back to white as soon as the pointer crossed it. */
|
||||
.setting-content .content-body .side-menu ul li.active,
|
||||
.setting-content .content-body .side-menu ul li.active:hover {
|
||||
background: var(--theme-accent, #2980b9);
|
||||
background: var(--theme-accent, #2573a7);
|
||||
}
|
||||
|
||||
/* …and when a THEME is active, the row is painted by that theme's own #header rule
|
||||
@@ -488,7 +488,7 @@ body.left-menu-resizing-active {
|
||||
}
|
||||
|
||||
.setting-content label:has(> input[type="checkbox"]:focus-visible) {
|
||||
outline: 2px solid #2980b9;
|
||||
outline: 2px solid #2573a7;
|
||||
outline-offset: 2px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
@@ -511,7 +511,7 @@ body.left-menu-resizing-active {
|
||||
}
|
||||
|
||||
.setting-content input[type="checkbox"]:focus-visible {
|
||||
outline: 2px solid var(--theme-accent, #2980b9);
|
||||
outline: 2px solid var(--theme-accent, #2573a7);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -562,13 +562,13 @@ body.left-menu-resizing-active {
|
||||
}
|
||||
|
||||
.setting-content .schedule-day-btn.active {
|
||||
background: var(--theme-accent, #2980b9);
|
||||
border-color: var(--theme-accent, #2980b9);
|
||||
background: var(--theme-accent, #2573a7);
|
||||
border-color: var(--theme-accent, #2573a7);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.setting-content .schedule-day-btn:focus-visible {
|
||||
outline: 2px solid var(--theme-accent, #2980b9);
|
||||
outline: 2px solid var(--theme-accent, #2573a7);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
|
||||
@@ -630,6 +630,10 @@ template(name='tableVisibilityModeSettings')
|
||||
.title {{_ 'text-below-custom-login-logo'}}
|
||||
.form-group
|
||||
textarea#text-below-custom-login-logo.wekan-form-control(data-editor-submit=".js-visibility-logo-save")= currentSetting.textBelowCustomLoginLogo
|
||||
li.tableVisibilityMode-form
|
||||
.title {{_ 'login-background-color'}}
|
||||
.form-group
|
||||
input.wekan-form-control#login-background-color(type="text", placeholder="#1b365d" value="{{currentSetting.loginBackgroundColor}}")
|
||||
li.tableVisibilityMode-form
|
||||
.title {{_ 'custom-top-left-corner-logo-image-url'}}
|
||||
.form-group
|
||||
|
||||
@@ -811,6 +811,7 @@ Template.tableVisibilityModeSettings.events({
|
||||
['#custom-login-logo-image-url', 'customLoginLogoImageUrl'],
|
||||
['#custom-login-logo-link-url', 'customLoginLogoLinkUrl'],
|
||||
['#text-below-custom-login-logo', 'textBelowCustomLoginLogo'],
|
||||
['#login-background-color', 'loginBackgroundColor'],
|
||||
['#custom-top-left-corner-logo-image-url', 'customTopLeftCornerLogoImageUrl'],
|
||||
['#custom-top-left-corner-logo-link-url', 'customTopLeftCornerLogoLinkUrl'],
|
||||
['#custom-top-left-corner-logo-height', 'customTopLeftCornerLogoHeight'],
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
display: none;
|
||||
}
|
||||
.sidebar .sidebar-content h3 {
|
||||
color: #808080;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
font-size: 1em;
|
||||
margin-bottom: 10px;
|
||||
font-weight: bold;
|
||||
@@ -750,6 +750,12 @@ body.sidebar-resizing-active {
|
||||
margin-inline-end: 6px;
|
||||
appearance: auto;
|
||||
}
|
||||
/* Why a date range was refused, below its buttons (sidebarFilters.js,
|
||||
rangeRefused): the browser's own bubble covered those buttons in Firefox. */
|
||||
.filter-date-range-error {
|
||||
color: #c0392b;
|
||||
margin: 6px 0 0;
|
||||
}
|
||||
.filter-native-group {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
|
||||
@@ -3782,7 +3782,7 @@ Template.addMemberPopup.helpers({
|
||||
const board = ReactiveCache.getBoard(boardId);
|
||||
return board && board.hasMember(userId);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
Template.addBoardOrgPopup.onCreated(function() {
|
||||
initGroupPickerState(this, 'org');
|
||||
@@ -3823,7 +3823,7 @@ Template.addBoardOrgPopup.events({
|
||||
"orgId": selectedOrgId,
|
||||
"orgDisplayName": selectedOrgDisplayName,
|
||||
"isActive" : true,
|
||||
})
|
||||
});
|
||||
|
||||
if (selectedOrgId != "-1") {
|
||||
Meteor.call('setBoardOrgs', boardOrganizations, currentBoard._id);
|
||||
@@ -3994,7 +3994,7 @@ Template.addBoardTeamPopup.events({
|
||||
"teamId": selectedTeamId,
|
||||
"teamDisplayName": selectedTeamDisplayName,
|
||||
"isActive" : true,
|
||||
})
|
||||
});
|
||||
|
||||
if (selectedTeamId != "-1") {
|
||||
let members = currentBoard.members;
|
||||
@@ -4011,7 +4011,7 @@ Template.addBoardTeamPopup.events({
|
||||
let index;
|
||||
if (boardTeamUsers && boardTeamUsers.length > 0) {
|
||||
boardTeamUsers.forEach((u) => {
|
||||
index = members.findIndex(function(m){ return m.userId == u._id});
|
||||
index = members.findIndex(function(m){ return m.userId == u._id;});
|
||||
if(index == -1){
|
||||
members.push({
|
||||
"isActive": true,
|
||||
@@ -4076,7 +4076,7 @@ Template.removeBoardTeamPopup.events({
|
||||
let index;
|
||||
if (boardTeamUsers && boardTeamUsers.length > 0) {
|
||||
boardTeamUsers.forEach((u) => {
|
||||
index = members.findIndex(function(m){ return m.userId == u._id});
|
||||
index = members.findIndex(function(m){ return m.userId == u._id;});
|
||||
if(index !== -1 && !members[index].isAdmin){
|
||||
members.splice(index, 1);
|
||||
}
|
||||
|
||||
@@ -140,7 +140,10 @@ template(name="filterSidebar")
|
||||
| {{_ 'filter-dates-label' }}
|
||||
each dateFilterProviderViews
|
||||
+Template.dynamic(template=template data=data)
|
||||
form.js-card-movement-range
|
||||
//- novalidate: the range is checked in the submit handler (rangeRefused in
|
||||
sidebarFilters.js). WebKit keeps a half-edited date segment after Clear
|
||||
empties a field and then refused every submit of the form, with no message.
|
||||
form.js-card-movement-range(novalidate)
|
||||
h4 {{_ 'filter-movement-range'}}
|
||||
label(for="filter-card-movement-from") {{_ 'filter-date-range-from'}}
|
||||
input.js-card-movement-from(id="filter-card-movement-from" type="date" value=movementFrom)
|
||||
@@ -148,7 +151,9 @@ template(name="filterSidebar")
|
||||
input.js-card-movement-to(id="filter-card-movement-to" type="date" value=movementTo)
|
||||
button.primary(type="submit") {{_ 'apply'}}
|
||||
button.js-clear-movement-range(type="button") {{_ 'filter-clear'}}
|
||||
form.js-card-date-range
|
||||
if movementRangeError
|
||||
p.js-card-movement-range-error.filter-date-range-error(role="alert") {{_ 'filter-date-range-invalid'}}
|
||||
form.js-card-date-range(novalidate)
|
||||
label(for="filter-card-date-field") {{_ 'filter-date-range-field'}}
|
||||
select.js-card-date-field(id="filter-card-date-field")
|
||||
each dateRangeFields
|
||||
@@ -162,6 +167,8 @@ template(name="filterSidebar")
|
||||
| {{_ 'filter-date-range-missing'}}
|
||||
button.primary(type="submit") {{_ 'apply'}}
|
||||
button.js-clear-date-range(type="button") {{_ 'filter-clear'}}
|
||||
if dateRangeError
|
||||
p.js-card-date-range-error.filter-date-range-error(role="alert") {{_ 'filter-date-range-invalid'}}
|
||||
hr
|
||||
fieldset.filter-native-group
|
||||
legend {{_ 'due-date'}}
|
||||
|
||||
@@ -13,9 +13,23 @@ import { getSidebarInstance } from '/client/features/sidebar/service';
|
||||
import { CARD_DATE_RANGE_FIELDS, CARD_RECENCY_PRESETS } from '/models/lib/cardDateRange';
|
||||
import { DEPENDENCY_TYPES } from '/models/metadata/dependencies';
|
||||
|
||||
// A refused date range marks its end field invalid - so it is :invalid and
|
||||
// screen readers announce the reason - and says why in the form itself. Not
|
||||
// with reportValidity(): Firefox's validation bubble opens over the Apply and
|
||||
// Clear buttons below the field and takes every click on them while the field
|
||||
// has focus, so the range could not be cleared at all.
|
||||
function rangeRefused(end, error, refused) {
|
||||
end.setCustomValidity(refused ? TAPi18n.__('filter-date-range-invalid') : '');
|
||||
error.set(refused);
|
||||
}
|
||||
|
||||
Template.filterSidebar.onCreated(function () {
|
||||
this.presetMessage = new ReactiveVar('');
|
||||
this.presetBusy = new ReactiveVar(false);
|
||||
// Why a date range was refused, shown in the form rather than in the
|
||||
// browser's own validation bubble (see rangeRefused below).
|
||||
this.movementRangeError = new ReactiveVar(false);
|
||||
this.dateRangeError = new ReactiveVar(false);
|
||||
this.autorun(() => {
|
||||
const boardId = Session.get('currentBoard');
|
||||
if (Meteor.userId() && boardId) this.subscribe('savedCardFilters', boardId);
|
||||
@@ -28,6 +42,8 @@ Template.filterSidebar.helpers({
|
||||
},
|
||||
presetMessage() { return Template.instance().presetMessage.get(); },
|
||||
presetBusy() { return Template.instance().presetBusy.get(); },
|
||||
movementRangeError() { return Template.instance().movementRangeError.get(); },
|
||||
dateRangeError() { return Template.instance().dateRangeError.get(); },
|
||||
filterProviderViews() { return Filter.providers.views(); },
|
||||
dateFilterProviderViews() { return Filter.providers.views('dates'); },
|
||||
dueUnrestricted() { return Filter.dueAt.state() === null; },
|
||||
@@ -102,15 +118,15 @@ Template.filterSidebar.events({
|
||||
const end = tpl.find('.js-card-movement-to');
|
||||
const valid = Filter.movementDate.set({ field: 'createdAt',
|
||||
from: tpl.find('.js-card-movement-from').value, to: end.value, includeMissing: false });
|
||||
end.setCustomValidity(valid ? '' : TAPi18n.__('filter-date-range-invalid'));
|
||||
if (!valid) end.reportValidity();
|
||||
else Filter.resetExceptions();
|
||||
rangeRefused(end, tpl.movementRangeError, !valid);
|
||||
if (valid) Filter.resetExceptions();
|
||||
},
|
||||
'input .js-card-movement-range input'(event, tpl) {
|
||||
tpl.find('.js-card-movement-to').setCustomValidity('');
|
||||
rangeRefused(tpl.find('.js-card-movement-to'), tpl.movementRangeError, false);
|
||||
},
|
||||
'click .js-clear-movement-range'(event, tpl) {
|
||||
event.preventDefault(); tpl.find('.js-card-movement-to').setCustomValidity('');
|
||||
event.preventDefault();
|
||||
rangeRefused(tpl.find('.js-card-movement-to'), tpl.movementRangeError, false);
|
||||
Filter.movementDate.reset(); Filter.resetExceptions();
|
||||
},
|
||||
'submit .js-card-date-range'(event, tpl) {
|
||||
@@ -119,22 +135,20 @@ Template.filterSidebar.events({
|
||||
const valid = Filter.dateRange.set({ field: tpl.find('.js-card-date-field').value,
|
||||
from: tpl.find('.js-card-date-from').value, to: end.value,
|
||||
includeMissing: tpl.find('.js-card-date-missing').checked });
|
||||
end.setCustomValidity(valid ? '' : TAPi18n.__('filter-date-range-invalid'));
|
||||
if (!valid) end.reportValidity();
|
||||
else Filter.resetExceptions();
|
||||
rangeRefused(end, tpl.dateRangeError, !valid);
|
||||
if (valid) Filter.resetExceptions();
|
||||
},
|
||||
'input .js-card-date-range input'(event, tpl) {
|
||||
tpl.find('.js-card-date-to').setCustomValidity('');
|
||||
rangeRefused(tpl.find('.js-card-date-to'), tpl.dateRangeError, false);
|
||||
},
|
||||
'click .js-clear-date-range'(event, tpl) {
|
||||
event.preventDefault();
|
||||
// Reset the native date controls as well as the reactive model. WebKit can
|
||||
// retain an edited date segment after Blaze changes the value attribute.
|
||||
for (const selector of ['.js-card-date-from', '.js-card-date-to']) {
|
||||
const input = tpl.find(selector);
|
||||
input.value = '';
|
||||
input.setCustomValidity('');
|
||||
tpl.find(selector).value = '';
|
||||
}
|
||||
rangeRefused(tpl.find('.js-card-date-to'), tpl.dateRangeError, false);
|
||||
Filter.dateRange.reset(); Filter.resetExceptions();
|
||||
},
|
||||
'submit .js-column-age-filter'(event, tpl) {
|
||||
|
||||
@@ -417,14 +417,14 @@ body.swimlane-resizing-active * {
|
||||
`outline`, not `border` or `box-shadow`: it is drawn outside the box and so
|
||||
moves neither the swimlane's rows nor the list's cards by a pixel. */
|
||||
.is-revealed {
|
||||
outline: 3px solid #2980b9;
|
||||
outline: 3px solid #2573a7;
|
||||
outline-offset: -3px;
|
||||
animation: wekan-reveal-fade 2s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes wekan-reveal-fade {
|
||||
0% { outline-color: #2980b9; }
|
||||
70% { outline-color: #2980b9; }
|
||||
0% { outline-color: #2573a7; }
|
||||
70% { outline-color: #2573a7; }
|
||||
100% { outline-color: transparent; }
|
||||
}
|
||||
|
||||
|
||||
@@ -1039,12 +1039,30 @@ Template.addListInline.events({
|
||||
// #2809
|
||||
'click .js-link-list': Popup.open('linkList'),
|
||||
'click .js-close-add-list-inline'() {
|
||||
Session.set('wekan-add-list-after', null);
|
||||
Session.set('wekan-add-list-swimlane', null);
|
||||
Session.set('wekan-add-list-empty', null);
|
||||
closeAddListComposer();
|
||||
},
|
||||
});
|
||||
|
||||
// Close the inline add-list composer (the one opened after a list, or in an empty
|
||||
// swimlane). The X in the composer and Escape do the same thing.
|
||||
function closeAddListComposer() {
|
||||
Session.set('wekan-add-list-after', null);
|
||||
Session.set('wekan-add-list-swimlane', null);
|
||||
Session.set('wekan-add-list-empty', null);
|
||||
}
|
||||
|
||||
// #6865: Escape closes the add-list composer. Only the X did: the composer stays
|
||||
// open after a list is created, so one can add several, and then Escape did
|
||||
// nothing. It is closed at the level of an inlined form (below popups and modals,
|
||||
// above the card's details and the sidebar), and not by a click elsewhere, as an
|
||||
// inlined form is not.
|
||||
EscapeActions.register(
|
||||
'inlinedForm',
|
||||
closeAddListComposer,
|
||||
() => !!(Session.get('wekan-add-list-after') || Session.get('wekan-add-list-empty')),
|
||||
{ enabledOnClick: false },
|
||||
);
|
||||
|
||||
Template.listsGroup.helpers({
|
||||
// #6745: board-role checks only (see listHeader.js) - not the whole user.
|
||||
currentUser() {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/* PASSWORD_MIN_LENGTH: the Change Password form over everything, until done. */
|
||||
.password-change-required {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: auto;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.password-change-required-box {
|
||||
max-width: 32rem;
|
||||
width: calc(100% - 2rem);
|
||||
margin: 1rem;
|
||||
padding: 1.5rem;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: #222;
|
||||
}
|
||||
.password-change-required-box h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
/* layouts.css hides the password form until a page shows it (#6380). */
|
||||
.password-change-required .at-pwd-form {
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
template(name="passwordChangeRequired")
|
||||
//- PASSWORD_MIN_LENGTH (server/passwordChangeRequired.js): an account whose
|
||||
//- password is shorter than the minimum signed in, and sees only this until
|
||||
//- it has a longer one. The form is the same Change Password Member
|
||||
//- Settings opens; changing it signs every session out, this one too.
|
||||
.password-change-required(role="dialog" aria-modal="true" aria-labelledby="password-change-required-title")
|
||||
.password-change-required-box
|
||||
h2#password-change-required-title {{_ 'password-change-required-title'}}
|
||||
p {{_ 'password-change-required-text' min=min}}
|
||||
+atForm(state='changePwd')
|
||||
.buttonsContainer
|
||||
button.js-password-change-required-logout(type="button") {{_ 'log-out'}}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Blaze } from 'meteor/blaze';
|
||||
import { Template } from 'meteor/templating';
|
||||
import { Tracker } from 'meteor/tracker';
|
||||
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
|
||||
|
||||
// PASSWORD_MIN_LENGTH: after signing in, ask whether this account must change
|
||||
// a password shorter than the minimum, and if so show nothing but the Change
|
||||
// Password form (passwordChangeRequired.jade). The server asks the question
|
||||
// (server/passwordChangeRequired.js); config/accounts.js tells it when the
|
||||
// password just typed was too short. Changing a password signs every session
|
||||
// out, so the account then signs in again, with the new password.
|
||||
let overlay = null;
|
||||
|
||||
function closeOverlay() {
|
||||
if (!overlay) return;
|
||||
Blaze.remove(overlay.view);
|
||||
overlay.node.remove();
|
||||
overlay = null;
|
||||
}
|
||||
|
||||
function openOverlay(min) {
|
||||
if (overlay) return;
|
||||
const node = document.createElement('div');
|
||||
document.body.appendChild(node);
|
||||
overlay = { node, view: Blaze.renderWithData(Template.passwordChangeRequired, { min }, node) };
|
||||
}
|
||||
|
||||
Template.passwordChangeRequired.events({
|
||||
'click .js-password-change-required-logout'() {
|
||||
Meteor.logout(() => closeOverlay());
|
||||
},
|
||||
});
|
||||
|
||||
export function checkPasswordChangeRequirement() {
|
||||
const userId = Meteor.userId();
|
||||
if (!userId) return;
|
||||
Meteor.call('getPasswordChangeRequirement', (error, result) => {
|
||||
if (error || !result || Meteor.userId() !== userId) return;
|
||||
if (result.required) openOverlay(result.minLength);
|
||||
});
|
||||
}
|
||||
|
||||
// A changed password signs this session out too (server/passwordChangeRequired.js).
|
||||
// The server closes the connection as it removes the token, so the answer to
|
||||
// the change - and useraccounts' onSubmitHook - may never arrive; being signed
|
||||
// out soon after submitting a change is the answer, and the next stop is the
|
||||
// sign-in page, from Member Settings and from this overlay alike.
|
||||
const SIGN_OUT_WAIT_MS = 2 * 60 * 1000;
|
||||
let changeSubmittedAt = 0;
|
||||
document.addEventListener('submit', event => {
|
||||
const form = event.target;
|
||||
if (form && form.id === 'at-pwd-form' && form.querySelector('#at-field-current_password')) {
|
||||
changeSubmittedAt = Date.now();
|
||||
}
|
||||
}, true);
|
||||
|
||||
Meteor.startup(() => {
|
||||
let askedFor = null;
|
||||
Tracker.autorun(() => {
|
||||
const userId = Meteor.userId();
|
||||
if (!userId) {
|
||||
askedFor = null;
|
||||
const changed = Date.now() - changeSubmittedAt < SIGN_OUT_WAIT_MS;
|
||||
if (overlay || changed) {
|
||||
// The change signed every session out: sign in with the new password.
|
||||
changeSubmittedAt = 0;
|
||||
closeOverlay();
|
||||
Popup.close?.();
|
||||
FlowRouter.go('atSignIn');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (askedFor === userId) return;
|
||||
askedFor = userId;
|
||||
Tracker.nonreactive(checkPasswordChangeRequirement);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
/* REQUIRE_2FA: the set-up over everything, until it is done. */
|
||||
.two-factor-required {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: auto;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.two-factor-required-box {
|
||||
max-width: 32rem;
|
||||
width: calc(100% - 2rem);
|
||||
margin: 1rem;
|
||||
padding: 1.5rem;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: #222;
|
||||
}
|
||||
.two-factor-required-box h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
template(name="twoFactorRequired")
|
||||
//- REQUIRE_2FA (models/lib/twoFactorRequirement.js): an account that must
|
||||
//- have two-factor authentication sees only this until it has set it up.
|
||||
//- The server refuses everything else meanwhile (server/requireTwoFactor.js);
|
||||
//- this is what the account can do about it. The set-up is the same
|
||||
//- twoFactorAuthPopup Member Settings opens.
|
||||
.two-factor-required(role="dialog" aria-modal="true" aria-labelledby="two-factor-required-title")
|
||||
.two-factor-required-box
|
||||
h2#two-factor-required-title {{_ 'two-factor-required-title'}}
|
||||
p {{_ 'two-factor-required-text'}}
|
||||
+twoFactorAuthPopup
|
||||
.buttonsContainer
|
||||
button.js-two-factor-required-logout(type="button") {{_ 'log-out'}}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Blaze } from 'meteor/blaze';
|
||||
import { Template } from 'meteor/templating';
|
||||
import { Tracker } from 'meteor/tracker';
|
||||
|
||||
// REQUIRE_2FA: after signing in, ask whether this account must still set up
|
||||
// two-factor authentication, and if so show nothing but the set-up
|
||||
// (twoFactorRequired.jade). accounts-2fa publishes the account's own
|
||||
// services.twoFactorAuthentication.type, so finishing the set-up is seen at
|
||||
// once; the page then reloads, because every subscription the server left
|
||||
// empty meanwhile has to be made again.
|
||||
let overlay = null;
|
||||
|
||||
function closeOverlay() {
|
||||
if (!overlay) return;
|
||||
Blaze.remove(overlay.view);
|
||||
overlay.node.remove();
|
||||
overlay = null;
|
||||
}
|
||||
|
||||
function openOverlay() {
|
||||
if (overlay) return;
|
||||
const node = document.createElement('div');
|
||||
document.body.appendChild(node);
|
||||
overlay = { node, view: Blaze.render(Template.twoFactorRequired, node) };
|
||||
}
|
||||
|
||||
Template.twoFactorRequired.events({
|
||||
'click .js-two-factor-required-logout'() {
|
||||
Meteor.logout(() => closeOverlay());
|
||||
},
|
||||
});
|
||||
|
||||
Meteor.startup(() => {
|
||||
let askedFor = null;
|
||||
Tracker.autorun(() => {
|
||||
const userId = Meteor.userId();
|
||||
const user = Meteor.user({ fields: { 'services.twoFactorAuthentication.type': 1 } });
|
||||
if (!userId) {
|
||||
askedFor = null;
|
||||
closeOverlay();
|
||||
return;
|
||||
}
|
||||
const enabled = user?.services?.twoFactorAuthentication?.type === 'otp';
|
||||
if (overlay && enabled) {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
if (askedFor === userId) return;
|
||||
askedFor = userId;
|
||||
Meteor.call('getTwoFactorRequirement', (error, result) => {
|
||||
if (error || !result || Meteor.userId() !== userId) return;
|
||||
if (result.required && !result.enabled) openOverlay();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -140,7 +140,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
color: #8c8c8c !important;
|
||||
color: var(--wekan-quiet-text, #595959) !important;
|
||||
}
|
||||
/* Outweighs .pop-over-list li > a:not(.disabled):hover, which would otherwise
|
||||
paint the delete button with the generic blue row-hover colour. */
|
||||
@@ -149,7 +149,7 @@
|
||||
background-color: #b04632;
|
||||
}
|
||||
.avatar-upload-hints {
|
||||
color: #8c8c8c;
|
||||
color: var(--wekan-quiet-text, #595959);
|
||||
font-size: calc(12px * var(--wekan-ui-font-scale, 1));
|
||||
line-height: calc(16px * var(--wekan-ui-font-scale, 1));
|
||||
margin: 6px 0;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
template(name="userAvatar")
|
||||
a.member(class="js-{{#if assignee}}assignee{{else}}member{{/if}} {{memberAccountClass}}" title="{{#if userData}}{{userData.profile.fullname}} ({{userData.username}}) {{_ memberType}}{{#if isImportedMember}} — {{_ 'imported-member-no-account'}}{{else}}{{#if isInactiveMember}} — {{_ 'inactive-member'}}{{/if}}{{/if}}{{/if}}")
|
||||
//- WCAG 2.5.3 Label in Name: the visible text of an initials avatar is its initials,
|
||||
//- so the accessible name starts with them (initialsInName). A picture has no text.
|
||||
a.member(class="js-{{#if assignee}}assignee{{else}}member{{/if}} {{memberAccountClass}}" title="{{#if userData}}{{userData.profile.fullname}} ({{userData.username}}) {{_ memberType}}{{#if isImportedMember}} — {{_ 'imported-member-no-account'}}{{else}}{{#if isInactiveMember}} — {{_ 'inactive-member'}}{{/if}}{{/if}}{{/if}}" aria-label="{{initialsInName}}{{#if userData}}{{userData.profile.fullname}} ({{userData.username}}) {{_ memberType}}{{#if isImportedMember}} — {{_ 'imported-member-no-account'}}{{else}}{{#if isInactiveMember}} — {{_ 'inactive-member'}}{{/if}}{{/if}}{{/if}}")
|
||||
if userData
|
||||
if userData.profile.avatarUrl
|
||||
img.avatar.avatar-image(src="{{avatarUrl}}" alt="")
|
||||
|
||||
@@ -45,6 +45,18 @@ const AVATAR_USER_FIELDS = {
|
||||
};
|
||||
|
||||
Template.userAvatar.helpers({
|
||||
// WCAG 2.5.3 Label in Name: an avatar drawn as initials shows those letters as
|
||||
// text, so the link's accessible name has to contain them. The name is the
|
||||
// initials followed by the title, "AB Ann Brown (ann) Admin". A picture shows
|
||||
// no text, so its name stays the title.
|
||||
initialsInName() {
|
||||
const user = ReactiveCache.getUser(this.userId, { fields: AVATAR_USER_FIELDS });
|
||||
if (user && user.profile && user.profile.avatarUrl) return '';
|
||||
const defaultAvatar = Template.userAvatar.__helpers.get('defaultAvatarUrl');
|
||||
if (user && defaultAvatar && defaultAvatar.call(this)) return '';
|
||||
const initials = (user && user.getInitials && user.getInitials()) || '';
|
||||
return initials ? `${initials} ` : '';
|
||||
},
|
||||
defaultAvatarUrl() {
|
||||
if (!(Meteor.settings.public && Meteor.settings.public.defaultAvatar)) return '';
|
||||
const userId = this.userId;
|
||||
|
||||
@@ -204,3 +204,57 @@ body.mobile-mode.userform-layout {
|
||||
#at-meteor-developer, #at-weibo, #at-meetup) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* #6835: Member Settings / Change Settings. Its rows are the shared popup list
|
||||
(popup.css: a flex row, every line 33px high, the icon centred), which reads
|
||||
well for one-line items and badly for these: a setting whose words wrap -
|
||||
most of them, in Persian and German - became two 33px lines with its icon
|
||||
floating between them and the next setting right below, with nothing to
|
||||
tell one from the other. Here a row is an icon column, the words, and the
|
||||
check mark at the end; the icon sits on the first line of the words, a
|
||||
wrapped line is an ordinary line high, and a rule divides the rows. Scoped
|
||||
to the list's own class: the member menu before it shares its .pop-over. */
|
||||
.pop-over-list.change-settings-list li + li {
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.pop-over-list.change-settings-list li > a {
|
||||
align-items: flex-start;
|
||||
gap: 0;
|
||||
line-height: 1.35;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
.pop-over-list.change-settings-list li > a > i.fa:first-child {
|
||||
flex: 0 0 1.4em;
|
||||
margin-inline-end: 0.6em;
|
||||
line-height: inherit;
|
||||
text-align: center;
|
||||
}
|
||||
.pop-over-list.change-settings-list li > a > i.fa-check {
|
||||
flex: 0 0 auto;
|
||||
margin-inline-start: auto;
|
||||
padding-inline-start: 0.6em;
|
||||
line-height: inherit;
|
||||
}
|
||||
.pop-over-list.change-settings-list li > label.bold {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
line-height: 1.35;
|
||||
padding: 8px 10px 4px;
|
||||
}
|
||||
.pop-over-list.change-settings-list li > label.bold > i.fa {
|
||||
flex: 0 0 1.4em;
|
||||
margin-inline-end: 0.6em;
|
||||
text-align: center;
|
||||
line-height: inherit;
|
||||
}
|
||||
/* The Card settings checkbox row: the box on the first line of its words,
|
||||
with the same space between them as an icon has. */
|
||||
.pop-over-list.change-settings-list .js-rescue-card-description {
|
||||
align-items: flex-start;
|
||||
gap: 0.6em;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.pop-over-list.change-settings-list .js-rescue-card-description .materialCheckBox {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 0.15em;
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
template(name="headerUserBar")
|
||||
#header-user-bar
|
||||
a.header-user-bar-name.js-open-header-member-menu(title="{{_ 'memberMenuPopup-title'}}")
|
||||
//- aria-label: WCAG 2.5.3 Label in Name - the name shown (and the initials of the
|
||||
//- avatar) are part of the accessible name, followed by what the link opens.
|
||||
a.header-user-bar-name.js-open-header-member-menu(title="{{_ 'memberMenuPopup-title'}}" aria-label="{{userBarLabel}}")
|
||||
.header-user-bar-avatar
|
||||
+userAvatar(userId=currentUser._id)
|
||||
unless isMiniScreen
|
||||
@@ -408,9 +410,15 @@ template(name="changeFontPopup")
|
||||
.color-wheel-wrap
|
||||
input.js-ui-text-color(type="color" value=textColorHex title="{{_ 'text-color'}}")
|
||||
a.js-reset-text-color(class="{{#unless hasTextColor}}disabled{{/unless}}") {{_ 'remove-btn'}}
|
||||
//- WCAG 1.4.3: the colour is made legible on the theme's surface; this says so.
|
||||
if textColorAdjusted
|
||||
p.ui-text-color-adjusted.js-ui-text-color-adjusted {{_ 'text-color'}}: {{readableTextColor}} (4.5:1)
|
||||
|
||||
template(name="changeSettingsPopup")
|
||||
ul.pop-over-list
|
||||
//- #6835: its own class - a popup keeps the screens before it (the member
|
||||
menu) side by side in the same .pop-over, so the popup's data-popup would
|
||||
restyle those too. userForm.css
|
||||
ul.pop-over-list.change-settings-list
|
||||
li
|
||||
a.js-toggle-desktop-drag-handles
|
||||
i.fa.fa-arrows
|
||||
@@ -431,6 +439,13 @@ template(name="changeSettingsPopup")
|
||||
| {{_ 'keep-title-tags'}}
|
||||
if isKeepTitleTags
|
||||
i.fa.fa-check
|
||||
//- #6755: the blue focus outline (an accessibility feature, so on by default).
|
||||
li
|
||||
a.js-toggle-hide-focus-outline
|
||||
i.fa.fa-square-o
|
||||
| {{_ 'show-focus-outline'}}
|
||||
if isShowFocusOutline
|
||||
i.fa.fa-check
|
||||
//- #6827: the Add card form stays open after a card is added.
|
||||
li
|
||||
a.js-toggle-keep-add-card-form-open(title="{{_ 'keep-add-card-form-open-description'}}")
|
||||
|
||||
@@ -9,9 +9,23 @@ import Users from '/models/users';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import { ReactiveVar } from 'meteor/reactive-var';
|
||||
import { detectAvailableFonts } from '/client/lib/fontDetector';
|
||||
import { fontFamilyValue, fontSizeValue, UI_FONT_SIZES, isHexColor6, colorValue } from '/models/lib/uiFonts';
|
||||
import { fontFamilyValue, fontSizeValue, UI_FONT_SIZES, isHexColor6, colorValue, readableUiTextColor } from '/models/lib/uiFonts';
|
||||
import { activeThemeName } from '/client/lib/activeTheme';
|
||||
const { allBoardsPath, SECTION_ARCHIVE } = require('/models/lib/allBoardsUrls');
|
||||
|
||||
// WCAG 2.5.3 Label in Name: the link shows the avatar's initials (when the avatar
|
||||
// is not a picture) and the name, so its accessible name starts with exactly
|
||||
// those, followed by what it opens.
|
||||
Template.headerUserBar.helpers({
|
||||
userBarLabel() {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
if (!user) return TAPi18n.__('memberMenuPopup-title');
|
||||
const initials = Template.userAvatar.__helpers.get('initialsInName').call({ userId: user._id });
|
||||
const name = (user.profile && user.profile.fullname) || user.username || '';
|
||||
return `${initials}${name} ${TAPi18n.__('memberMenuPopup-title')}`.replace(/\s+/g, ' ').trim();
|
||||
},
|
||||
});
|
||||
|
||||
Template.headerUserBar.events({
|
||||
'click .js-open-header-member-menu': Popup.open('memberMenu'),
|
||||
'click .js-change-avatar': Popup.open('changeAvatar'),
|
||||
@@ -489,6 +503,11 @@ Template.changeSettingsPopup.helpers({
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
return currentUser ? currentUser.hasKeepTitleTags() : false;
|
||||
},
|
||||
// #6755: the outline is shown unless this member hid it.
|
||||
isShowFocusOutline() {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
return currentUser ? !currentUser.hasHideFocusOutline() : true;
|
||||
},
|
||||
isKeepAddCardFormOpen() {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
return currentUser ? currentUser.hasKeepAddCardFormOpen() : false;
|
||||
@@ -555,6 +574,9 @@ Template.changeSettingsPopup.events({
|
||||
'click .js-toggle-unread-changes'() {
|
||||
if (ReactiveCache.getCurrentUser()) Meteor.call('toggleUnreadChanges');
|
||||
},
|
||||
'click .js-toggle-hide-focus-outline'() {
|
||||
if (ReactiveCache.getCurrentUser()) Meteor.call('toggleHideFocusOutline');
|
||||
},
|
||||
'click .js-toggle-keep-add-card-form-open'() {
|
||||
if (ReactiveCache.getCurrentUser()) Meteor.call('toggleKeepAddCardFormOpen');
|
||||
},
|
||||
@@ -653,12 +675,22 @@ Template.changeFontPopup.helpers({
|
||||
hasTextColor() {
|
||||
return !!Template.instance().textColor.get();
|
||||
},
|
||||
// The chosen text color as it is applied: made legible on the theme's surface
|
||||
// (4.5:1), and whether that changed it, so the member is told why the page does
|
||||
// not show exactly what the wheel did.
|
||||
readableTextColor() {
|
||||
return readableUiTextColor(Template.instance().textColor.get(), activeThemeName());
|
||||
},
|
||||
textColorAdjusted() {
|
||||
const chosen = colorValue(Template.instance().textColor.get());
|
||||
return !!chosen && readableUiTextColor(chosen, activeThemeName()) !== chosen.toLowerCase();
|
||||
},
|
||||
// Preview reflects the chosen font, size and text color.
|
||||
previewStyle() {
|
||||
const tpl = Template.instance();
|
||||
const family = fontFamilyValue(tpl.selected.get());
|
||||
const size = fontSizeValue(tpl.selectedSize.get());
|
||||
const color = colorValue(tpl.textColor.get());
|
||||
const color = readableUiTextColor(tpl.textColor.get(), activeThemeName());
|
||||
return [
|
||||
family && `font-family: ${family};`,
|
||||
size && `font-size: ${size};`,
|
||||
|
||||
Loaded 100 of 757 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user