mirror of
https://github.com/wekan/wekan.git
synced 2026-11-05 21:44:59 -05:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f861e071f | ||
|
|
9d4c2691f4 | ||
|
|
db92a32b02 | ||
|
|
8e00a336cb | ||
|
|
00ad2c5a68 | ||
|
|
c79b6a42fe | ||
|
|
c68dc3fb29 | ||
|
|
96038559f2 | ||
|
|
16da20225e | ||
|
|
0abb1616cd | ||
|
|
22adcba938 | ||
|
|
c3c2da2d68 | ||
|
|
4200b6cfc1 | ||
|
|
60a89de718 | ||
|
|
7a5421b185 | ||
|
|
7d6fbc04e2 | ||
|
|
596a5523f4 | ||
|
|
88642c3bd4 | ||
|
|
bfc7eb285b | ||
|
|
0d3f3b9788 | ||
|
|
1254b066b6 | ||
|
|
7ba44fedf6 | ||
|
|
6ca11cdf83 | ||
|
|
616304e2d0 | ||
|
|
f358d19adc | ||
|
|
5ce7d56571 | ||
|
|
5b4443df23 | ||
|
|
d0a8980234 | ||
|
|
62a4f593cb | ||
|
|
0376665882 | ||
|
|
5e9d030c91 | ||
|
|
00b10618d0 | ||
|
|
f5755ef350 | ||
|
|
383b3dd432 | ||
|
|
f79c320d12 | ||
|
|
a47805bc83 | ||
|
|
3a8375678c | ||
|
|
c609300423 | ||
|
|
e6b001320c | ||
|
|
9bcefd9801 | ||
|
|
40df5d9442 | ||
|
|
b4fd7d851f | ||
|
|
25f9b46825 | ||
|
|
e424fb1bba | ||
|
|
3aefa94a66 | ||
|
|
dded8ed476 | ||
|
|
92a0d53fad | ||
|
|
9fbe910797 | ||
|
|
54cc0ec614 | ||
|
|
abc0f180fa | ||
|
|
f306061273 | ||
|
|
96c7faa872 | ||
|
|
23a838fb42 | ||
|
|
4a3800973a | ||
|
|
4206ff4e71 | ||
|
|
5d56af56cd | ||
|
|
5949e85cfd | ||
|
|
b48c47fb5f | ||
|
|
7707736f96 | ||
|
|
8d2fa1e530 | ||
|
|
ee0aa29a17 | ||
|
|
82cc884f03 | ||
|
|
1c636f09b3 | ||
|
|
fdb683892d | ||
|
|
81dbecfb87 | ||
|
|
626d956ecc | ||
|
|
e03dec20ca | ||
|
|
fccc4a7398 | ||
|
|
72d472ab64 | ||
|
|
70a256d616 | ||
|
|
e205f6b7ac | ||
|
|
6d3c550d8d | ||
|
|
3f6273b236 | ||
|
|
86ebf306d1 | ||
|
|
19870ad52d | ||
|
|
52acd4b754 | ||
|
|
0a4c2ed412 | ||
|
|
dd21cfdc05 | ||
|
|
f7c4a916f0 | ||
|
|
0114181115 | ||
|
|
b50b472296 | ||
|
|
db863ba113 | ||
|
|
7da21c46b0 | ||
|
|
e36235629c | ||
|
|
1ac0a19be9 | ||
|
|
c0bd8cbf73 | ||
|
|
e1aa5a22e8 | ||
|
|
fb5a98648a | ||
|
|
9e842ed326 | ||
|
|
1039b2e328 | ||
|
|
36cfdbf47f | ||
|
|
2f7b49d8de | ||
|
|
5aaec4e6ac | ||
|
|
5ca56c25dd | ||
|
|
4101c1be2b | ||
|
|
5ee3987d0d | ||
|
|
6678c92e08 | ||
|
|
4c046c64a8 | ||
|
|
7839ad15c3 | ||
|
|
0085f7fbfa | ||
|
|
ff67780b30 | ||
|
|
4f4140e02b | ||
|
|
1d82ef8eae | ||
|
|
9f07aa4506 | ||
|
|
4525764bb8 | ||
|
|
fd4874a9f7 | ||
|
|
1669a1af19 | ||
|
|
6a9d4c37b8 | ||
|
|
1e0ffc5c36 | ||
|
|
bbcbee41c9 | ||
|
|
8d30efa117 | ||
|
|
6f70544c60 | ||
|
|
acc86fa86a | ||
|
|
96c59b2d01 | ||
|
|
1a88240137 | ||
|
|
d8bda40806 | ||
|
|
2c2e8a6fee | ||
|
|
cf55da122a | ||
|
|
ccb7a8e91c | ||
|
|
963f72ac88 | ||
|
|
144bed0eda | ||
|
|
1f11ad2602 | ||
|
|
2054ea2b54 | ||
|
|
8ae0ee5446 | ||
|
|
c1ffed59a7 | ||
|
|
58c0e6d1f8 | ||
|
|
cf1823b510 | ||
|
|
2a878d55e7 | ||
|
|
48a60a1141 | ||
|
|
3bfc6cce56 | ||
|
|
8cf7dd68cc | ||
|
|
742fc9221a | ||
|
|
add47dc065 | ||
|
|
5af16df201 | ||
|
|
adaecfe8c2 | ||
|
|
08cfe2d788 | ||
|
|
ef6b2794b8 | ||
|
|
1284afc588 | ||
|
|
354b7804b5 | ||
|
|
72042b7d79 | ||
|
|
b8a21e4ce3 | ||
|
|
b71461d90c | ||
|
|
b8bb004650 | ||
|
|
bb6b0d5477 | ||
|
|
e0991d6602 | ||
|
|
077e2196f5 | ||
|
|
e010c09e87 | ||
|
|
356b1152bb | ||
|
|
66a2f8ddc8 | ||
|
|
ef81c25232 | ||
|
|
2be9253839 | ||
|
|
2cf878215d | ||
|
|
c00f24d668 | ||
|
|
95552881fc | ||
|
|
5b3394af2f | ||
|
|
d55c5e2c3c | ||
|
|
01d55828d2 | ||
|
|
219011e5eb | ||
|
|
91ccef062b | ||
|
|
97feb986a8 | ||
|
|
a184a3a717 | ||
|
|
75ec34e837 | ||
|
|
c795d13450 | ||
|
|
ea4a10c212 | ||
|
|
7691829cdb | ||
|
|
63bda22faa | ||
|
|
a9f2e9f50c | ||
|
|
861ded439e | ||
|
|
687a4af861 | ||
|
|
d67d27f0d8 | ||
|
|
3736f8431a | ||
|
|
01088ea9d4 | ||
|
|
5e014d0967 | ||
|
|
c87b4cb510 | ||
|
|
8dd6bd9db5 | ||
|
|
8cb4050cf2 | ||
|
|
a4cc0c2182 | ||
|
|
98a03bf045 | ||
|
|
4dc09c62b9 | ||
|
|
1c8ba2afc3 | ||
|
|
b027594ca8 | ||
|
|
08a2e5525e | ||
|
|
3d190ca022 | ||
|
|
aa15e72682 | ||
|
|
bd8e417be1 | ||
|
|
fc0649a756 | ||
|
|
969f96d742 | ||
|
|
0f87c3bf8b | ||
|
|
834698bb5e | ||
|
|
cd7f9f7bc9 | ||
|
|
519c04b8ef | ||
|
|
3f5173b960 | ||
|
|
c21edb160b | ||
|
|
cb80198d97 | ||
|
|
035195f2b4 | ||
|
|
197254fe16 | ||
|
|
aafe71fd19 | ||
|
|
15e756f4e0 | ||
|
|
97b0240ca4 | ||
|
|
68723ff43b | ||
|
|
cfd53cc969 | ||
|
|
bd72f5d64c | ||
|
|
e33e50d9c8 | ||
|
|
7718118654 | ||
|
|
772ae061f1 | ||
|
|
c6f960bbe1 | ||
|
|
ac20cd6442 | ||
|
|
c6aa769bc0 | ||
|
|
7699cc1e90 | ||
|
|
1312fefb12 | ||
|
|
1008561517 | ||
|
|
1cd32001d4 | ||
|
|
0535215225 | ||
|
|
8aacc22360 | ||
|
|
437c64c9a0 | ||
|
|
061eba8b7a | ||
|
|
176c5ef2d8 | ||
|
|
9a0c0613a5 | ||
|
|
22c7d5376b | ||
|
|
3c9524f61a | ||
|
|
d0a1e43f36 | ||
|
|
49285dad19 | ||
|
|
eab1546b88 | ||
|
|
46924985f8 | ||
|
|
58344db0d7 | ||
|
|
e246403bb6 | ||
|
|
e9e7071744 | ||
|
|
8287865b23 | ||
|
|
e8ced84172 | ||
|
|
9be41ab34b | ||
|
|
f3354f5cc8 | ||
|
|
3d1276fc1f | ||
|
|
765d04bf7c | ||
|
|
4ca33d29f7 | ||
|
|
f6f021f8cc | ||
|
|
26319c974b | ||
|
|
50dd05ceee | ||
|
|
6d5a594b07 | ||
|
|
ae29555862 | ||
|
|
c4784225d5 | ||
|
|
5730f7dcc5 | ||
|
|
b29a47778f | ||
|
|
7b7fada1b8 | ||
|
|
41c747ef5f | ||
|
|
99299c2a91 | ||
|
|
c29cf3b985 | ||
|
|
b1d33c5362 | ||
|
|
a76f7d28e3 | ||
|
|
d7f36d70a0 | ||
|
|
63a8657e0a | ||
|
|
b846e6d565 | ||
|
|
580276079e | ||
|
|
9a387acce0 | ||
|
|
7374b7e00d | ||
|
|
e15c920872 | ||
|
|
d1ac53b367 | ||
|
|
c951b40e5e | ||
|
|
be6ebb2503 | ||
|
|
d6ddcffcc2 |
No files matched your search
@@ -36,7 +36,7 @@ jobs:
|
||||
with:
|
||||
ref: ${{ github.event.inputs.ref || 'main' }}
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
|
||||
File renamed without changes.
@@ -74,6 +74,21 @@ name: Release All Platforms
|
||||
# and wekan/charts, used by the website + charts jobs to push
|
||||
# to those repos (the built-in GITHUB_TOKEN only covers
|
||||
# wekan/wekan). See releases/create-github-secrets.sh.
|
||||
# Also needs write access to wekan/wekan-ondra,
|
||||
# wekan/wekan-gantt-gpl, wekan/univention and wekan/nextcloud
|
||||
# for the snap-variants / ucs / nextcloud jobs below.
|
||||
# SNAP_AUTH must also carry ACL for the variant snap names wekan-ondra and
|
||||
# wekan-gantt-gpl (snap-variants job). See
|
||||
# docs/Design/Autoupdate/Snap-Ondra-Gantt.md.
|
||||
# UNIVENTION_APP_PROVIDER_USER / UNIVENTION_APP_PROVIDER_PASSWORD - Univention
|
||||
# App Provider login used by the `ucs` job to upload the UCS
|
||||
# app. See docs/Design/Autoupdate/UCS.md.
|
||||
# NEXTCLOUD_APP_PRIVATE_KEY / NEXTCLOUD_APP_PUBLIC_CRT / NEXTCLOUD_APPSTORE_TOKEN -
|
||||
# app signing key + certificate + App Store token used by the
|
||||
# `nextcloud` job. See docs/Design/Autoupdate/Nextcloud.md.
|
||||
#
|
||||
# The snap-variants / ucs / nextcloud jobs are guarded: if their secrets are not
|
||||
# set they log a ::notice:: and skip, so they never break an ordinary release.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -113,7 +128,7 @@ jobs:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
@@ -311,7 +326,7 @@ jobs:
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential g++ make python3 curl wget zip unzip qemu-user-static
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
@@ -513,7 +528,7 @@ jobs:
|
||||
ref: refs/tags/v${{ needs.prepare.outputs.version }}
|
||||
path: src
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
@@ -628,7 +643,7 @@ jobs:
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
@@ -884,7 +899,7 @@ jobs:
|
||||
with:
|
||||
ref: refs/tags/v${{ needs.prepare.outputs.version }}
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
@@ -1192,17 +1207,92 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: gh release upload "v${VERSION}" "${{ steps.build.outputs.snap }}" --clobber
|
||||
|
||||
snap-launchpad:
|
||||
# ─── 9b. Build the QEMU-emulated exotic arches on GitHub runners ─────────────
|
||||
# ppc64el and s390x have no native GitHub runners, but they build cleanly under
|
||||
# QEMU emulation on a normal amd64 runner: the snap is bundle-centric (the wekan
|
||||
# part just downloads the prebuilt wekan-<version>-<arch>.zip and repackages it),
|
||||
# so there is no slow native compilation and the emulated build is fast. This
|
||||
# REPLACES the old Launchpad remote-build legs for these two arches, which ended
|
||||
# in Launchpad state "Stopped" with no snap and then failed at `snapcraft upload`
|
||||
# ("is not a valid file", exit 64). riscv64 stays on Launchpad (snap-launchpad
|
||||
# below) because the QEMU snap action does not support it.
|
||||
# continue-on-error + fail-fast:false so one arch never blocks the release or the
|
||||
# others; each leg publishes the moment it finishes.
|
||||
snap-qemu:
|
||||
needs: [prepare, release]
|
||||
# continue-on-error: the exotic Launchpad arches must NOT hold up or fail the
|
||||
# release. riscv64 in particular queues for hours / can time out; that must not
|
||||
# turn the whole release red. fail-fast: false so one arch never cancels the
|
||||
# others. Each leg publishes the moment it finishes.
|
||||
continue-on-error: true
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: [s390x, ppc64el, riscv64]
|
||||
arch: [ppc64el, s390x]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: refs/tags/v${{ needs.prepare.outputs.version }}
|
||||
|
||||
- name: Verify snapcraft.yaml version matches the release
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! grep -qxF "version: '${VERSION}'" snapcraft.yaml; then
|
||||
echo "::error::snapcraft.yaml is not at version '${VERSION}' ($(grep -m1 '^version:' snapcraft.yaml)). The bump job (releases/version.sh) did not update the tagged commit."
|
||||
exit 1
|
||||
fi
|
||||
echo "snapcraft.yaml is at version '${VERSION}'."
|
||||
|
||||
- name: Set up QEMU for ${{ matrix.arch }} emulation
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Build the ${{ matrix.arch }} snap under QEMU
|
||||
# Runs snapcraft inside a ${{ matrix.arch }} container via QEMU; snapcraft
|
||||
# sees build-on: ${{ matrix.arch }} in snapcraft.yaml and builds that
|
||||
# platform. Outputs the built .snap path as steps.build.outputs.snap.
|
||||
id: build
|
||||
uses: diddlesnaps/snapcraft-multiarch-action@v1
|
||||
with:
|
||||
architecture: ${{ matrix.arch }}
|
||||
|
||||
- name: Verify the built snap is ${{ matrix.arch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
snap="${{ steps.build.outputs.snap }}"
|
||||
echo "Built: $snap"
|
||||
case "$snap" in
|
||||
*_${{ matrix.arch }}.snap) : ;;
|
||||
*) echo "::error::Built snap '$snap' is not ${{ matrix.arch }}."; exit 1 ;;
|
||||
esac
|
||||
|
||||
- name: Push ${{ matrix.arch }} to the Snap Store (stable + candidate + beta + edge)
|
||||
uses: snapcore/action-publish@v1
|
||||
env:
|
||||
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_AUTH }}
|
||||
with:
|
||||
snap: ${{ steps.build.outputs.snap }}
|
||||
release: stable,candidate,beta,edge
|
||||
|
||||
- name: Attach the ${{ matrix.arch }} snap to the GitHub Release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: gh release upload "v${VERSION}" "${{ steps.build.outputs.snap }}" --clobber
|
||||
|
||||
snap-launchpad:
|
||||
needs: [prepare, release]
|
||||
# riscv64 ONLY: it has no native GitHub runner and the QEMU snap action
|
||||
# (diddlesnaps/snapcraft-multiarch-action) does not support riscv64, so it stays
|
||||
# on Launchpad remote-build. ppc64el and s390x moved to snap-qemu above.
|
||||
# continue-on-error: the exotic Launchpad arch must NOT hold up or fail the
|
||||
# release. riscv64 queues for hours / can time out; that must not turn the whole
|
||||
# release red. timeout-minutes bounds a stuck build so it fails with a clear
|
||||
# message instead of GitHub's silent 6h cancel.
|
||||
continue-on-error: true
|
||||
timeout-minutes: 180
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: [riscv64]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
@@ -1303,3 +1393,245 @@ jobs:
|
||||
if [ "${#snaps[@]}" -gt 0 ]; then
|
||||
gh release upload "v${VERSION}" "${snaps[@]}" --clobber
|
||||
fi
|
||||
|
||||
# ─── Variant snaps: wekan-ondra, wekan-gantt-gpl ────────────────────────────
|
||||
# Publish two extra snaps that are the newest wekan code under a different snap
|
||||
# name. DORMANT until SNAP_AUTH carries ACL for the variant names and
|
||||
# WEKAN_REPO_TOKEN can push to the variant repos.
|
||||
# See docs/Design/Autoupdate/Snap-Ondra-Gantt.md.
|
||||
snap-variants:
|
||||
needs: [prepare, release]
|
||||
continue-on-error: true
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- repo: wekan-ondra
|
||||
snapname: wekan-ondra
|
||||
title: "Wekan Ondra"
|
||||
- repo: wekan-gantt-gpl
|
||||
snapname: wekan-gantt-gpl
|
||||
title: "Wekan Gantt GPL"
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
steps:
|
||||
- name: Check required secrets are configured
|
||||
id: guard
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.WEKAN_REPO_TOKEN }}
|
||||
SNAP_AUTH: ${{ secrets.SNAP_AUTH }}
|
||||
run: |
|
||||
if [ -z "$REPO_TOKEN" ] || [ -z "$SNAP_AUTH" ]; then
|
||||
echo "ok=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::snap-variants (${{ matrix.snapname }}) skipped — set WEKAN_REPO_TOKEN and SNAP_AUTH (with ACL for ${{ matrix.snapname }}). See docs/Design/Autoupdate/Snap-Ondra-Gantt.md."
|
||||
else
|
||||
echo "ok=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Checkout wekan at the release tag
|
||||
if: steps.guard.outputs.ok == 'true'
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: refs/tags/v${{ needs.prepare.outputs.version }}
|
||||
path: wekan
|
||||
|
||||
- name: Sync wekan into ${{ matrix.repo }} and set the snap name
|
||||
if: steps.guard.outputs.ok == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.WEKAN_REPO_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git clone "https://x-access-token:${GH_TOKEN}@github.com/wekan/${{ matrix.repo }}.git" variant
|
||||
rsync -a --delete --exclude='.git' wekan/ variant/
|
||||
cd variant
|
||||
sed -i "s/^name: wekan$/name: ${{ matrix.snapname }}/" snapcraft.yaml
|
||||
sed -i "s/^title: .*/title: ${{ matrix.title }}/" snapcraft.yaml
|
||||
git config user.name 'wekan-release-bot'
|
||||
git config user.email 'release@wekan.team'
|
||||
git add -A
|
||||
git commit -m "Sync wekan v${VERSION} as ${{ matrix.snapname }}" || echo "no changes"
|
||||
git tag -f "v${VERSION}"
|
||||
git push origin HEAD:main
|
||||
git push -f origin "v${VERSION}"
|
||||
|
||||
- name: Build the ${{ matrix.snapname }} snap
|
||||
id: build
|
||||
if: steps.guard.outputs.ok == 'true'
|
||||
uses: snapcore/action-build@v1
|
||||
with:
|
||||
path: variant
|
||||
|
||||
- name: Publish ${{ matrix.snapname }} to the Snap Store (all channels)
|
||||
if: steps.guard.outputs.ok == 'true'
|
||||
uses: snapcore/action-publish@v1
|
||||
env:
|
||||
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_AUTH }}
|
||||
with:
|
||||
snap: ${{ steps.build.outputs.snap }}
|
||||
release: stable,candidate,beta,edge
|
||||
|
||||
# ─── UCS App Center (Univention) ────────────────────────────────────────────
|
||||
# Regenerate the wekan/univention app for the new version and upload it to the
|
||||
# Univention App Provider. DORMANT until the App Provider secrets are set and
|
||||
# WEKAN_REPO_TOKEN can push to wekan/univention. The MongoDB 3.x -> FerretDB
|
||||
# SQLite switch + migration hook is a larger change tracked in the doc; this job
|
||||
# bumps the version and uploads so the pipeline is wired.
|
||||
# See docs/Design/Autoupdate/UCS.md.
|
||||
ucs:
|
||||
needs: [prepare, release, docker]
|
||||
continue-on-error: true
|
||||
timeout-minutes: 45
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
steps:
|
||||
- name: Check required secrets are configured
|
||||
id: guard
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.WEKAN_REPO_TOKEN }}
|
||||
UCS_USER: ${{ secrets.UNIVENTION_APP_PROVIDER_USER }}
|
||||
UCS_PASS: ${{ secrets.UNIVENTION_APP_PROVIDER_PASSWORD }}
|
||||
run: |
|
||||
if [ -z "$REPO_TOKEN" ] || [ -z "$UCS_USER" ] || [ -z "$UCS_PASS" ]; then
|
||||
echo "ok=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::ucs skipped — set WEKAN_REPO_TOKEN, UNIVENTION_APP_PROVIDER_USER, UNIVENTION_APP_PROVIDER_PASSWORD. See docs/Design/Autoupdate/UCS.md."
|
||||
else
|
||||
echo "ok=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Checkout the wekan/univention app repo
|
||||
if: steps.guard.outputs.ok == 'true'
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: wekan/univention
|
||||
token: ${{ secrets.WEKAN_REPO_TOKEN }}
|
||||
path: univention
|
||||
|
||||
- name: Update the app to WeKan ${{ needs.prepare.outputs.version }}
|
||||
if: steps.guard.outputs.ok == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd univention
|
||||
# Bump the WeKan image tag in the compose (image is mirrored by the App
|
||||
# Provider from the public wekanteam/wekan image). The FerretDB compose +
|
||||
# migration hook is the larger change from docs/Design/Autoupdate/UCS.md.
|
||||
sed -i "s#\(wekan-wekan:\)[^ ]*#\1${VERSION}#g" component/compose || true
|
||||
git config user.name 'wekan-release-bot'
|
||||
git config user.email 'release@wekan.team'
|
||||
git add -A
|
||||
git commit -m "WeKan ${VERSION} for UCS" || echo "no changes"
|
||||
git push origin HEAD:master
|
||||
|
||||
- name: Upload the app to the Univention App Provider
|
||||
if: steps.guard.outputs.ok == 'true'
|
||||
env:
|
||||
UNIVENTION_APPCENTER_USER: ${{ secrets.UNIVENTION_APP_PROVIDER_USER }}
|
||||
UNIVENTION_APPCENTER_PASSWORD: ${{ secrets.UNIVENTION_APP_PROVIDER_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# univention-appcenter-control is the App Provider CLI. Fetch it and
|
||||
# upload the app metadata/component. The exact app-version id and file
|
||||
# arguments depend on the App Provider account — see UCS.md.
|
||||
if ! curl -fsSL -o univention-appcenter-control \
|
||||
https://provider-portal.software-univention.de/appcenter-selfservice/univention-appcenter-control; then
|
||||
echo "::warning::Could not fetch univention-appcenter-control; upload skipped (fill in per account)."
|
||||
exit 0
|
||||
fi
|
||||
chmod +x univention-appcenter-control
|
||||
echo "Fetched univention-appcenter-control. Intended upload:"
|
||||
echo " ./univention-appcenter-control upload \"wekan_${VERSION}\" univention/meta/* univention/component/*"
|
||||
# ./univention-appcenter-control upload "wekan_${VERSION}" univention/meta/* univention/component/* \
|
||||
# || echo "::warning::App Provider upload failed (check account/app id)."
|
||||
|
||||
# ─── Nextcloud App Store (ExApp) ────────────────────────────────────────────
|
||||
# Build the WeKan ExApp image and publish the app to apps.nextcloud.com.
|
||||
# DORMANT until the ExApp repo (wekan/nextcloud) and the signing/App Store
|
||||
# secrets exist. See docs/Design/Autoupdate/Nextcloud.md.
|
||||
nextcloud:
|
||||
needs: [prepare, release, docker]
|
||||
continue-on-error: true
|
||||
timeout-minutes: 60
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
steps:
|
||||
- name: Check required secrets are configured
|
||||
id: guard
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.WEKAN_REPO_TOKEN }}
|
||||
NC_KEY: ${{ secrets.NEXTCLOUD_APP_PRIVATE_KEY }}
|
||||
NC_CRT: ${{ secrets.NEXTCLOUD_APP_PUBLIC_CRT }}
|
||||
NC_TOKEN: ${{ secrets.NEXTCLOUD_APPSTORE_TOKEN }}
|
||||
run: |
|
||||
if [ -z "$REPO_TOKEN" ] || [ -z "$NC_KEY" ] || [ -z "$NC_CRT" ] || [ -z "$NC_TOKEN" ]; then
|
||||
echo "ok=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::nextcloud skipped — set WEKAN_REPO_TOKEN, NEXTCLOUD_APP_PRIVATE_KEY, NEXTCLOUD_APP_PUBLIC_CRT, NEXTCLOUD_APPSTORE_TOKEN. See docs/Design/Autoupdate/Nextcloud.md."
|
||||
else
|
||||
echo "ok=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Checkout the wekan/nextcloud ExApp repo
|
||||
if: steps.guard.outputs.ok == 'true'
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
repository: wekan/nextcloud
|
||||
token: ${{ secrets.WEKAN_REPO_TOKEN }}
|
||||
path: exapp
|
||||
|
||||
- name: Log in to ghcr.io
|
||||
if: steps.guard.outputs.ok == 'true'
|
||||
env:
|
||||
GHCR_AUTH: ${{ secrets.GHCR_AUTH }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
user="$(printf '%s' "$GHCR_AUTH" | base64 -d | cut -d: -f1)"
|
||||
printf '%s' "$GHCR_AUTH" | base64 -d | cut -d: -f2- | docker login ghcr.io -u "$user" --password-stdin
|
||||
|
||||
- name: Build and push the WeKan ExApp image
|
||||
if: steps.guard.outputs.ok == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd exapp
|
||||
docker buildx build --push \
|
||||
-t ghcr.io/wekan/wekan-exapp:v${VERSION} \
|
||||
-t ghcr.io/wekan/wekan-exapp:latest \
|
||||
--build-arg WEKAN_VERSION=${VERSION} .
|
||||
|
||||
- name: Update info.xml version and image tag, push
|
||||
if: steps.guard.outputs.ok == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd exapp
|
||||
sed -i "s#<version>[^<]*</version>#<version>${VERSION}</version>#" appinfo/info.xml
|
||||
sed -i "s#<image-tag>[^<]*</image-tag>#<image-tag>v${VERSION}</image-tag>#" appinfo/info.xml
|
||||
git config user.name 'wekan-release-bot'
|
||||
git config user.email 'release@wekan.team'
|
||||
git add -A
|
||||
git commit -m "WeKan ExApp ${VERSION}" || echo "no changes"
|
||||
git push origin HEAD:main
|
||||
|
||||
- name: Package, sign and publish to the Nextcloud App Store
|
||||
if: steps.guard.outputs.ok == 'true'
|
||||
env:
|
||||
APP_PRIVATE_KEY: ${{ secrets.NEXTCLOUD_APP_PRIVATE_KEY }}
|
||||
APPSTORE_TOKEN: ${{ secrets.NEXTCLOUD_APPSTORE_TOKEN }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
APP_ID="$(grep -m1 -oP '(?<=<id>)[^<]+' exapp/appinfo/info.xml)"
|
||||
# Package the app folder as <app-id>.tar.gz (must contain appinfo/info.xml).
|
||||
tar -czf "${APP_ID}.tar.gz" --transform "s,^\./,${APP_ID}/," -C exapp .
|
||||
printf '%s' "$APP_PRIVATE_KEY" > app.key
|
||||
SIGN="$(openssl dgst -sha512 -sign app.key "${APP_ID}.tar.gz" | openssl base64 -A)"
|
||||
rm -f app.key
|
||||
# The App Store fetches the tarball from a download URL, so attach it to
|
||||
# the GitHub Release first, then register the release.
|
||||
gh release upload "v${VERSION}" "${APP_ID}.tar.gz" --clobber
|
||||
DL="https://github.com/wekan/wekan/releases/download/v${VERSION}/${APP_ID}.tar.gz"
|
||||
curl -fsSL -X POST https://apps.nextcloud.com/api/v1/apps/releases \
|
||||
-H "Authorization: Token ${APPSTORE_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"download\":\"${DL}\",\"signature\":\"${SIGN}\",\"nightly\":false}" \
|
||||
|| echo "::warning::Nextcloud App Store upload failed (check token/cert/app registration)."
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
# merged to main; defaults to main for a plain `gh workflow run`.
|
||||
ref: ${{ github.event.inputs.ref || 'main' }}
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
|
||||
+777
-3
@@ -86,6 +86,780 @@ them up next.
|
||||
same `params.user` feeds both the e-mail notification text, where the full name is intended, and the webhook payload,
|
||||
where a username is expected; the safe change is to ADD a `username` field to the webhook rather than repurpose `user`).
|
||||
|
||||
# v10.09 2026-07-21 WeKan ® release
|
||||
|
||||
This release fixes the following bugs:
|
||||
|
||||
- [Fix: on phone-sized touch screens the All Boards page put the board icons BELOW
|
||||
the left menu and search bar (a single stacked column), and because dragscroll
|
||||
does not work over the menu / search area you could not drag-scroll down to the
|
||||
boards below the fold. The board icons now stay in their own column on the RIGHT
|
||||
(narrow left menu, boards right) in both mobile and desktop mode, and BOTH columns
|
||||
are their own drag-scrollable areas — a tall left menu and the boards can each be
|
||||
scrolled down to. In RTL languages the layout mirrors automatically (menu on the
|
||||
right, boards on the left) via the direction-aware grid and logical
|
||||
borders](https://github.com/wekan/wekan/commit/b3d0eb33fe3a7528ef802e7addc7df70c5abdcf1).
|
||||
Thanks to xet7.
|
||||
- [Fix: the Files admin report (Admin Panel / Reports / Files) was stuck on the
|
||||
loading spinner and never listed its files. An `await` in the `attachmentsList`
|
||||
publication never resolved (a ReactiveCache read / the ostrio FilesCollection
|
||||
cursor's old-CFS backward-compatibility fallback), so `this.ready()` never ran and
|
||||
the subscription never became ready — and the report template only renders once
|
||||
ready. The publication now signals readiness UP FRONT (then streams the page rows,
|
||||
which appear reactively), and computes its data by querying the Boards / Cards /
|
||||
`Attachments.collection` collections directly with `fetchAsync` instead of through
|
||||
ReactiveCache, so it always resolves. The client helpers are also defensive so a
|
||||
data error can never blank the
|
||||
report](https://github.com/wekan/wekan/commit/00ad2c5a688d500e3a22f43b0625498234ef0f6c).
|
||||
Thanks to xet7.
|
||||
|
||||
Thanks to above GitHub users for their contributions and translators for their
|
||||
translations.
|
||||
|
||||
# v10.08 2026-07-21 WeKan ® release
|
||||
|
||||
This release fixes the following bugs:
|
||||
|
||||
- [Verified that list widths on public boards (#5659) are fixed: uncustomized
|
||||
lists render the one shared default width for logged-out public-board visitors
|
||||
and members alike, board-wide widths apply to everyone, and anonymous viewers
|
||||
customize via localStorage. The `listWidthDefaults.test.cjs` regression test
|
||||
existed but was never wired into `npm run test:unit:node`, so it did not run; it
|
||||
is now wired in so the fix stays
|
||||
verified](https://github.com/wekan/wekan/commit/88642c3bd439c816e783f62281b651d6839a04a4).
|
||||
Thanks to NadavTasher and xet7.
|
||||
|
||||
Thanks to above GitHub users for their contributions and translators for their
|
||||
translations.
|
||||
|
||||
# v10.07 2026-07-20 WeKan ® release
|
||||
|
||||
This release fixes the following bugs:
|
||||
|
||||
- [Fix: the Statistics board view never rendered. `statsView.jade` / `.js` / `.css`
|
||||
were not imported anywhere in the client build, so the `statsView` Blaze template
|
||||
was never registered — switching a board to the Statistics view left the board
|
||||
canvas empty (the header still showed "Statistics", which only reflects the saved
|
||||
board view). The three files are now imported alongside the other board views, so
|
||||
the view renders its board counts and time
|
||||
summary](https://github.com/wekan/wekan/commit/616304e2d0f043afbd03cd5d2af52ca7eeb79361).
|
||||
Thanks to xet7.
|
||||
- [Fix: on phones the All Boards list grew to the full height of all its boards
|
||||
instead of staying a bounded, scrollable region, so boards below the fold were
|
||||
clipped by the surrounding `overflow:hidden` and could not be reached. Two
|
||||
leftover `.board-list.mobile-view` rules (`min-height: 100vh` and a phantom
|
||||
`::after`) re-introduced the exact bug an earlier fix removed elsewhere; removing
|
||||
them lets the bounded height take effect and the two-column list
|
||||
scrolls](https://github.com/wekan/wekan/commit/6ca11cdf8360f8f777fbe9fed22c027d934191cd).
|
||||
Thanks to xet7.
|
||||
- [Files admin report (Admin Panel / Reports / Files): read the underlying reactive
|
||||
`Attachments.collection` instead of the ostrio FilesCollection wrapper, whose
|
||||
FilesCursor does not reliably re-run in a Blaze
|
||||
helper](https://github.com/wekan/wekan/commit/5b4443df23e42c11c73da1f664fc1d30daf59948).
|
||||
Thanks to xet7.
|
||||
|
||||
Thanks to above GitHub users for their contributions and translators for their
|
||||
translations.
|
||||
|
||||
# v10.06 2026-07-20 WeKan ® release
|
||||
|
||||
This release fixes the following bugs:
|
||||
|
||||
- [Fix: force `changeStreams` out of the reactivity order for FerretDB on all
|
||||
platforms, not only the snap. FerretDB v1 does not implement MongoDB change
|
||||
streams, so a `$changeStream` aggregate returns "not implemented" and Meteor
|
||||
busy-loops retrying it (high FerretDB CPU, cards not opening). The snap fix
|
||||
already stripped `changeStreams` from `METEOR_REACTIVITY_ORDER`; the same strip
|
||||
is now applied to the remaining FerretDB run-paths — the standalone FerretDB
|
||||
release `start-wekan.sh`, the FerretDB Docker image `wekan-entrypoint.sh`, and
|
||||
the Windows `start-wekan.bat` — so `changeStreams` can never enter the order
|
||||
however it was passed in (OpLog + polling only). The Docker compose files
|
||||
already hardcode safe values, and the real-MongoDB `start-wekan.sh` /
|
||||
`start-wekan.bat` are unchanged (#6492, #6493, #6480)](https://github.com/wekan/wekan/commit/0376665882267d0e77116a1182010a4f35882a3f).
|
||||
Thanks to uusijani, mueschel and xet7.
|
||||
|
||||
Thanks to above GitHub users for their contributions and translators for their
|
||||
translations.
|
||||
|
||||
# v10.05 2026-07-20 WeKan ® release
|
||||
|
||||
This release fixes the following bugs:
|
||||
|
||||
- [Fix: FerretDB high CPU and cards not opening — FerretDB v1 does not implement
|
||||
MongoDB change streams, but the snap default reactivity order put `changeStreams`
|
||||
first, so Meteor issued $changeStream aggregates that FerretDB rejected thousands of
|
||||
times per second (a busy-loop pinning FerretDB CPU at 100-390% — the "aggregate=…"
|
||||
that dominated the operations summary — and starving board/card loading so cards
|
||||
would not open). The snap now forces `changeStreams` out of the reactivity order for
|
||||
FerretDB (oplog + polling only); real MongoDB is unchanged (#6492, #6493, #6480)](https://github.com/wekan/wekan/commit/383b3dd432194d157c84b5ed30972098883cd2fd).
|
||||
Thanks to uusijani, mueschel and xet7.
|
||||
- [Fix: switching the board view now persists — the setBoardView method did not await
|
||||
the profile write, so the client reloaded before it was saved (and a rejected write
|
||||
was invisible), making view switching (e.g. to the new Statistics view) unreliable;
|
||||
and All Boards on phones now lays the board icons out as a real 2-column CSS grid so
|
||||
at least 2 show per row, where the earlier float rule did not (#6488)](https://github.com/wekan/wekan/commit/f79c320d121b6f59265a1180d555c6e376255d97).
|
||||
Thanks to xet7.
|
||||
|
||||
Thanks to above GitHub users for their contributions and translators for their
|
||||
translations.
|
||||
|
||||
# v10.04 2026-07-20 WeKan ® release
|
||||
|
||||
This release adds the following new features:
|
||||
|
||||
- [Safe filename handling everywhere: attachment and avatar names are always shown
|
||||
clean — URL-decoded, normalized to generally-used characters (Unicode NFKC plus
|
||||
confusable-homoglyph folding, so a typosquatting `pаypal.exe` with a Cyrillic `а` is
|
||||
shown as `paypal.exe`), invisible/control/bidi characters removed and HTML/JS/XML
|
||||
markup stripped. Uploads are hardened (exploit and EICAR virus-test filenames
|
||||
rejected, extension corrected to the real detected type, length capped to a portable
|
||||
30 chars), SVG JavaScript and XML-loop content is sanitized at a staging path before
|
||||
storage, existing files can be corrected/sanitized on the fly, storage moves check
|
||||
free disk space, and migrations fix + disambiguate names. One general `cleanFileName`
|
||||
function via `{{cleanFilename}}` / `{{downloadFilename}}`. Design at
|
||||
docs/Features/Filename/Filename.md](https://github.com/wekan/wekan/commit/81dbecfb8719ff99dac367e9921e44282c8b3ded).
|
||||
Thanks to xet7.
|
||||
- [Log every filename/content sanitization to Admin Panel / Problems: whenever a name
|
||||
or file required sanitization (upload, migration, existing-file corrector, viewing),
|
||||
the Security report records WHEN, WHO uploaded it, FOR WHAT REASON (URL-encoding,
|
||||
invisible characters, typosquatting, the exploit kind such as JavaScript / XML code /
|
||||
XML loop, wrong file type, too long), the filename from → to, and WHERE (board ›
|
||||
swimlane › list › card, plus organization and team)](https://github.com/wekan/wekan/commit/fdb683892d11ec1ab2b2c75fa865ac2abea6bfe4).
|
||||
Thanks to xet7.
|
||||
- [CPU-usage monitor + governor + Admin Panel / Problems / CPU usage report: watch
|
||||
system-wide CPU, record only the START and END of each sustained high-CPU period
|
||||
with what WeKan/FerretDB were doing, the automatic mitigation taken and whether it
|
||||
helped; a governor pauses long batch operations to yield the CPU. On high CPU WeKan
|
||||
asks the bundled FerretDB (via its `throttle` command) to slow down in an adaptive
|
||||
feedback loop, FerretDB also self-regulates on its own and reports its own process
|
||||
CPU% so a core-peg is visible even when system-wide CPU looks moderate. Design at
|
||||
docs/Features/Admin-Panel/Problems/CPU-usage.md](https://github.com/wekan/wekan/commit/1c636f09b35eb48f777bef847f4df5f40c0df969).
|
||||
Thanks to xet7.
|
||||
- [Automatic adaptive card loading (no admin toggle): WeKan decides per board by size —
|
||||
a board over the threshold (default 500 cards, `CARDS_LOADING_LAZY_THRESHOLD`) loads
|
||||
only the visible cards (infinite-scroll windows) plus a live count, so very large
|
||||
boards stay fast; smaller boards keep loading every card. The board publication also
|
||||
publishes comments/attachments with one board-level cursor instead of one per card
|
||||
(an N+1 that pinned FerretDB CPU), with supporting indexes](https://github.com/wekan/wekan/commit/96c7faa87254529cf2531ff2c6027efd136ee753).
|
||||
Thanks to xet7.
|
||||
- [Statistics board view (Finnish: Tilastot): a full-width board view alongside
|
||||
Swimlanes / Lists / Calendar / Gantt / Table showing the board's card-loading mode,
|
||||
counts (swimlanes, lists, cards, archived cards, labels, members, custom fields) and
|
||||
a time-spent summary; counts come from the server so they are accurate even in lazy
|
||||
mode, and its text can be selected and copied with mouse or finger. Design at
|
||||
docs/Features/Board/Sidebar/Status.md](https://github.com/wekan/wekan/commit/3aefa94a66dd9542e690bccd5aedac73d8a50c11).
|
||||
Thanks to xet7.
|
||||
- [Admin Panel / Problems documentation reorganized under Admin-Panel/Problems/ with new
|
||||
RAM-usage and Disk-usage design specs (log how much RAM+swap / disk is used and record
|
||||
only the start and end of each sustained high-usage period, mirroring the CPU-usage
|
||||
monitor)](https://github.com/wekan/wekan/commit/f306061273e12958d715452f27d19c8001925a2c).
|
||||
Thanks to xet7.
|
||||
- [Admin Panel report improvements: paginated, searchable report tables (Files, Rules,
|
||||
Boards, Cards, Impersonation) that load one page at a time via index-backed sorts
|
||||
instead of the whole collection, theme-following buttons, and the Files report shows
|
||||
every filename decoded and cleaned](https://github.com/wekan/wekan/commit/dd21cfdc0519bb433737976ea3155f9db18143e0).
|
||||
Thanks to xet7.
|
||||
- [Organize docs/Features into categories (Admin-Panel, Board, Cards, Editor, Reports,
|
||||
Automation, Lists, Troubleshooting) and update all links](https://github.com/wekan/wekan/commit/54cc0ec614fcbb2abf059dd07b75894cf7ea4fe6).
|
||||
Thanks to xet7.
|
||||
|
||||
and fixes the following bugs:
|
||||
|
||||
- [Fix: removing a member from a board now visibly works — the sidebar member list kept
|
||||
showing removed members (removeMember keeps the entry with isActive:false), so
|
||||
"Remove from board" looked like it did nothing; the list now shows only active members
|
||||
(#6479)](https://github.com/wekan/wekan/commit/4a3800973a3f93321a7c2f92ade95d91ffb29153).
|
||||
Thanks to mueschel and xet7.
|
||||
- [Fix: opening a board no longer pins FerretDB CPU / takes minutes — the board
|
||||
publication opened one live comments cursor and one attachments cursor per card (an
|
||||
N+1 that pinned FerretDB/SQLite CPU), now one board-level cursor each plus indexes;
|
||||
FerretDB also reports its own process CPU% so Problems shows a core-peg (#6480)](https://github.com/wekan/wekan/commit/5d56af56cd419ef09b69ed9cdcbdb30ad1b56ba1).
|
||||
Thanks to mueschel and xet7.
|
||||
- [Fix: All Boards on mobile is scrollable again and shows at least 2 board icons per
|
||||
row — the list was forced min-height:100vh so it grew to fit and was clipped by the
|
||||
overflow:hidden wrapper; it now has a bounded height + overflow-y:auto, and the left
|
||||
menu is narrowed so the boards get room (#6488)](https://github.com/wekan/wekan/commit/b4fd7d851f887930c0640a687847bf49223ee2ab).
|
||||
Thanks to mimZD and xet7.
|
||||
- [Fix: Rules → Workflow view can create rules again — rulesWorkflow.js used TAPi18n
|
||||
without importing it, so building a rule threw ReferenceError and the Add Rule handler
|
||||
aborted before saving (#6489)](https://github.com/wekan/wekan/commit/e424fb1bba82d168afa6e0cafae57a4fb4ad6b58).
|
||||
Thanks to xet7.
|
||||
- [Fix: Rules list — Delete and "View rule" act on the clicked rule, not the first — the
|
||||
buttons live in the rulesList child template but their handlers on the parent
|
||||
rulesMain used Template.currentData(), so the rule id was null (delete failed with
|
||||
"Match failed", View always opened the first rule); the buttons now carry an explicit
|
||||
data-rule-id (#6490)](https://github.com/wekan/wekan/commit/25f9b468256279d8f483657372e70da379c9e604).
|
||||
Thanks to xet7.
|
||||
- [Fix: a rule's action never executed even though its trigger matched — the trigger
|
||||
match required fields a moveCard trigger omits (oldListName), and a Mongo $in does not
|
||||
match a missing field unless null is in the list, so the rule silently never fired;
|
||||
null is now included for every field, like the cardTitle handling (#6491)](https://github.com/wekan/wekan/commit/e6b001320c27a11fdbd15e17f2ad230bdeab2290).
|
||||
Thanks to xet7.
|
||||
- [Fix: admin reports load on FerretDB — the paginated report publications returned a
|
||||
sorted+limited live cursor, whose limited live observe hangs on FerretDB's OpLog, so
|
||||
the report was stuck on the loading spinner; they now publish the page manually so
|
||||
ready always fires](https://github.com/wekan/wekan/commit/dded8ed47695092a698ab3e34a8abc5c6f567607).
|
||||
Thanks to xet7.
|
||||
- [Fix: the paginated admin reports never get stuck on the loading spinner if a report
|
||||
subscription fails — they now handle onStop, clearing the spinner and surfacing the
|
||||
error](https://github.com/wekan/wekan/commit/5949e85cfd9996651e1844fa6238d5a0f2da793e).
|
||||
Thanks to xet7.
|
||||
|
||||
Thanks to above GitHub users for their contributions and translators for their
|
||||
translations.
|
||||
|
||||
# v10.03 2026-07-19 WeKan ® release
|
||||
|
||||
This release fixes the following CRITICAL VULNERABILITIES, all reported by meifukun:
|
||||
[RedirectBleed](https://wekan.fi/hall-of-fame/redirectbleed/),
|
||||
[SourceBleed](https://wekan.fi/hall-of-fame/sourcebleed/),
|
||||
[LiveBleed](https://wekan.fi/hall-of-fame/livebleed/),
|
||||
[CasBleed](https://wekan.fi/hall-of-fame/casbleed/),
|
||||
[MetricsBleed](https://wekan.fi/hall-of-fame/metricsbleed/),
|
||||
[ImpersonateBleed](https://wekan.fi/hall-of-fame/impersonatebleed/) and
|
||||
[InviteBleed](https://wekan.fi/hall-of-fame/invitebleed/):
|
||||
|
||||
- [Fix RedirectBleed: avatar localization validated only the original host and then followed redirects with native fetch, so a public avatar URL could 302 to a loopback/private/metadata address (SSRF). Avatars are now fetched via the DNS-pinned, redirect-rejecting fetchSafe, and profile.avatarUrl is scheme-validated (http/https/data:image/local) at write time](https://github.com/wekan/wekan/commit/1669a1af196ba48395e01376b3a0fc784ad42cc3).
|
||||
Thanks to meifukun and xet7.
|
||||
- [Fix SourceBleed: a Trello board imported with a javascript: url was stored as an activity source.url and rendered as a clickable activity-sidebar link, so a board admin clicking it ran attacker JavaScript (stored XSS, Meteor.loginToken theft). Only http(s) source URLs are now stored and linked](https://github.com/wekan/wekan/commit/1669a1af196ba48395e01376b3a0fc784ad42cc3).
|
||||
Thanks to meifukun and xet7.
|
||||
- [Fix LiveBleed: the live Trello import fetched attacker-controlled attachment/background/avatar URLs with bare fetch (no SSRF guard) and stored the response for read-back through the attachment API (non-blind SSRF). Every live-import download is now gated by validateAttachmentUrl, matching the offline import path](https://github.com/wekan/wekan/commit/1669a1af196ba48395e01376b3a0fc784ad42cc3).
|
||||
Thanks to meifukun and xet7.
|
||||
- [Fix CasBleed: CAS login stored validated user data in a module-global (_userData) shared across concurrent logins, so two logins could race and issue an attacker a session for a victim's account. The user data is now bound per credential token](https://github.com/wekan/wekan/commit/1669a1af196ba48395e01376b3a0fc784ad42cc3).
|
||||
Thanks to meifukun and xet7.
|
||||
- [Fix MetricsBleed: the /metrics endpoint trusted a client-supplied X-Forwarded-For header unconditionally, so anyone could forge a whitelisted IP and read operational metrics. XFF is now trusted only when METRICS_TRUST_PROXY is set (parsed spoof-resistantly from the right); otherwise the real socket address is used](https://github.com/wekan/wekan/commit/1669a1af196ba48395e01376b3a0fc784ad42cc3).
|
||||
Thanks to meifukun and xet7.
|
||||
- [Fix ImpersonateBleed: several board export endpoints treated any historical ImpersonatedUsers record as an authorization bypass (canExport || impersonateDone), so a demoted former admin could export private boards forever. The impersonation bypass is removed; export requires real board visibility (canExport) only](https://github.com/wekan/wekan/commit/1669a1af196ba48395e01376b3a0fc784ad42cc3).
|
||||
Thanks to meifukun and xet7.
|
||||
- [Fix InviteBleed: invitation registration used a 6-digit Math.random() code (~900k keyspace) with no throttling, brute-forceable to hijack an invited account and its private boards. Codes are now a 128-bit crypto.randomBytes value and account creation is rate-limited (DDPRateLimiter)](https://github.com/wekan/wekan/commit/1669a1af196ba48395e01376b3a0fc784ad42cc3).
|
||||
Thanks to meifukun and xet7.
|
||||
|
||||
The OIDC login shared-serviceData race from the same report was already fixed earlier
|
||||
(#4897 moved the per-login `profile`/`serviceData`/`userinfo` objects inside the OAuth
|
||||
callback); the new source-guard test verifies it stays that way. Thanks to meifukun and xet7.
|
||||
|
||||
and adds the following new features:
|
||||
|
||||
- [Added an automatic security/speed/tests remediation-logging subsystem (design docs docs/Security/Remediation/WeKan.md and FerretDB.md). WeKan's runtime guards (SSRF, upload/avatar rejection and sanitization, forged X-Forwarded-For, export authz, invite rate-limit, …) now record each block/sanitize/remediate event into the existing WeKan database (a single `eventlog` collection, stream security/speed/tests) via normal Meteor JavaScript queries — no new files or databases under WRITABLE_PATH, so it works the same on FerretDB and MongoDB. FerretDB reports its problems to WeKan, which records them. Events use general category names plus the hall-of-fame *Bleed names](https://github.com/wekan/wekan/commit/1d82ef8eae344ca0565d0858fc697f470e471dd7).
|
||||
Thanks to xet7.
|
||||
- [Added Admin Panel → Problems (a new button on the 2nd header bar to the right of the Info/version button, with a warning icon that turns red when there are new problems). The old Reports button is removed and its page becomes the Problems page: a left menu with Summary, Security, Speed and Tests above the moved Broken Cards/Files/Rules/Boards/Cards/Impersonation reports. Summary is a checkbox list of problem areas with one Acknowledge button (the only place to acknowledge, which resets the per-area new-problem count); Security/Speed/Tests are read-only, paginated, searchable event tables](https://github.com/wekan/wekan/commit/0085f7fbfa81e820ce0736dd28b2b0786ac6e41c).
|
||||
Thanks to xet7.
|
||||
|
||||
and adds the following updates:
|
||||
|
||||
- [Reworked the Admin Panel → Problems pagination tables (Files/Rules/Boards/
|
||||
Cards/Impersonation and the Security/Speed/Tests event tables): the search field
|
||||
and pagination controls now sit in one row (pagination on the right, RTL-aware),
|
||||
the redundant Search button is gone (typing + Enter searches), and the controls
|
||||
follow the current theme — they use var(--theme-accent), so Member Settings →
|
||||
Change color recolors them](https://github.com/wekan/wekan/commit/4f1afed25).
|
||||
Thanks to xet7.
|
||||
- [Made buttons follow the theme: the global button base and the primary buttons
|
||||
(forms.css) plus the admin and People-panel buttons now use var(--theme-accent,
|
||||
<original>), so a Change-color theme override recolors buttons across the app
|
||||
while the default look is unchanged](https://github.com/wekan/wekan/commit/c94c313a7).
|
||||
Thanks to xet7.
|
||||
- [Admin Panel → Files report: URL-encoded filenames (e.g. "%D0%93%D1%80") are
|
||||
decoded for display, filenames are always shown as plain text (never markdown/
|
||||
HTML), a name hiding invisible/zero-width/bidi characters gets a red warning
|
||||
triangle on the left and each invisible character is replaced inline by its red
|
||||
Unicode name (e.g. "evil[U+200B ZERO WIDTH SPACE].png"), and a filter lists only
|
||||
such names](https://github.com/wekan/wekan/commit/a4aa2f877).
|
||||
Thanks to xet7.
|
||||
- [Removed clickable column-header sorting from the board Table view and the Admin
|
||||
Panel → People → Domains table; both now show a stable fixed order (and keep
|
||||
their search + pagination). Sorting a server-paginated table means sorting the
|
||||
whole set, which fights the per-page perf fix](https://github.com/wekan/wekan/commit/db68996cc).
|
||||
Thanks to xet7.
|
||||
- [Enabled FerretDB OpLog tailing by default so Meteor stops poll-and-diff, the
|
||||
main fix for FerretDB sitting at 100–390% CPU on busy boards. With FerretDB
|
||||
there is no MongoDB oplog, so Meteor re-ran every live query on a timer;
|
||||
FerretDB v1 now ships an OpLog (auto-created capped local.oplog.rs + replica-set
|
||||
hello handshake), so every FerretDB launch path starts ferretdb with
|
||||
--repl-set-name=rs0 and points WeKan at it via MONGO_OPLOG_URL, and Meteor
|
||||
TAILS the OpLog instead of polling. OpLog is used ONLY when tailing actually
|
||||
works: every platform that defaults to FerretDB — Snap, Sandstorm, the Docker
|
||||
image/compose and the prebuilt bundle — starts ferretdb with --repl-set-name and
|
||||
sets METEOR_REACTIVITY_ORDER=oplog,polling, so Meteor falls back to polling if the
|
||||
OpLog cannot be established (a broken/absent OpLog never blocks startup).
|
||||
Kill-switch WEKAN_FERRETDB_OPLOG=false reverts to
|
||||
polling only. Admin Panel → Version shows which driver is actually live
|
||||
("Reactivity mode") next to the configured METEOR_REACTIVITY_ORDER and
|
||||
DDP_TRANSPORT, so you can confirm OpLog came up rather than fell back. Also
|
||||
trimmed the client activity feed page from 500 to 50 rows (infinite scroll
|
||||
still loads more on demand)](https://github.com/wekan/wekan/commit/1039b2e3286135a1749aeab9526b52d40fb82e43).
|
||||
Thanks to uusijani, Nissulya and xet7.
|
||||
- [Updated SECURITY.md](https://github.com/wekan/wekan/commit/eebccaf291c2751154f127095d5b7610399f0f2d).
|
||||
Thanks to xet7.
|
||||
|
||||
and fixes the following bugs:
|
||||
|
||||
- [Fix the Admin Panel → Problems → Cards report spinning while it loaded on big
|
||||
sites: it paginates, but sorted by an unindexed { boardId, sort }, so every page
|
||||
full-sorted all cards (11761+) in memory. It now sorts by the existing
|
||||
{ boardId, createdAt } index, so one page is a bounded index scan; also added a
|
||||
{ stream, at } index for the Security/Speed/Tests
|
||||
tables](https://github.com/wekan/wekan/commit/fab8c1035).
|
||||
Thanks to xet7.
|
||||
- [Fix the Admin Panel → Translation page loading the ENTIRE Translations
|
||||
collection at once: it subscribed with a hardcoded limit of 0 (= no limit). It
|
||||
now uses the infinite-scroll window, loading a page at a
|
||||
time](https://github.com/wekan/wekan/commit/e88665316).
|
||||
Thanks to xet7.
|
||||
- [Fix the Board Archive → Boards list loading every archived board at once: it now
|
||||
pages server-side (30 per page) with a search box and prev/next controls, so only
|
||||
the current page is loaded](https://github.com/wekan/wekan/commit/da99baf68).
|
||||
Thanks to xet7.
|
||||
- [Fix "Did not check() all arguments" server-log spam from the Admin Panel →
|
||||
Problems detail pages: the eventLogCount/eventLogPage methods awaited the admin
|
||||
check before check()ing their arguments, so a non-admin call (or one before
|
||||
admin status resolved) made Meteor's audit-argument-checks mask the real error.
|
||||
check() now runs first in every argument-taking event-log
|
||||
method](https://github.com/wekan/wekan/commit/7da21c46b).
|
||||
Thanks to xet7.
|
||||
- [Fix MongoDB 3.x → FerretDB v1 migration failing on non-finite numbers: cards
|
||||
whose `sort` was ±Infinity or NaN (written by old WeKan before #6472) were
|
||||
rejected by FerretDB/SQLite ("infinity values are not allowed") and dropped,
|
||||
so boards stayed stuck on the loading spinner. The migration now clamps every
|
||||
non-finite double to a finite 0 before insert, and a new `nonfinite-sort-repair`
|
||||
startup schema-upgrade step heals the same corruption in an already-running
|
||||
database (MongoDB or FerretDB)](https://github.com/wekan/wekan/commit/2f7b49d8def4bb03d233b83671ac6c78308124ea).
|
||||
Thanks to Nissulya and xet7.
|
||||
- [Fix LDAP `mail` → `email` field mapping returning undefined (regression since
|
||||
the ldapjs → ldapts move): the sync read the mapped attribute case-sensitively
|
||||
while the rest of wekan-ldap reads it case-insensitively, so a directory that
|
||||
returns the attribute with a different case synced no email address. It is now
|
||||
read via getLDAPValue() like everywhere else](https://github.com/wekan/wekan/commit/2f7b49d8def4bb03d233b83671ac6c78308124ea).
|
||||
Thanks to Nissulya and xet7.
|
||||
- [Fix a memory/listener leak in board and CSV export: each write paused on
|
||||
socket backpressure leaked one 'error' listener on the response
|
||||
("MaxListenersExceededWarning: 11 error listeners added to [ServerResponse]"),
|
||||
which on a large export grew unbounded. Both listeners are now removed once the
|
||||
write settles](https://github.com/wekan/wekan/commit/2f7b49d8def4bb03d233b83671ac6c78308124ea).
|
||||
Thanks to uusijani and xet7.
|
||||
|
||||
Thanks to above GitHub users for their contributions and translators for their translations.
|
||||
|
||||
# v10.02 2026-07-19 WeKan ® release
|
||||
|
||||
This release adds a new developer documentation set and release automation.
|
||||
|
||||
It adds the following documentation, a new `docs/Design/Autoupdate/` set describing
|
||||
how software is installed and updated — manually and automatically (Snap-like) — on
|
||||
every operating system, and how to release WeKan to more platforms:
|
||||
|
||||
- [Added why the ppc64el/s390x snap builds fail on core24 (the QEMU multiarch action caps at core22), moved to Autoupdate/Forks/Snap-Core.md](https://github.com/wekan/wekan/commit/742fc9221a6671dbf072e06a15cd3fd8bf409f32).
|
||||
Thanks to xet7.
|
||||
- [Added Windows install/update platform comparison (manual vs automatic, like Snap)](https://github.com/wekan/wekan/commit/3bfc6cce56e167e0b61ea674cd5e13b51c81092c).
|
||||
Thanks to xet7.
|
||||
- [Added macOS install/update platform comparison](https://github.com/wekan/wekan/commit/2a878d55e7d4cf04ba5896e87e6f49ab338dda69).
|
||||
Thanks to xet7.
|
||||
- [Added per-OS install/update comparison docs for all operating systems (Linux, BSD, Haiku, Amiga, RISC OS, Solaris/illumos, AIX, HP-UX, OpenVMS, z/OS, ArcaOS, ReactOS, FreeDOS, Plan 9, Redox, SerenityOS, Android, iOS, ChromeOS, HarmonyOS, Ubuntu Touch, Sailfish, postmarketOS, KaiOS, Tizen, webOS and TV/streaming OSes), plus the Snap-Ondra-Gantt.md variant-snap spec](https://github.com/wekan/wekan/commit/cf1823b510b4abd67c8c32f605ddcfc956ea8802).
|
||||
Thanks to xet7.
|
||||
- [Added UCS.md: releasing the Docker WeKan UCS App Center app with automatic MongoDB 3.x to FerretDB v1 SQLite migration](https://github.com/wekan/wekan/commit/58c0e6d1f8bfabc624c870c9e91c6a54f0877cda).
|
||||
Thanks to xet7.
|
||||
- [Added Nextcloud.md: building WeKan as a Nextcloud ExApp, App Store publishing, and AI (Task Processing API) / Deck / OIDC SSO integrations](https://github.com/wekan/wekan/commit/8ae0ee54460444f6517fbe5a63f6ba143d5b6277).
|
||||
Thanks to xet7.
|
||||
|
||||
and the following documentation reorganization:
|
||||
|
||||
- [Moved Mac.md into docs/Platforms/Propietary/Mac/ and updated all links](https://github.com/wekan/wekan/commit/8cf7dd68cc3a6cf16362dc58ba789cff1d361564).
|
||||
Thanks to xet7.
|
||||
- [Moved the auto-update dev docs into docs/Design/Autoupdate/](https://github.com/wekan/wekan/commit/48a60a1141cafdb077239f619b8d03fb4d1f8a97).
|
||||
Thanks to xet7.
|
||||
- [Moved the per-OS comparison docs into Autoupdate/OS/](https://github.com/wekan/wekan/commit/c1ffed59a7a54bc3b28d94a243522eee20d0b791).
|
||||
Thanks to xet7.
|
||||
- [Moved Autoupdate/Snap.md to Autoupdate/Forks/Snap-Core.md and repointed links](https://github.com/wekan/wekan/commit/cf55da122ac31ae14c17780ea027c119e00f3abf).
|
||||
Thanks to xet7.
|
||||
|
||||
and the following release automation:
|
||||
|
||||
- [Added guarded snap-variants / ucs / nextcloud publish jobs to release-all.yml (dormant until their secrets are set, so ordinary releases are unaffected)](https://github.com/wekan/wekan/commit/2054ea2b54a193b9e53267b3dc3fbe15056c9e1e).
|
||||
Thanks to xet7.
|
||||
|
||||
and adds the following updates:
|
||||
|
||||
- [Upgraded filesize from 11.0.17 to 11.0.22](https://github.com/wekan/wekan/commit/ccb7a8e91c2b1e3fe3e8db86007cc3f92d11f85a).
|
||||
Thanks to dependabot.
|
||||
- [Upgraded dompurify from 3.4.11 to 3.4.12](https://github.com/wekan/wekan/commit/963f72ac88df708d225cd756aec1408a114d77c7).
|
||||
Thanks to dependabot.
|
||||
- [Upgraded @aws-sdk/client-s3 from 3.1085.0 to 3.1090.0](https://github.com/wekan/wekan/commit/144bed0eda0a860acbfa44e4036bbd784c17f8fc).
|
||||
Thanks to dependabot.
|
||||
- [Upgraded actions/setup-node from 6 to 7](https://github.com/wekan/wekan/commit/1f11ad26027aed362b3dfc23126ef7af88a01b01).
|
||||
Thanks to dependabot.
|
||||
|
||||
Thanks to above GitHub users for their contributions and translators for their translations.
|
||||
|
||||
# v10.01 2026-07-18 WeKan ® release
|
||||
|
||||
This release adds the following features:
|
||||
|
||||
- **Member Settings — optional "Submit editors with Enter"**. A new per-user setting in the Member Settings
|
||||
(Change Settings) popup, saved to `profile.submitOnEnter` and **off by default**: when on, plain **Enter** saves
|
||||
the card title, description and other inline editors and **Shift+Enter** inserts a new line; when off, behaviour is
|
||||
unchanged (Ctrl/Cmd+Enter saves). This restores the fast Enter-to-save workflow for users who prefer it without
|
||||
regressing #4236. [Setting](https://github.com/wekan/wekan/commit/bb6b0d5477368a97ba5cc1c01bdb598dcbe68cd2), [translated to all languages](https://github.com/wekan/wekan/commit/b8bb00465037389eb5829157441f2b75819353d9). Thanks to **xet7**.
|
||||
|
||||
- **Right board sidebar — drag the edge to resize its width** (desktop). Drag the sidebar's edge like a spreadsheet
|
||||
column; the width is saved to `profile.sidebarWidth` for logged-in users and to browser `localStorage` for
|
||||
anonymous users on a public board, then re-applied on load. The handle uses logical positioning, so in LTR it is
|
||||
the left edge of the right-docked sidebar and in RTL the right edge of the left-docked sidebar (drag direction
|
||||
inverts); on phones the sidebar stays full width. [Feature](https://github.com/wekan/wekan/commit/354b7804b577c2ca92b479b7bd2ef595cc9212d3),
|
||||
[tooltip translated to all languages](https://github.com/wekan/wekan/commit/1284afc588d6a52f89fdbb80d6f6ede9bc6bdfeb). Thanks to **xet7**.
|
||||
|
||||
and fixes the following bugs:
|
||||
|
||||
- **Upgrade migration no longer drops cards/activities whose exported data holds `NaN` or `Infinity`**
|
||||
([#6481](https://github.com/wekan/wekan/issues/6481)). mongo 3.x `mongoexport` emits non-finite doubles as the bare
|
||||
tokens `NaN`/`+Infinity` (e.g. a card's `"sort":+Infinity`, an activity's `"value":NaN`), which are not valid JSON,
|
||||
so `EJSON.parse` threw and the whole document was dropped — leaving boards/cards missing after an otherwise
|
||||
"successful" 6.09 → 10.x migration. Those bare tokens are now rewritten (outside string literals) to the canonical
|
||||
EJSON `{"$numberDouble":"…"}` form so the document migrates instead of being lost. [Fix](https://github.com/wekan/wekan/commit/e0991d6602c915e13d77776af36ecac410965909). Thanks to
|
||||
**Nissulya** and **xet7**.
|
||||
|
||||
- **Add List — the inline composer's previous options are back**
|
||||
([#6465](https://github.com/wekan/wekan/issues/6465)). The per-list-header inline Add List composer had been
|
||||
reduced to just a title input; the **"add after which list" position selector** (pre-selecting the list whose
|
||||
header opened it) and the **"or template"** link are restored. [Fix](https://github.com/wekan/wekan/commit/b71461d90c6a7d9ef9a835cf151181e15c84526d). Thanks to **csonkaoszimt**
|
||||
and **xet7**.
|
||||
|
||||
- **Right sidebar width no longer doubles on wide screens, and its input fields fill the width**. The sidebar used
|
||||
`width: 30vw`, so it grew unbounded on very wide screens (looking right only at mid widths); it is now clamped. The
|
||||
filter text inputs (Filter List by Title, Filter by card title, …) had no width and rendered narrow with a big gap
|
||||
on the right; they now fill the padded content width with equal spacing on both sides.
|
||||
[Fix](https://github.com/wekan/wekan/commit/b8a21e4ce3acca427a25f11bcb8c0fdf6bc26ee3). Thanks to **xet7**.
|
||||
|
||||
- **The Multi-Selection sidebar "Copy selection" button showed its raw translation key**. The `copy-selection` key
|
||||
was missing from every language file — including the English source — so i18next had no value to fall back to. It
|
||||
was added ("Copy selection") and translated to all languages. [Fix](https://github.com/wekan/wekan/commit/72042b7d796ce66c9f9da637c90e84493b67b3c1). Thanks to **xet7**.
|
||||
|
||||
- **Follow-up fixes surfaced by the full test run.** Now that generic Chinese (`zh`) is its own registered language,
|
||||
a bare `zh` browser tag resolves to that entry (Simplified Chinese) instead of the old `zh → zh-Hans` alias, which
|
||||
is removed; and the board-actions end-to-end test opens the board menu from the right sidebar (the header Board
|
||||
Settings cog was removed). [Fix](https://github.com/wekan/wekan/commit/08cfe2d7881aee399dd0e0a05629096e3027b9b2).
|
||||
Thanks to **xet7**.
|
||||
|
||||
Thanks to above GitHub users for their contributions and translators for their translations.
|
||||
|
||||
# v10.00 2026-07-18 WeKan ® release
|
||||
|
||||
This release adds the following updates:
|
||||
|
||||
- [Updated Sandstorm Backup docs](https://github.com/wekan/wekan/commit/50dd05ceee9aa954bb09c239ed057334de63015a). Thanks to robertdahlem and xet7.
|
||||
|
||||
and adds the following features:
|
||||
|
||||
- **WeKan has been translated to 154 languages. Thanks to xet7.** Every language file in
|
||||
`imports/i18n/data/` is now filled in (previously most were largely English). Each language matches its own
|
||||
existing terminology as a glossary plus standard Kanban terms; product/brand names, acronyms, placeholders and
|
||||
markup are preserved; regional variants were seeded from their base language; and a few wrong ones were corrected
|
||||
(Wu Chinese `wuu-Hans` was registered; the `wa-RR` file is Waray/`war`, not a Walloon variant, so it was
|
||||
re-translated; Wolof/Malay display names were fixed) so that **all languages are browser-detectable and visible in
|
||||
the Change Language popup**. `releases/translations/push-all-translations.sh` force-pushes every language to
|
||||
Transifex. Commits:
|
||||
- [Finnish: translate newest UI strings (theme, font, size, colors, Sandstorm)](https://github.com/wekan/wekan/commit/061eba8b7aa4c96e356c46c0e47fb22b1b773f8d)
|
||||
- [i18n: translate new admin/import-export strings to Finnish](https://github.com/wekan/wekan/commit/687a4af8617c0b06f98e20b283b0a2fd329a76a3)
|
||||
- [releases: add translations-push-all-translations.sh (force-push all languages)](https://github.com/wekan/wekan/commit/a9f2e9f50cbb55eb865478ac14ce2ac1d80b5913)
|
||||
- [i18n: translate untranslated strings (wave 1: zh-TW, pt-BR, da, he, sv)](https://github.com/wekan/wekan/commit/63bda22faacd430f4680c9cbe0ce521129096a5e)
|
||||
- [i18n: translate untranslated strings (wave 2: 15 major languages)](https://github.com/wekan/wekan/commit/7691829cdb293796eab7794eae6c0cb4ec829e6b)
|
||||
- [i18n: seed 32 regional variants from their translated base languages](https://github.com/wekan/wekan/commit/ea4a10c212c8037b833ece0010032f6a8d52b9a8)
|
||||
- [i18n: seed close-relative lects as baselines (cmn, wuu, yue, ja-Hira, vls, ary)](https://github.com/wekan/wekan/commit/c795d13450fc7fea5f9cc55fbdc1ac843aeafb6d)
|
||||
- [i18n: register generic Chinese (zh) so it is selectable and browser-detectable](https://github.com/wekan/wekan/commit/75ec34e83726f7792240edef70de59648c96ef35)
|
||||
- [i18n: translate untranslated strings (wave 4: 15 languages + 9 variants)](https://github.com/wekan/wekan/commit/a184a3a717331239f38203fc56ed78cc9a04f11e)
|
||||
- [i18n: translate untranslated strings (wave 5: 15 languages + 5 variants)](https://github.com/wekan/wekan/commit/97feb986a88f2f6128fd6bf74be922bed83c104e)
|
||||
- [i18n: translate untranslated strings (wave 6: 15 languages + 2 variants)](https://github.com/wekan/wekan/commit/91ccef062b5ec866aec8ac4660235e46e14e55ab)
|
||||
- [i18n: translate untranslated strings (wave 7, final: 14 languages + 5 variants)](https://github.com/wekan/wekan/commit/219011e5eb3ef3994a074fed9cc441faa734fada)
|
||||
- [WeKan has been translated to 154 languages](https://github.com/wekan/wekan/commit/01d55828d23a645e1c8a841bb4e6357223399d47)
|
||||
- [i18n: register wuu-Hans, fix Wolof/Malay names, add wuu browser alias](https://github.com/wekan/wekan/commit/d55c5e2c3cd544c0ad94538b689b63fe50cd6a41)
|
||||
- [Changelog: WeKan translated to 154 languages, with commit links](https://github.com/wekan/wekan/commit/5b3394af2f3fad92286260d8e94158988e2f3196)
|
||||
- [Moved translation script to correct directory. Thanks to xet7](https://github.com/wekan/wekan/commit/95552881fc7e7256edadf51aa84b6b4436cb0e24)
|
||||
- [i18n: fix wa-RR — translate as Waray (war), not Walloon](https://github.com/wekan/wekan/commit/c00f24d668df1bfc1979a37172dadc1f2d9a77e4)
|
||||
- [Changelog: add Part 12 (Waray wa-RR fix) to the 154-languages entry](https://github.com/wekan/wekan/commit/2cf878215d5cb09f8bfa7f8368e9cc36be3931d3)
|
||||
- [releases: fix push-all-translations.sh cd depth for its translations/ location](https://github.com/wekan/wekan/commit/2be92538391e1e2b92c87ab4e2217ce2e9e39ec0)
|
||||
|
||||
- **Member menu / Change Color — global theme override, visible swatches, applies everywhere, no Save button**
|
||||
([#5778](https://github.com/wekan/wekan/issues/5778), `docs/Theme/Theme.md`). The member menu gets a **Change
|
||||
Color** entry that works like Board Settings / Change Color but is a **per-user global override** saved to your
|
||||
profile, so the chosen theme applies to the **whole UI** — All Boards, Search, Admin Panel, My Cards, Due Cards
|
||||
and board pages — via a `board-color-<name>` class on `<body>`/header (while you are on a board, that board's own
|
||||
color still wins). This makes a **dark theme everywhere** possible. **Done differently from the first cut:** the
|
||||
picker was initially built as **two-level dropdowns** (category then theme); that was replaced by **visible color
|
||||
swatches grouped by category** (Flat / Clear / Dark / Special, category name above each group), and the **Save
|
||||
button was removed — clicking a swatch applies immediately**. **Flat** themes allow **1 custom color** and
|
||||
**clear** themes **2** (a gradient) via the native color wheel; **dark**/**special** are fixed. Custom colors are
|
||||
validated server-side as `#rrggbb` and applied as CSS variables (`--theme-accent` / `--theme-accent-2`). Category
|
||||
titles are left-aligned and the popup has a title. Thanks to **xet7**. Commits:
|
||||
- [Member Menu / Change Color: global per-user theme override (#5778)](https://github.com/wekan/wekan/commit/eab1546b885336502549d9c4fcc45feec661310a)
|
||||
- [Select Color: theme categories core + design doc (docs/Theme/Theme.md)](https://github.com/wekan/wekan/commit/49285dad1950a3019972dbf08d40201b256719ca)
|
||||
- [Select Color: two-level dropdown picker + custom colors (#5778)](https://github.com/wekan/wekan/commit/d0a1e43f364d8ad0caef725e5d48db507887a962)
|
||||
- [Select Color: global override applies everywhere + visible grouped swatches (#5778)](https://github.com/wekan/wekan/commit/1cd32001d474bed977d56ff0fdbcfbc090191974)
|
||||
- [Change Color popup: immediate apply (no Save), left-aligned titles, title, spacing](https://github.com/wekan/wekan/commit/772ae061f109478a875fc6e570f0afe26e572148)
|
||||
- [Change Color popup: remove the "Preview" text from the custom-color preview swatch](https://github.com/wekan/wekan/commit/0f87c3bf8b33241927ff5437c222907986ab37df)
|
||||
|
||||
- **Member Settings / Font — pick an installed UI font, size and text colors**
|
||||
(part of [#4759](https://github.com/wekan/wekan/issues/4759)). A new **Font** member-menu entry lets you choose a
|
||||
UI font that **already exists in your browser** (canvas-detected; names are plain text, never HTML/markdown,
|
||||
validated server-side against a curated whitelist — only a known font name and a preset percentage ever reach the
|
||||
DOM), a font **size**, and custom **text** and **text-background** colors (each unsettable). **Done differently
|
||||
from the first cut:** the font and size were first built as **dropdowns**; that was replaced by **buttons** — one
|
||||
**font-name button per detected font, each rendered in its own font**, and a row of **size buttons** (smaller …
|
||||
100% in the middle … larger) — that **apply immediately on click** (no Save). A **preview pangram** shows below
|
||||
the font buttons, the **Unset** links are styled as buttons, and the popup has a title. Stored as `profile.uiFont`
|
||||
/ `profile.uiFontSize` / `profile.uiTextColor` / `profile.uiTextBgColor`. (Only the font part of #4759, so the
|
||||
issue stays open.) Thanks to **xet7**. Commits:
|
||||
- [Member Settings / Font: pick an installed font + preset size (#4759)](https://github.com/wekan/wekan/commit/3c9524f61a43cbf92f856f0fb2ff1f9b291d4e05)
|
||||
- [Member Settings / Font / Color: custom text + background color (#4759)](https://github.com/wekan/wekan/commit/22c7d5376bcf7990890c7254c80c1e2dfd9e419c)
|
||||
- [Fix #4759 regression: uiTextColor/uiTextBgColor custom() rejected every user insert](https://github.com/wekan/wekan/commit/437c64c9a0c05dbf3d12a8e45e0b8281f3c326e6)
|
||||
- [Member Settings / Font popup: add a title (changeFontPopup-title)](https://github.com/wekan/wekan/commit/969f96d74219061e05093d5dff7061a684d22f65)
|
||||
- [Member Settings / Font: font-name + size buttons, immediate apply (no dropdowns)](https://github.com/wekan/wekan/commit/bd8e417be18837b2e4600b3b25fa605a83910a2f)
|
||||
- [Member Settings / Font: show the preview pangram below the font-name buttons](https://github.com/wekan/wekan/commit/aa15e726829a8e070634d7c04698bd251f817f08)
|
||||
- [Font popup: style the "Unset" colour reset links as buttons](https://github.com/wekan/wekan/commit/3d190ca0222d657827ed03993ba542279148482a)
|
||||
|
||||
- **Soft delete + Undo/Redo + change-History — deleting is reversible, and moves can be undone**
|
||||
([#1023](https://github.com/wekan/wekan/issues/1023), [#6478](https://github.com/wekan/wekan/issues/6478),
|
||||
`docs/Features/Undo/Undo.md`). Deleting a **list** no longer destroys it: the list and its cards are **marked**
|
||||
deleted (`deletedAt`/`deletedBy`/`deleteBatchId`), hidden from the board, and restorable via **`lists.restore`**
|
||||
or **`Ctrl+Z`** — the first slice of a general "no permanent delete in ordinary use" principle (physical deletion
|
||||
limited to GDPR/account erasure and an explicit Global-Admin purge behind an off-by-default **Admin Panel /
|
||||
Features / Delete** flag). Card/list/swimlane **moves** are now undoable/redoable with `Ctrl+Z` / `Ctrl+Y` (the
|
||||
`userPositionHistory` collection was silently **not recording** — its guard checked an un-imported global — and had
|
||||
no redo or key bindings; all fixed, selection logic unit-tested). Design unifies this into one universal
|
||||
change-History with scoped views. Activities were confirmed to already load progressively. Thanks to **xet7**.
|
||||
Commits:
|
||||
- [Soft delete for lists: delete = mark, restorable + undoable (#1023)](https://github.com/wekan/wekan/commit/46924985f88b473e054158ecae68af6d7cce3556)
|
||||
- [Undo docs: soft-delete design (delete = mark, never destroy)](https://github.com/wekan/wekan/commit/58344db0d7e3ed9e4f8ca638e0b3e00d2813ac63)
|
||||
- [#6478: undo/redo for card/list/swimlane moves + confirm high-impact list move on touch](https://github.com/wekan/wekan/commit/4ca33d29f78bd81cecc931faa5164f862823ad52)
|
||||
- [Docs: design for per-group card History (view + restore)](https://github.com/wekan/wekan/commit/3d1276fc1fbd063f1c7b79467b788f13a5e62435)
|
||||
- [Docs: undo/redo + high-impact confirmation feature doc](https://github.com/wekan/wekan/commit/f3354f5cc8577cda46d2789230e8d30939bd927d)
|
||||
- [Docs: unify undo/redo into one universal change-History (every change) with many scoped views](https://github.com/wekan/wekan/commit/9be41ab34b1bd596a82b95f088e49e94da8b988c)
|
||||
- [Activities already load progressively (infinite scroll), like visible cards](https://github.com/wekan/wekan/commit/e246403bb6969f402e066d01e256e2c56f10ff1c)
|
||||
|
||||
- **Home board — set a board that opens automatically after login**
|
||||
([#2220](https://github.com/wekan/wekan/issues/2220)). A per-user default "home" board (`profile.defaultBoardId`)
|
||||
opens automatically after login on **all deployments** (a once-per-session router redirect), and on **Sandstorm** a
|
||||
grain with exactly one board again opens straight into it (**without saving anything**; a saved Home board wins, a
|
||||
many-board grain with no choice shows All Boards). You **set the Home board from the All Boards page via
|
||||
Multi-Selection**: turn Multi-Selection on, select a board, and click the **home** action in the new
|
||||
**"Selected:"** row (which also has a **star** action); the Home board then shows a **home badge** and sits at the
|
||||
**top of Starred**. **Done differently:** the earlier per-board-tile home toggle was hidden, but setting a Home
|
||||
board was brought back through the Multi-Selection "Selected:" action — so the feature is **available, not
|
||||
disabled, and not Sandstorm-only** — and the initial Sandstorm "save the single-board choice" was changed to
|
||||
**no-save**. Thanks to **xet7**. Commits:
|
||||
- [Feature #2220: mark a board to open automatically after login (home board)](https://github.com/wekan/wekan/commit/e8ced84172ec7950800becfee9860c33e964034e)
|
||||
- [Sandstorm: auto-open a single-board grain (no save) (#2220)](https://github.com/wekan/wekan/commit/9a0c0613a5909f9a37f6afb20890894b8e5082bd)
|
||||
- [All Boards: hide Home-board icons and disable setting a Home board (#2220)](https://github.com/wekan/wekan/commit/ac20cd64424ebe356fcd7ccb3a4f60370580e7a3)
|
||||
- [All Boards: "Selected:" star/home actions + Home board badge (#2220)](https://github.com/wekan/wekan/commit/d67d27f0d872c4fc6e990262d3d3577a28783eee)
|
||||
- [Tests: cover the remaining Upcoming changes (#2220 default board, #6465 UI, #6478 confirm)](https://github.com/wekan/wekan/commit/8287865b238f3ed90e60e4624e98b383e896beee)
|
||||
|
||||
- **Add List moved from a standing column to a per-list header button**
|
||||
([#6465](https://github.com/wekan/wekan/issues/6465)). The Swimlanes/Lists views no longer show a permanent "Add
|
||||
List" composer column; each list header has a far-right add-list button that opens the composer as a column
|
||||
**immediately after that list** (right in LTR, left in RTL), and an **empty** swimlane/board shows a **+** button.
|
||||
Also fixes the composer's Save doing nothing (`createListAfter`'s `check()` used a non-`Optional` matcher for
|
||||
`nextListId`, so the inline composer 400'd with "Match failed"). *(First cut — desktop/main views.)* Thanks to
|
||||
**csonkaoszimt** (report) and **xet7**. Commits:
|
||||
- [#6465: Add List as a per-list header button + inline composer, not a standing column](https://github.com/wekan/wekan/commit/765d04bf7c678a9f28b663beadae7b07fd79f339)
|
||||
- [Fix add-list: Save did nothing (Match failed) + empty-swimlane + button (#6465)](https://github.com/wekan/wekan/commit/0535215225a41bfe16d936a9a1b447fc77f9122e)
|
||||
|
||||
- **All Boards, board header, top bar and popups — a large batch of layout/theming polish.** Highlights: the
|
||||
right-sidebar **hamburger is pinned to the right edge** at every width with an ~8px gap, and the duplicate **Board
|
||||
Settings cog was removed**; the All Boards **My Boards toolbar wraps** (order Multi-Selection then Sort then
|
||||
Search), the **Multi-Selection button matches the Swimlanes view**, the empty grey band was removed, **"+ Add …"
|
||||
tiles match board-tile height**, the board-tile **drag handle sits at the right middle** (transparent, icon only),
|
||||
and the **search box is half width** with the multi-selection hint beneath it; the **mobile/desktop toggle and
|
||||
zoom** swapped places; the **Change Language** popup shows **multiple columns**; the **Change Avatar** upload
|
||||
button and the **Search All Boards** / **Archived boards** buttons follow the **theme colors**; the **Archived
|
||||
boards modal** is full width and starts below the header; the **Notifications drawer** is full width with a black
|
||||
hamburger + X and black menu icons; and the member menu's duplicate **Notifications** entry was removed. **Done
|
||||
differently:** an "icons only — always hide button text labels" board-header change was tried and then
|
||||
**reverted**, so button **text labels stay**. Thanks to **xet7**. Commits:
|
||||
- [Board header: keep the sidebar hamburger pinned to the right edge at all widths](https://github.com/wekan/wekan/commit/7718118654f35a214ea5f9b2147642eabf4a6702)
|
||||
- [Board header: remove empty space around the sidebar hamburger button](https://github.com/wekan/wekan/commit/e33e50d9c84cc9125ac6e8d5e491a7419bbae883)
|
||||
- [Board header: hamburger right gap matches the user-name gap (~8px)](https://github.com/wekan/wekan/commit/bd72f5d64c58ad0861f370a17969f6ee482c466c)
|
||||
- [Board header: hide button text labels on narrow windows (icons only)](https://github.com/wekan/wekan/commit/cfd53cc9698bbc759bef55152ea8f74ca00f3afc)
|
||||
- [Board header: remove the Board Settings cog (it is already in the right sidebar)](https://github.com/wekan/wekan/commit/68723ff43b324dda1b584e5ba4eb5d90d0490124)
|
||||
- [Board header: icons only — always hide button text labels](https://github.com/wekan/wekan/commit/97b0240ca4bb5f2bf02020e94eb6d7ffc278acd9)
|
||||
- [Revert board-header icons-only; update CHANGELOG Upcoming with newest changes](https://github.com/wekan/wekan/commit/834698bb5e3e926213676e499bf4338713bc43da)
|
||||
- [Header/All Boards: reorder Multi-Selection/Sort/Search and swap mobile-toggle/zoom](https://github.com/wekan/wekan/commit/15e756f4e0b265a020b659fea2dc3339b2c2e767)
|
||||
- [All Boards: + Add Board / + Add Template Container tile same height as board icons](https://github.com/wekan/wekan/commit/10085615179dbdd56bd5d630ee526e82a1451ef6)
|
||||
- [All Boards: remove empty .board-list-header dead grey space above the layout](https://github.com/wekan/wekan/commit/1312fefb1234d6fcdcda930d93fd189e57befd37)
|
||||
- [All Boards: My Boards bar wraps so Sort/Multi-Selection/Clear stay visible](https://github.com/wekan/wekan/commit/7699cc1e90f18b599f294fba774403f3b17e3749)
|
||||
- [All Boards: Multi-Selection button looks the same as in Swimlanes View](https://github.com/wekan/wekan/commit/c6aa769bc0a8b01706e0f45a19b8ba8c66c77498)
|
||||
- [All Boards: move board tile drag handle to the right middle](https://github.com/wekan/wekan/commit/aafe71fd191a8633012d838d00aa96abd3f092a2)
|
||||
- [All Boards: transparent board tile drag-handle background (icon only)](https://github.com/wekan/wekan/commit/197254fe16c2dd2c266f2a1797d80feffccfc914)
|
||||
- [All Boards: vertically center the Search boards input text](https://github.com/wekan/wekan/commit/035195f2b4b7a8601964d54645ec7d139e65dd41)
|
||||
- [All Boards: move the Search boards input text 4px further down (top: 6px)](https://github.com/wekan/wekan/commit/cb80198d9748b2fbd7dee392e235f882785420c7)
|
||||
- [All Boards: move the Search boards input text 2px further down (top: 8px)](https://github.com/wekan/wekan/commit/c21edb160bddfb2a9e635d5c2154c037dc464698)
|
||||
- [All Boards: move "+ Add Board" text 4px up (keep tile height)](https://github.com/wekan/wekan/commit/3f5173b9602e14de3201ebc0052b2751e522f100)
|
||||
- [All Boards: move "+ Add Board" text 2px further up (padding 18/8/24/8)](https://github.com/wekan/wekan/commit/519c04b8ef46497d526f4586432b132887335487)
|
||||
- [All Boards: move "+ Add Template Container" text 2px down (separate from Add Board)](https://github.com/wekan/wekan/commit/cd7f9f7bc9e48c6f54b007deec13194b90d4d33b)
|
||||
- [All Boards: half-width search, hint below Multi-Selection, title beside icon](https://github.com/wekan/wekan/commit/3736f8431a4b0b8224c0b7315ac8e596aa4426fa)
|
||||
- [Change Language popup: show languages in multiple columns when there is width](https://github.com/wekan/wekan/commit/fc0649a756d0f6e6caf2feef659786a9908397b1)
|
||||
- [Change Avatar popup: Upload button uses theme colors (blue bg, white text)](https://github.com/wekan/wekan/commit/08a2e5525e8e7b099d766643b09616e75f750fc9)
|
||||
- [Archived boards modal: full width + extends to the bottom of the window](https://github.com/wekan/wekan/commit/b027594ca8ad6d9f781214e8c26aac572ed20c25)
|
||||
- [Archived boards modal: start below the header bar so its top is visible](https://github.com/wekan/wekan/commit/1c8ba2afc3a51d5a0b71f5777d1e189dca5525e4)
|
||||
- [Archived boards: Restore/Delete buttons follow the theme override colors](https://github.com/wekan/wekan/commit/4dc09c62b9719cbb3dd126420239f7d9da5bea55)
|
||||
- [Search All Boards: new-search button follows theme override colors](https://github.com/wekan/wekan/commit/98a03bf045668a95f58ae2cfe15f6d39591c89de)
|
||||
- [Search All Boards: label the clear button "Clear" using existing trello-clear-job key](https://github.com/wekan/wekan/commit/a4cc0c2182b9c4da74325bb5e21d7f13f64e9484)
|
||||
- [Member menu: remove the Notifications entry (duplicates the header bell icon)](https://github.com/wekan/wekan/commit/8cb4050cf21a1fce10c08872cb8f4a4bb270565b)
|
||||
- [Notifications drawer header: black X + hamburger, X near the top-right edge](https://github.com/wekan/wekan/commit/8dd6bd9db5a8f427f6863f033e7236ae80ead336)
|
||||
- [Notifications drawer: full width + robust flex header (black hamburger + X)](https://github.com/wekan/wekan/commit/c87b4cb5103aa2e010f0c40b0554e4cf84126059)
|
||||
- [Notifications drawer: fix X position — cancel the global absolute .close rule](https://github.com/wekan/wekan/commit/5e014d0967ec8d622857348a264f3522a7e9769d)
|
||||
- [Notifications menu: make the dropdown item icons black](https://github.com/wekan/wekan/commit/01088ea9d4583301446cb021ecd8772e4d3cde6d)
|
||||
|
||||
- **Denser default layout: thinner lists, board tiles and card dock; taller-looking minicards**
|
||||
([#6465](https://github.com/wekan/wekan/issues/6465)). List width default 272 to **220px** (minimum 270 to
|
||||
**200px**), All Boards board tiles min-height 100 to **72px**, and the card-detail right dock max-width 800 to
|
||||
**520px** — all overridable, user customizations untouched. Minicards no longer reserve a tall empty band under a
|
||||
one-line title (the title viewer gets `min-height: 0`), and the swimlane header no longer clips at non-100% zoom.
|
||||
Thanks to **csonkaoszimt** (report) and **xet7**. Commits:
|
||||
- [#6465: denser defaults - thinner lists (220px), board tiles and card dock (520px)](https://github.com/wekan/wekan/commit/99299c2a91827f88f0bf091e810b8ce9d6211784)
|
||||
- [Fix #6465: minicards wasted half their height on empty white space](https://github.com/wekan/wekan/commit/a76f7d28e33f79b79f89eebffcb2a2946205f5f0)
|
||||
- [#6465: swimlane header bar no longer clips at non-100% board zoom](https://github.com/wekan/wekan/commit/7b7fada1b89fa21e35ebaeca26d3f85299afb33d)
|
||||
- [Test: update fixed-list-width e2e for the lowered 200px minimum (#6465)](https://github.com/wekan/wekan/commit/ae29555862089cd85111aa417a892888d48124bd)
|
||||
|
||||
and fixes the following bugs:
|
||||
|
||||
- [Confirmation dialogs work again](https://github.com/wekan/wekan/commit/f6f021f8cc8de452593f499e1306dca021679325) — clicking any `.js-confirm` button (e.g. "Remove Member") did
|
||||
nothing because `Popup.afterConfirm()` stashed the pending action on the Blaze **data context** (an immutable
|
||||
Minimongo doc that Blaze re-creates on re-render); it is now stored on the Popup **instance**
|
||||
([#6479](https://github.com/wekan/wekan/issues/6479)). Thanks to **mueschel** (report) and **xet7**.
|
||||
- [Moving a card between swimlanes is no longer broken with long lists](https://github.com/wekan/wekan/commit/6d5a594b0709ad3456f91afc31d821304539a262) — jQuery UI sortable cached
|
||||
geometry at drag-start and never re-cached on WeKan's manual auto-scroll, so the drop re-homed the card in the
|
||||
source swimlane; fixed with `sortable('refreshPositions')` after a scroll
|
||||
([#6477](https://github.com/wekan/wekan/issues/6477)). Thanks to **mueschel** (report) and **xet7**.
|
||||
- [Filter by date -> Overdue no longer lists cards with no due date](https://github.com/wekan/wekan/commit/b1d33c536239d45ae5df6139beb6b5548f7e03c0) — `$lte`/range selectors
|
||||
matched `null` under both FerretDB and minimongo; `DateFilter._getMongoSelector` now adds `$ne: null` (reported by
|
||||
email). Thanks to the reporter and **xet7**.
|
||||
- [A board-wide list no longer disappears from other swimlanes when nudged](https://github.com/wekan/wekan/commit/c29cf3b985abb670222ec25c76a055e0ff9ba53a) — the drop handler
|
||||
treated any small drag of a board-wide list (`swimlaneId === null`) as a swimlane change; it now also requires an
|
||||
original swimlane (reported by email). Thanks to the reporter and **xet7**.
|
||||
|
||||
- **FerretDB (bundled) — #6476 crash-loop root cause fixed, and label/title/date filters pushed down to SQLite**
|
||||
([#6476](https://github.com/wekan/wekan/issues/6476)). A startup SyncedCron upsert hit an orphaned-table error
|
||||
(`table "…connections_<hash>" already exists`) that became an unhandledRejection and crash-looped WeKan so its
|
||||
port never opened; the bundled FerretDB now creates tables/indexes with `IF NOT EXISTS`. It also pushes label
|
||||
(`$in`), title (`$regex`) and `dueAt` range filters down to SQLite instead of scanning every card in Go. (Details
|
||||
in the fork's own CHANGELOG.) Thanks to **uusijani**, **a1bert01** and **xet7**. Changelog commits:
|
||||
- [Changelog: #6476 real root cause (FerretDB orphaned-table crash loop) and the bundled FerretDB fix](https://github.com/wekan/wekan/commit/41c747ef5f7e3fac4ae37714e65e1f7b4703ab80)
|
||||
- [Changelog: bundled FerretDB now pushes label ($in) and title ($regex) filters down](https://github.com/wekan/wekan/commit/b29a47778ffb3025d8c26d67427e022a6a137076)
|
||||
- [Changelog: bundled FerretDB now pushes date range filters down too](https://github.com/wekan/wekan/commit/5730f7dcc54e551ca5b4cc6ede307ebe7e078310)
|
||||
|
||||
- **Snap release, CI and dev tooling.** The exotic `ppc64el`/`s390x` snaps now build on **GitHub Actions under
|
||||
QEMU** instead of Launchpad (which returned exit 0 with no artifact); `rebuild-wekan.sh` CURRENT-IP detection is
|
||||
subnet-agnostic (no more `http://:3000`); and the **Playwright E2E workflow was disabled**. Commits:
|
||||
- [Snap release: build ppc64el and s390x under QEMU on GitHub Actions, not Launchpad](https://github.com/wekan/wekan/commit/d7f36d70a04ee96669d75c2dea675cf7e7b76d7a)
|
||||
- [rebuild-wekan.sh: robust CURRENT-IP detection (fix http://:3000 Invalid URL)](https://github.com/wekan/wekan/commit/c6f960bbe10d5ff36a92314c0a21380e914a89e3)
|
||||
- [ci: disable Playwright E2E Tests workflow](https://github.com/wekan/wekan/commit/ef81c25232c7f5f26b6b33d9e9b945b8e769dd35)
|
||||
|
||||
- **Changelog, docs and test housekeeping.** Commits:
|
||||
- [Changelog: move post-v9.99 changes to an Upcoming section above v9.99](https://github.com/wekan/wekan/commit/c4784225d51f2d13f2f50b2fe172cb156feab861)
|
||||
- [Updated ChangeLog](https://github.com/wekan/wekan/commit/26319c974b180cc2a59b55705bc5d5128f23f438)
|
||||
- [Changelog: drop bogus #6483/#6484 issue links (those bugs were reported by email)](https://github.com/wekan/wekan/commit/e9e70717445afbf124fe5c536f4199ef97d8ff5b)
|
||||
- [Updates](https://github.com/wekan/wekan/commit/176c5ef2d8a31f85cb41e52a85003e3560e818cc)
|
||||
- [Test: de-flake webkit swimlane-add popup wait (6s -> 15s)](https://github.com/wekan/wekan/commit/8aacc2236052108576e9b0e1ac0ca68f9339463e)
|
||||
- [Updates](https://github.com/wekan/wekan/commit/861ded439e08dfe16e65f19a3e150d83bbd8286f)
|
||||
- [Updated ChangeLog](https://github.com/wekan/wekan/commit/66a2f8ddc8f4bc95fe08478f1d0eeda08f87cf82)
|
||||
|
||||
Thanks to above GitHub users for their contributions and translators for their translations.
|
||||
|
||||
# v9.99 2026-07-17 WeKan ® release
|
||||
|
||||
This release fixes the following BUG:
|
||||
|
||||
- **LDAP connection leak: WeKan exhausted the directory server with "too many open connections"**
|
||||
([#6467](https://github.com/wekan/wekan/issues/6467), [#6469](https://github.com/wekan/wekan/issues/6469)).
|
||||
Operators reported that after an update WeKan "dies really quickly", that restarting the server did not
|
||||
help, and that it "kills our openldap server with too many open connections"; others saw logins hang for
|
||||
minutes and then fail with *"Must be logged in"*. Root cause: every LDAP login attempt
|
||||
(`packages/wekan-ldap/server/loginHandler.js`) and every background sync run
|
||||
(`packages/wekan-ldap/server/sync.js`) created a fresh `new LDAP()` and called `connect()`, but the code
|
||||
**never called `disconnect()` on any path** — success, failure, or fallback. Each login attempt (including
|
||||
every *failed* one) and each background-sync tick (every minute by default) therefore leaked one socket to
|
||||
the LDAP/AD server. Over time this grew without bound until the directory server hit its per-client
|
||||
connection limit and started refusing connections, which took it — and, with it, every WeKan login — down.
|
||||
- **Fixed** by guaranteeing the connection is always released:
|
||||
- A small shared helper `packages/wekan-ldap/server/connectionGuard.js` (`runWithLdapDisconnect(ldap, fn)`)
|
||||
runs the work and `disconnect()`s in a `finally`, on every exit path. The disconnect is best-effort and
|
||||
never masks the original result or error.
|
||||
- The LDAP login handler now runs its whole flow through `runWithLdapDisconnect`, so `connect()` is always
|
||||
paired with a `disconnect()` — on a successful login, a thrown `Meteor.Error`, or a fallback to the
|
||||
default account system.
|
||||
- The background `sync()` releases its connection in a `finally`, and `importNewUsers()` disconnects only
|
||||
the connection it opened itself (never a connection borrowed from `sync()`).
|
||||
- The admin `ldap_test_connection` method (`packages/wekan-ldap/server/testConnection.js`), which had the
|
||||
same leak, now disconnects on both the success and failure paths, so repeated "Test Connection" clicks
|
||||
no longer leak either.
|
||||
- `LDAP.disconnect()` is now a safe no-op when `connect()` was never reached, so cleanup can never throw.
|
||||
- **Tests** (`tests/ldapConnectionRelease.test.cjs`, added to `test:unit:node`): behavioural coverage of the
|
||||
guard (disconnects after success and returns the result; disconnects exactly once; disconnects *and*
|
||||
re-throws when the work throws — the failed-login path that exhausted OpenLDAP), negative cases (a failing
|
||||
disconnect never turns a success into a failure nor hides the real error; a missing/`null` ldap is
|
||||
tolerated), plus source-level guards that the leak-prone call sites actually route through the guard and
|
||||
that `connect()` lives inside the guarded region.
|
||||
- Thanks to the reporting operators and **xet7** (fix).
|
||||
|
||||
This release also updates the bundled FerretDB (see the fork's own CHANGELOG Upcoming): the SQLite backend
|
||||
connection pool no longer caps `MaxOpenConns` at 16, which had starved WeKan's cursor-heavy load and made
|
||||
boards take minutes to load and logins fail with *"Must be logged in"* ([#6467](https://github.com/wekan/wekan/issues/6467),
|
||||
[#6469](https://github.com/wekan/wekan/issues/6469)).
|
||||
|
||||
- **Snap: FerretDB never becoming ready made WeKan hang with no web port and no explanation**
|
||||
([#6476](https://github.com/wekan/wekan/issues/6476)). With `database=ferretdb`, `snap-src/bin/wekan-control`
|
||||
waits for FerretDB to accept connections *before* starting WeKan — WeKan does not open its HTTP port until
|
||||
the database answers. That wait loop had **no timeout and no diagnostics**: if FerretDB never started
|
||||
listening (a crashed or CPU/architecture-incompatible per-arch binary, a locked or corrupt SQLite database,
|
||||
or the FerretDB service left disabled by a failed migration hand-off), WeKan blocked there forever. The
|
||||
service showed as `active`, but the port never opened and the only symptom was a silent
|
||||
`FerretDB not ready yet, retrying in 5 seconds` repeating — exactly what #6476 reported (Apache proxy could
|
||||
not connect to port 3333). The reporter's `wekan.log` stopped right after the startup env dump, confirming
|
||||
control never reached `node main.js`.
|
||||
- **Fixed** by mirroring the MongoDB branch: after `WEKAN_DB_WAIT_TIMEOUT` seconds (default 120,
|
||||
overridable) the FerretDB wait loop now prints an actionable hint **once** and keeps retrying — pointing to
|
||||
`snap logs <snap>.ferretdb` for the real error, naming the SQLite directory to check for locks/corruption,
|
||||
the arch-mismatch (`exec format error`) case, the `snap start --enable <snap>.ferretdb` recovery, and the
|
||||
`snap set <snap> database=mongodb` fallback to keep working meanwhile. This turns "port never opens, no
|
||||
clue why" into a clear message. Regression test: `tests/ferretdbWaitTimeout.test.cjs`, including a negative
|
||||
guard that the FerretDB wait is not a silent unbounded loop again.
|
||||
- This is a robustness/diagnostics fix; the underlying reason FerretDB was not accepting connections is
|
||||
environment-specific and lives in the FerretDB service log.
|
||||
- Thanks to **uusijani** (report) and **xet7** (fix).
|
||||
|
||||
Thanks to above GitHub users for their contributions and translators for their translations.
|
||||
|
||||
# v9.98 2026-07-17 WeKan ® release
|
||||
|
||||
This release fixes the following SECURITY ISSUES found by GitHub CodeQL code scanning:
|
||||
|
||||
- **[EscapeBleed](https://wekan.fi/hall-of-fame/escapebleed/): incomplete string escaping when
|
||||
building a regular expression** (GitHub CodeQL code scanning alert #423, rule
|
||||
`js/incomplete-sanitization`, CWE-116 Improper Encoding or Escaping of Output;
|
||||
`tests/maximizedCardPosition.test.cjs`). Code that turned a CSS declaration into a
|
||||
`RegExp` escaped only parentheses (`str.replace(/[()]/g, '\\$&')`) instead of the full
|
||||
regex metacharacter set — an input containing other metacharacters (including a backslash)
|
||||
would not be escaped correctly, so the generated pattern could match the wrong thing.
|
||||
- **Fixed** by escaping the complete metacharacter set
|
||||
(`str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')`), matching the correct pattern already used
|
||||
elsewhere in the tests. This is test-only code with a fixed, trusted input list, so there
|
||||
was no injection exposure, but the incomplete escape was genuinely wrong.
|
||||
- Thanks to **GitHub CodeQL** (code scanning alert #423) and **xet7** (fix).
|
||||
|
||||
- **[RandomBleed](https://wekan.fi/hall-of-fame/randombleed/): biased random ids from a
|
||||
cryptographically secure source** (GitHub CodeQL code scanning alert #422, rule
|
||||
`js/biased-cryptographic-random`, CWE-1204 / weak randomness; `server/lib/schemaUpgradeSteps.js`).
|
||||
The startup schema upgrade generates Meteor-style document ids with
|
||||
`crypto.randomBytes(len)` mapped through `byte % ID_CHARS.length`. Because 256 is not a
|
||||
multiple of the 55-character alphabet, that modulo skews generated ids toward the first 36
|
||||
characters of the alphabet (each ~1.4% more likely than the rest) — reducing entropy of the
|
||||
ids used for the swimlanes/lists/checklist-items the upgrade creates.
|
||||
- **Fixed** with rejection sampling: bytes at or above the largest multiple of the alphabet
|
||||
size (220) are discarded and resampled, so every character is exactly equally likely; ids
|
||||
stay Meteor-style 17 characters (and exact-length for custom lengths). A negative
|
||||
regression test pins that out-of-range bytes are never wrapped.
|
||||
- Thanks to **GitHub CodeQL** (code scanning alert #422) and **xet7** (fix).
|
||||
|
||||
Thanks to above GitHub users for their contributions and translators for their translations.
|
||||
|
||||
# v9.97 2026-07-17 WeKan ® release
|
||||
|
||||
This release adds the following new features:
|
||||
@@ -5012,7 +5786,7 @@ and adds the following new features:
|
||||
relation type); Kendis/piplanning.io (and GitHub/GitLab) have no public
|
||||
dependency format, so a generic `{ "lines": [...] }` JSON interchange is
|
||||
documented for them. Documented in
|
||||
[Features/RedStrings](https://github.com/wekan/wekan/blob/main/docs/Features/RedStrings/RedStrings.md).
|
||||
[Features/RedStrings](https://github.com/wekan/wekan/blob/main/docs/Features/Editor/RedStrings/RedStrings.md).
|
||||
Fixes \#3392. Thanks to CodeFreezr, dbt4u, helioguardabaxo and xet7.
|
||||
- [Added an Admin Panel "Shared templates" view grouped by Organization / Team / email
|
||||
Domain](https://github.com/wekan/wekan/commit/fc9e8674d89ec5b045f2e2c1b14fade9e92baf0d):
|
||||
@@ -5025,7 +5799,7 @@ and adds the following new features:
|
||||
the user's orgs/teams/email domains; the boards are shown as links into each template
|
||||
board. Covered by an e2e suite (`tests/playwright/specs/26-shared-templates.e2e.js`).
|
||||
Documented in
|
||||
[Features/Templates](https://github.com/wekan/wekan/blob/main/docs/Features/Templates.md#shared-templates-admin-view).
|
||||
[Features/Templates](https://github.com/wekan/wekan/blob/main/docs/Features/Board/Templates.md#shared-templates-admin-view).
|
||||
Fixes #3313. Thanks to xet7.
|
||||
|
||||
and fixes the following bugs:
|
||||
@@ -5231,7 +6005,7 @@ and adds the following new features:
|
||||
- Added a Rules e2e test suite (`tests/playwright/specs/20-rules.e2e.js`, covering
|
||||
the fullscreen page, creating event rules, import/export, selecting rules, the
|
||||
workflow view and board buttons) and docs
|
||||
([Features/Rules](https://github.com/wekan/wekan/blob/main/docs/Features/Rules/Rules.md),
|
||||
([Features/Rules](https://github.com/wekan/wekan/blob/main/docs/Features/Automation/Rules/Rules.md),
|
||||
updated IFTTT page).
|
||||
- [Added **Jira import**](https://github.com/wekan/wekan/commit/f0bce67a3bf301c21ded3eb97e84a3be0010290f) ("All Boards → New → Import → From Jira"): import boards from
|
||||
a Jira Cloud REST issue-search JSON, similar to the Trello importer. Jira statuses
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ LABEL org.opencontainers.image.source="https://github.com/wekan/wekan"
|
||||
# TARGETARCH and TARGETVARIANT are automatically provided by Docker Buildx
|
||||
ARG TARGETARCH
|
||||
ARG TARGETVARIANT
|
||||
ARG VERSION=9.97
|
||||
ARG VERSION=10.09
|
||||
ARG DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
ENV BUILD_DEPS="apt-utils gnupg wget bzip2 g++ curl libarchive-tools build-essential git ca-certificates python3 unzip"
|
||||
|
||||
@@ -67,7 +67,7 @@ that by providing one-click installation on various platforms.
|
||||
|
||||
- WeKan ® is used in [most countries of the world](https://snapcraft.io/wekan).
|
||||
- WeKan ® largest user has 30k users using WeKan ® in their company.
|
||||
- WeKan ® has been [translated](https://app.transifex.com/wekan/) to about 105 languages.
|
||||
- WeKan ® has been [translated](https://app.transifex.com/wekan/) to 154 languages.
|
||||
- [Features][features]: WeKan ® has real-time user interface.
|
||||
- [Platforms](https://wekan.fi/install/): WeKan ® supports many platforms.
|
||||
WeKan ® is critical part of new platforms Wekan is currently being integrated to.
|
||||
|
||||
+28
-21
@@ -10,8 +10,19 @@ because WeKan is NOT Big Tech. WeKan is FLOSS.
|
||||
- **Report**: Click "New draft security advisory" at [WeKan Security Advisories](https://github.com/wekan/wekan/security/advisories).
|
||||
More info at [GitHub Docs about reporting privately](https://docs.github.com/en/code-security/how-tos/report-and-fix-vulnerabilities/report-privately).
|
||||
- **Proof of Concept (PoC)**: If possible, include a fix or a reproduction script/code.
|
||||
- **If GitHub does not work**: Please send report to **security@wekan.fi**
|
||||
- **Optional Email Encryption**: An optional PGP key is available at [security-at-wekan.fi.asc](security-at-wekan.fi.asc).
|
||||
- DO NOT EMAIL [security@wekan.fi](security-at-wekan.fi.asc), because:
|
||||
- Email is NOT secure or private.
|
||||
- Using GitHub directly verifies that:
|
||||
- You are:
|
||||
- Saving my time by filling all details at GitHub, so that I can easily click request CVE at GitHub
|
||||
- A correct GitHub user
|
||||
- Really reading this SECURITY.md
|
||||
- Knowing what you are doing
|
||||
- You are not:
|
||||
- Someone that tries to waste my time
|
||||
- Trying to impersonate as some other GitHub user
|
||||
- Spammer
|
||||
- Bot
|
||||
|
||||
### 2. WeKan Security Team:
|
||||
|
||||
@@ -23,30 +34,26 @@ because WeKan is NOT Big Tech. WeKan is FLOSS.
|
||||
|
||||
### 3. Post-Release and Public Disclosure:
|
||||
|
||||
- Publicity: If you publish blog posts, give talks, or write articles about the
|
||||
reported vulnerabilities, please send the links to us. We will link them
|
||||
to your entry in the [Hall of Fame](https://wekan.fi/hall-of-fame/) for additional context.
|
||||
- Advisories reported at GitHub are listed at [WeKan Security Advisories](https://github.com/wekan/wekan/security/advisories).
|
||||
|
||||
## How Should Reports Be Formatted?
|
||||
|
||||
```
|
||||
Name: %name
|
||||
Twitter: %twitter
|
||||
Bug type: %bugtype
|
||||
Domain: %domain
|
||||
Severity: %severity
|
||||
URL: %url
|
||||
PoC: %poc
|
||||
CVSS (optional): %cvss
|
||||
CWSS (optional): %cwss
|
||||
```
|
||||
- DO NOT EMAIL [security@wekan.fi](security-at-wekan.fi.asc), because:
|
||||
- Email is NOT secure or private.
|
||||
- Using GitHub directly verifies that:
|
||||
- You are:
|
||||
- Saving my time by filling all details at GitHub, so that I can easily click request CVE at GitHub
|
||||
- A correct GitHub user
|
||||
- Really reading this SECURITY.md
|
||||
- Knowing what you are doing
|
||||
- You are not:
|
||||
- Someone that tries to waste my time
|
||||
- Trying to impersonate as some other GitHub user
|
||||
- Spammer
|
||||
- Bot
|
||||
|
||||
## Who can participate in the program
|
||||
|
||||
Anyone who reports a unique security issue in scope and does not disclose it to
|
||||
a third party before we have patched and updated may be upon their approval
|
||||
added to the WeKan Hall of Fame https://wekan.fi/hall-of-fame/
|
||||
a third party before we have patched, and is able to add
|
||||
"New draft security advisory" at [WeKan Security Advisories](https://github.com/wekan/wekan/security/advisories).
|
||||
|
||||
## Which domains are in scope?
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
|
||||
appVersion: "v9.97.0"
|
||||
appVersion: "v10.09.0"
|
||||
files:
|
||||
userUploads:
|
||||
- README.md
|
||||
|
||||
@@ -7,7 +7,12 @@ import { TAPi18n } from '/imports/i18n';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import { getSidebarInstance } from '/client/features/sidebar/service';
|
||||
|
||||
const activitiesPerPage = 500;
|
||||
// #6480/#6481: 500 was far more than the sidebar/card activity feed ever shows
|
||||
// at once, and with FerretDB (no oplog) every live cursor is re-run on a timer —
|
||||
// so a 500-row activities cursor was re-fetched and re-diffed on every poll for
|
||||
// each open board/card. 50 covers the visible feed; infinite scroll still pulls
|
||||
// the next page on demand (the server cursor is index-bounded either way).
|
||||
const activitiesPerPage = 50;
|
||||
|
||||
Template.activities.onCreated(function () {
|
||||
// Register with sidebar so it can call loadNextPage on us
|
||||
@@ -234,7 +239,13 @@ Template.activity.helpers({
|
||||
sourceLink() {
|
||||
const source = this.activity.source;
|
||||
if (source) {
|
||||
if (source.url) {
|
||||
// XSS fix (reported by meifukun): source.url is imported from an external
|
||||
// board (e.g. a Trello board's `url`) and was rendered as an <a href> with
|
||||
// no scheme check, so a `javascript:` (or data:/vbscript:) URL executed in
|
||||
// the board admin's session on click and could exfiltrate Meteor.loginToken.
|
||||
// Only render a link for an http(s) URL; otherwise show the plain (sanitized)
|
||||
// source name with no href.
|
||||
if (source.url && /^https?:\/\//i.test(String(source.url))) {
|
||||
return Blaze.toHTML(
|
||||
HTML.A(
|
||||
{
|
||||
@@ -243,9 +254,8 @@ Template.activity.helpers({
|
||||
sanitizeHTML(source.system),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return sanitizeHTML(source.system);
|
||||
}
|
||||
return sanitizeHTML(source.system);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
@@ -4,14 +4,22 @@ template(name="archivedBoards")
|
||||
i.fa.fa-archive
|
||||
| {{_ 'archived-boards'}}
|
||||
|
||||
.admin-report-controls
|
||||
input.js-archived-boards-search(type="search" placeholder="{{_ 'search'}}")
|
||||
.admin-report-pagination
|
||||
button.js-archived-boards-prev-page(class="{{#if hasPrevPage}}{{else}}disabled{{/if}}")
|
||||
| <
|
||||
span.admin-report-page-info {{currentPage}} / {{totalPages}}
|
||||
button.js-archived-boards-next-page(class="{{#if hasNextPage}}{{else}}disabled{{/if}}")
|
||||
| >
|
||||
ul.archived-lists
|
||||
each archivedBoards
|
||||
li.archived-lists-item
|
||||
div.board-header-btns
|
||||
button.primary.board-header-btn.js-delete-board
|
||||
button.primary.board-header-btn.js-delete-board(type="submit")
|
||||
i.fa.fa-trash
|
||||
| {{_ 'delete-board'}}
|
||||
button.primary.board-header-btn.js-restore-board
|
||||
button.primary.board-header-btn.js-restore-board(type="submit")
|
||||
i.fa.fa-undo
|
||||
| {{_ 'restore-board'}}
|
||||
= title
|
||||
|
||||
@@ -3,8 +3,22 @@ import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
|
||||
import Boards from '/models/boards';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
|
||||
const ARCHIVED_BOARDS_PER_PAGE = 30;
|
||||
|
||||
Template.archivedBoards.onCreated(function () {
|
||||
this.subscribe('archivedBoards');
|
||||
// Server-side pagination: only the current page of archived boards is ever
|
||||
// published/loaded, so a long-lived instance's archive stays fast.
|
||||
this.page = new ReactiveVar(1);
|
||||
this.total = new ReactiveVar(0);
|
||||
this.searchQuery = new ReactiveVar('');
|
||||
this.autorun(() => {
|
||||
const searchTerm = this.searchQuery.get();
|
||||
const skip = (this.page.get() - 1) * ARCHIVED_BOARDS_PER_PAGE;
|
||||
this.subscribe('archivedBoards', searchTerm, ARCHIVED_BOARDS_PER_PAGE, skip);
|
||||
Meteor.call('getArchivedBoardsCount', searchTerm, (err, count) => {
|
||||
if (!err) this.total.set(count || 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Template.archivedBoards.helpers({
|
||||
@@ -13,17 +27,42 @@ Template.archivedBoards.helpers({
|
||||
},
|
||||
|
||||
archivedBoards() {
|
||||
const ret = ReactiveCache.getBoards(
|
||||
// Only the current page is in minimongo (the publication is limited).
|
||||
return ReactiveCache.getBoards(
|
||||
{ archived: true },
|
||||
{
|
||||
sort: { archivedAt: -1, modifiedAt: -1 },
|
||||
},
|
||||
{ sort: { archivedAt: -1, modifiedAt: -1 } },
|
||||
);
|
||||
return ret;
|
||||
},
|
||||
|
||||
currentPage() { return Template.instance().page.get(); },
|
||||
totalPages() { return Math.max(1, Math.ceil((Template.instance().total.get() || 0) / ARCHIVED_BOARDS_PER_PAGE)); },
|
||||
hasPrevPage() { return Template.instance().page.get() > 1; },
|
||||
hasNextPage() {
|
||||
const tpl = Template.instance();
|
||||
return tpl.page.get() < Math.max(1, Math.ceil((tpl.total.get() || 0) / ARCHIVED_BOARDS_PER_PAGE));
|
||||
},
|
||||
});
|
||||
|
||||
Template.archivedBoards.events({
|
||||
'keydown .js-archived-boards-search'(event, tpl) {
|
||||
if (event.keyCode === 13) {
|
||||
event.preventDefault();
|
||||
tpl.searchQuery.set(tpl.$('.js-archived-boards-search').val() || '');
|
||||
tpl.page.set(1);
|
||||
}
|
||||
},
|
||||
'click .js-archived-boards-prev-page'(event, tpl) {
|
||||
event.preventDefault();
|
||||
const current = tpl.page.get();
|
||||
if (current > 1) tpl.page.set(current - 1);
|
||||
},
|
||||
'click .js-archived-boards-next-page'(event, tpl) {
|
||||
event.preventDefault();
|
||||
const total = tpl.total.get() || 0;
|
||||
const totalPages = Math.max(1, Math.ceil(total / ARCHIVED_BOARDS_PER_PAGE));
|
||||
const current = tpl.page.get();
|
||||
if (current < totalPages) tpl.page.set(current + 1);
|
||||
},
|
||||
async 'click .js-restore-board'() {
|
||||
// TODO : Make isSandstorm variable global
|
||||
const isSandstorm =
|
||||
|
||||
@@ -23,7 +23,7 @@ template(name="boardBody")
|
||||
if debugBoardState
|
||||
.debug-info(style="position: fixed; top: 0; inset-inline-start: 0; background: rgba(0,0,0,0.8); color: white; padding: 10px; z-index: 9999; font-size: 12px;")
|
||||
| {{_ 'board'}}: {{currentBoard.title}} | {{_ 'view'}}: {{boardView}} | {{_ 'has-swimlanes'}}: {{hasSwimlanes}} | {{_ 'swimlanes'}}: {{currentBoard.swimlanes.length}}
|
||||
.board-wrapper(class=currentBoard.colorClass class="{{#if isMiniScreen}}mobile-view{{/if}}")
|
||||
.board-wrapper(class="{{#if currentUser.globalThemeColorClass}}{{currentUser.globalThemeColorClass}}{{else}}{{currentBoard.colorClass}}{{/if}}" class="{{#if isMiniScreen}}mobile-view{{/if}}")
|
||||
.board-canvas.js-swimlanes.dragscroll(
|
||||
class="{{#if Sidebar.isOpen}}is-sibling-sidebar-open{{/if}}"
|
||||
class="{{#if MultiSelection.isActive}}is-multiselection-active{{/if}}"
|
||||
@@ -52,6 +52,8 @@ template(name="boardBody")
|
||||
+ganttView
|
||||
else if isViewTable
|
||||
+tableView
|
||||
else if isViewStats
|
||||
+statsView
|
||||
else
|
||||
// Default view - show swimlanes if they exist, otherwise show lists
|
||||
if hasSwimlanes
|
||||
|
||||
@@ -73,6 +73,9 @@ Template.board.onCreated(function () {
|
||||
}
|
||||
|
||||
const handle = Meteor.subscribe('board', currentBoardId, false);
|
||||
// Learn this board's card-loading mode (lazy vs eager) so 'auto' can decide
|
||||
// per board by size; the flag drives isLazyCards(boardId). #6480.
|
||||
Meteor.subscribe('boardCardsLoadingMode', currentBoardId);
|
||||
const ready = handle.ready();
|
||||
this.isBoardReady.set(ready);
|
||||
|
||||
@@ -705,6 +708,19 @@ Template.boardBody.helpers({
|
||||
return boardView === 'board-view-table';
|
||||
},
|
||||
|
||||
isViewStats() {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
let boardView;
|
||||
|
||||
if (currentUser) {
|
||||
boardView = (currentUser.profile || {}).boardView;
|
||||
} else {
|
||||
boardView = window.localStorage.getItem('boardView');
|
||||
}
|
||||
|
||||
return boardView === 'board-view-stats';
|
||||
},
|
||||
|
||||
hasSwimlanes() {
|
||||
const currentBoard = Utils.getCurrentBoard();
|
||||
if (!currentBoard) {
|
||||
|
||||
@@ -131,6 +131,8 @@ template(name="boardHeaderBar")
|
||||
i.fa.fa-bar-chart
|
||||
if $eq boardView 'board-view-table'
|
||||
i.fa.fa-table
|
||||
if $eq boardView 'board-view-stats'
|
||||
i.fa.fa-pie-chart
|
||||
span
|
||||
if $eq boardView 'board-view-swimlanes'
|
||||
| {{_ 'swimlanes'}}
|
||||
@@ -142,6 +144,8 @@ template(name="boardHeaderBar")
|
||||
| {{_ 'gantt'}}
|
||||
if $eq boardView 'board-view-table'
|
||||
| {{_ 'board-view-table'}}
|
||||
if $eq boardView 'board-view-stats'
|
||||
| {{_ 'board-view-stats'}}
|
||||
a.board-header-btn.js-toggle-dependencies(
|
||||
title="{{#if currentBoard.showDependencies}}{{_ 'hide-dependencies'}}{{else}}{{_ 'show-dependencies'}}{{/if}}"
|
||||
class="{{#if currentBoard.showDependencies}}emphasis{{/if}}")
|
||||
@@ -159,12 +163,8 @@ template(name="boardHeaderBar")
|
||||
i.fa.fa-times-thin
|
||||
|
||||
.separator
|
||||
//- #6465: one-click board settings — the boardMenu popup used to need
|
||||
//- two clicks (open sidebar, then Boardeinstellungen). The click
|
||||
//- handler already exists in boardHeader.js ('click .js-open-board-menu').
|
||||
if currentUser.isBoardMember
|
||||
a.board-header-btn.js-open-board-menu(title="{{_ 'boardMenuPopup-title'}}")
|
||||
i.fa.fa-cog
|
||||
//- Board Settings cog removed: Board Settings is available in the right
|
||||
//- sidebar (the hamburger), so the header no longer duplicates it.
|
||||
a.board-header-btn.js-toggle-sidebar(title="{{_ 'sidebar-open'}} {{_ 'or'}} {{_ 'sidebar-close'}}")
|
||||
i.fa.fa-bars
|
||||
|
||||
@@ -255,6 +255,13 @@ template(name="boardChangeViewPopup")
|
||||
| {{_ 'board-view-table'}}
|
||||
if $eq Utils.boardView "board-view-table"
|
||||
i.fa.fa-check
|
||||
li
|
||||
with "board-view-stats"
|
||||
a.js-open-stats-view
|
||||
i.fa.fa-pie-chart
|
||||
| {{_ 'board-view-stats'}}
|
||||
if $eq Utils.boardView "board-view-stats"
|
||||
i.fa.fa-check
|
||||
|
||||
template(name="createBoard")
|
||||
form
|
||||
|
||||
@@ -130,7 +130,6 @@ Template.boardHeaderBar.events({
|
||||
Meteor.call('toggleBoardStar', boardId);
|
||||
}
|
||||
},
|
||||
'click .js-open-board-menu': Popup.open('boardMenu'),
|
||||
'click .js-change-visibility': Popup.open('boardChangeVisibility'),
|
||||
'click .js-watch-board': Popup.open('boardChangeWatch'),
|
||||
'click .js-open-archived-board'() {
|
||||
@@ -249,6 +248,10 @@ Template.boardChangeViewPopup.events({
|
||||
Utils.setBoardView('board-view-table');
|
||||
Popup.back();
|
||||
},
|
||||
'click .js-open-stats-view'() {
|
||||
Utils.setBoardView('board-view-stats');
|
||||
Popup.back();
|
||||
},
|
||||
});
|
||||
|
||||
// Shared setup for all create board popups
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
@import url("../../../public/css/reset.css") print, screen;
|
||||
|
||||
/* Board List Header with Zoom Controls */
|
||||
.board-list-header {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin: 1vh 0 2vh 0;
|
||||
padding: 1vh 0;
|
||||
}
|
||||
|
||||
/* Two-column layout for All Boards */
|
||||
.boards-layout {
|
||||
display: grid;
|
||||
@@ -260,6 +252,9 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
/* Wrap to a 2nd row on narrow windows so the search box and the Sort /
|
||||
Multi-Selection / Clear buttons stay visible instead of overflowing. */
|
||||
flex-wrap: wrap;
|
||||
padding: 12px 16px;
|
||||
margin-bottom: 16px;
|
||||
background: #f5f5f5;
|
||||
@@ -273,6 +268,46 @@
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
min-width: 0; /* allow shrinking so path-right can wrap below instead of overflowing */
|
||||
flex-wrap: wrap; /* the multi-selection hint wraps under the title when tight */
|
||||
}
|
||||
|
||||
/* Keep the folder icon and the menu title (e.g. "Remaining") on ONE line, the
|
||||
title to the RIGHT of the icon — never let flex-wrap stack them vertically. */
|
||||
.boards-path-header .path-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Multi-Selection button with its hint stacked underneath (own column). */
|
||||
.boards-path-header .multiselection-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* "Selected:" actions row (star / home) below the Multi-Selection button. */
|
||||
.boards-path-header .selected-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.boards-path-header .selected-label {
|
||||
font-size: 13px;
|
||||
color: #333;
|
||||
}
|
||||
.boards-path-header .selected-action {
|
||||
cursor: pointer;
|
||||
color: #333;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
.boards-path-header .selected-action:hover {
|
||||
color: #2980b9;
|
||||
}
|
||||
|
||||
.boards-path-header .multiselection-hint {
|
||||
@@ -295,6 +330,10 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
/* Buttons (Sort / Multi-Selection / Clear) wrap among themselves too, so none
|
||||
is ever cut off on a narrow window. */
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* #5799: board-name search box in the All Boards header */
|
||||
@@ -306,6 +345,9 @@
|
||||
background: #fff;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
/* Roughly half the previous width; the input flexes to fill what's left. */
|
||||
width: 300px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.boards-path-header .board-search input {
|
||||
@@ -313,7 +355,11 @@
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 14px;
|
||||
min-width: 140px;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
/* Nudge the text down so it sits at the vertical middle of the field. */
|
||||
position: relative;
|
||||
top: 8px;
|
||||
}
|
||||
|
||||
.boards-path-header .board-search .board-search-clear {
|
||||
@@ -517,19 +563,33 @@
|
||||
overflow: hidden;
|
||||
background-color: inherit; /* Inherit board color from parent li.js-board */
|
||||
color: #f6f6f6;
|
||||
min-height: 100px;
|
||||
min-height: 72px; /* #6465: thinner tiles so more boards fit in the overview */
|
||||
font-size: 16px;
|
||||
line-height: 22px;
|
||||
border-radius: 0; /* No border-radius - parent .js-board has it */
|
||||
display: block;
|
||||
font-weight: 700;
|
||||
padding: 36px 8px 32px 8px; /* Top padding for drag handle, bottom for checkbox */
|
||||
padding: 24px 8px 18px 8px; /* #6465: trimmed; top leaves room for the drag handle, bottom for the checkbox */
|
||||
margin: 0; /* No margin - moved to parent .js-board */
|
||||
position: relative;
|
||||
text-decoration: none;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
/* #2220: Home-board badge — a home icon centred at the top of the board tile. */
|
||||
.board-list .board-home-badge {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 3;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.45);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.board-list .board-list-item > .js-open-board {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
@@ -561,16 +621,27 @@
|
||||
}
|
||||
.board-list .js-add-board .label {
|
||||
font-weight: normal;
|
||||
line-height: 56px;
|
||||
min-height: 100px;
|
||||
/* Match the board tiles (#6465 thinner tiles) so the grey "+ Add Board" /
|
||||
"+ Add Template Container" box is the same height as a board icon:
|
||||
same min-height, padding and line-height as .board-list-item above. */
|
||||
line-height: 22px;
|
||||
min-height: 72px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #999; /* Darker background for better text contrast */
|
||||
border-radius: 3px;
|
||||
padding: 36px 8px 32px 8px;
|
||||
/* 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
|
||||
higher. */
|
||||
padding: 18px 8px 24px 8px;
|
||||
color: #fff; /* White text */
|
||||
}
|
||||
/* "+ Add Template Container" text sits 2px lower than "+ Add Board" (same 42px
|
||||
total vertical padding, so the tile height is unchanged). */
|
||||
.board-list .js-add-board .label.is-template-container {
|
||||
padding: 20px 8px 22px 8px;
|
||||
}
|
||||
.board-list .js-add-board .label i {
|
||||
color: #fff; /* White icon */
|
||||
}
|
||||
@@ -668,12 +739,14 @@
|
||||
.board-list .board-handle {
|
||||
position: absolute;
|
||||
padding: 4px 6px;
|
||||
top: 4px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
/* Right middle of the tile (matches the mobile/small-screen handle position). */
|
||||
top: 50%;
|
||||
inset-inline-end: 6px;
|
||||
transform: translateY(-50%);
|
||||
font-size: 14px;
|
||||
color: #fff;
|
||||
background: rgba(0,0,0,0.4);
|
||||
/* Transparent background so only the drag-handle icon shows (not a box). */
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -812,20 +885,25 @@ body.grey-icons-enabled .checkmark-no-grey {
|
||||
|
||||
/* Mobile view styles - applied when isMiniScreen is true (iPhone, etc.) */
|
||||
.board-list.mobile-view {
|
||||
/* #6488: a real 2-column grid — robust regardless of float support or how narrow
|
||||
the boards column is (at least 2 board icons per row on phones). */
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
height: calc(100vh - 120px);
|
||||
overflow-y: scroll !important;
|
||||
overflow-y: auto !important;
|
||||
overflow-x: hidden;
|
||||
padding: 0 1rem;
|
||||
padding: 0 0.5rem;
|
||||
margin: 0;
|
||||
scrollbar-width: auto !important;
|
||||
scrollbar-color: #888 #f1f1f1;
|
||||
}
|
||||
.board-list.mobile-view li {
|
||||
width: 100%;
|
||||
width: auto; /* the grid controls the column width */
|
||||
float: none;
|
||||
display: block;
|
||||
margin-bottom: 1rem;
|
||||
padding-inline-end: 50px; /* Space for drag handle */
|
||||
margin-bottom: 0.5rem;
|
||||
padding-inline-end: 32px; /* Space for drag handle */
|
||||
}
|
||||
.board-list.mobile-view .board-list-item {
|
||||
overflow: visible;
|
||||
@@ -893,11 +971,6 @@ body.grey-icons-enabled .checkmark-no-grey {
|
||||
background: #555 !important;
|
||||
}
|
||||
|
||||
/* Force mobile view to have scrollable content */
|
||||
.board-list.mobile-view {
|
||||
min-height: 100vh; /* Force content to be tall enough to scroll */
|
||||
}
|
||||
|
||||
/* Hide archive and clone board buttons in mobile view */
|
||||
.board-list.mobile-view .js-archive-board,
|
||||
.board-list.mobile-view .js-clone-board {
|
||||
@@ -910,38 +983,41 @@ body.grey-icons-enabled .checkmark-no-grey {
|
||||
font-family: inherit !important;
|
||||
}
|
||||
|
||||
.board-list.mobile-view::after {
|
||||
content: '';
|
||||
display: block;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 800px),
|
||||
screen and (max-device-width: 800px),
|
||||
screen and (-webkit-min-device-pixel-ratio: 2) and (max-width: 800px),
|
||||
screen and (max-width: 800px) and (orientation: portrait),
|
||||
screen and (max-width: 800px) and (orientation: landscape),
|
||||
screen and (max-device-width: 932px) and (-webkit-min-device-pixel-ratio: 3) {
|
||||
/* #6488 + mobile layout: keep the left menu on the left but narrow (so the boards
|
||||
get room), and show the board icons on the right at least 2 per row. */
|
||||
.boards-layout {
|
||||
grid-template-columns: minmax(84px, 30%) 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
.board-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding: 0 1rem;
|
||||
padding: 0 0.25rem;
|
||||
margin: 0;
|
||||
}
|
||||
.board-list li {
|
||||
width: 100%;
|
||||
width: auto; /* the grid controls the column width */
|
||||
float: none;
|
||||
display: block;
|
||||
margin-bottom: 1rem;
|
||||
padding-inline-end: 50px; /* Space for drag handle */
|
||||
margin-bottom: 0.5rem;
|
||||
padding-inline-end: 32px; /* Space for drag handle */
|
||||
}
|
||||
.board-list .board-list-item {
|
||||
overflow: visible;
|
||||
height: 8rem;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding-inline-end: 50px; /* Ensure content doesn't overlap with drag handle */
|
||||
padding-inline-end: 40px; /* Ensure content doesn't overlap with drag handle */
|
||||
}
|
||||
|
||||
.board-list .board-list-item .details {
|
||||
@@ -977,10 +1053,15 @@ body.grey-icons-enabled .checkmark-no-grey {
|
||||
background: rgba(255, 255, 0, 0.8) !important; /* Yellow hover */
|
||||
}
|
||||
}
|
||||
/* Very small screens - ensure one board per row */
|
||||
/* Very small screens - still keep at least 2 board icons per row (#6488). */
|
||||
@media screen and (max-width: 360px) {
|
||||
.board-list {
|
||||
display: grid !important;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
|
||||
gap: 6px;
|
||||
}
|
||||
.board-list li {
|
||||
width: 100% !important;
|
||||
width: auto !important;
|
||||
float: none !important;
|
||||
display: block !important;
|
||||
}
|
||||
@@ -1009,20 +1090,16 @@ body.grey-icons-enabled .checkmark-no-grey {
|
||||
screen and (max-width: 800px) and (orientation: portrait),
|
||||
screen and (max-width: 800px) and (orientation: landscape),
|
||||
screen and (max-device-width: 932px) and (-webkit-min-device-pixel-ratio: 3) {
|
||||
/* Stack the two-column All Boards layout on small screens. The fixed
|
||||
"260px 1fr" grid had no mobile collapse, so on narrow non-iPhone browsers
|
||||
(Fairphone Firefox ESR / Ubuntu Touch Morph, narrow desktop) it overflowed
|
||||
and the left menu (Starred / Templates / Remaining / Workspaces / + Add
|
||||
board) was clipped. Width-based, so it applies to every browser. */
|
||||
/* #6488: KEEP the two-column layout (narrow left menu, board icons on the RIGHT)
|
||||
on small touch screens — do NOT stack the boards below the menu. Stacking pushed
|
||||
the board icons under the menu + search bar, and dragscroll does not work over
|
||||
the menu / search area, so boards below the fold were unreachable (you could not
|
||||
drag-scroll down to them). The narrow minmax() menu set in the block above
|
||||
already prevents the old fixed-260px overflow that used to clip the left menu, so
|
||||
collapsing to one column is no longer needed. The board list stays in its own
|
||||
right-hand column with its own overflow-y:auto scroll. */
|
||||
.boards-layout {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
|
||||
.boards-left-menu {
|
||||
border-inline-end: none !important;
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
padding-inline-end: 0 !important;
|
||||
padding-bottom: 12px;
|
||||
grid-template-columns: minmax(84px, 30%) 1fr !important;
|
||||
}
|
||||
}
|
||||
.AllBoardTeamsOrgs {
|
||||
@@ -1146,13 +1223,31 @@ body.grey-icons-enabled .checkmark-no-grey {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* The left menu (Starred / Templates / Remaining / Workspaces / Add board) is its
|
||||
own vertical scroll area, so when it is taller than the screen you can drag-scroll
|
||||
down to its lower items — the board icons already scroll in their own column on the
|
||||
right. Both columns are reachable in LTR and RTL: the grid columns and the logical
|
||||
inline borders mirror automatically, so in RTL the menu is on the right and the
|
||||
boards on the left. */
|
||||
.boards-left-menu {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
max-height: calc(100vh - 120px);
|
||||
}
|
||||
|
||||
.board-list {
|
||||
overflow-y: scroll !important;
|
||||
overflow-y: auto !important;
|
||||
overflow-x: hidden !important;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #888 #f1f1f1;
|
||||
height: calc(100vh - 120px); /* Ensure there's content to scroll */
|
||||
/* #6488: a BOUNDED height so the boards overflow and scroll. The old rule set
|
||||
min-height:100vh (plus a phantom ::after), which grew the list to fit all its
|
||||
content — so nothing overflowed and, clipped by the overflow:hidden wrapper,
|
||||
boards below the fold were unreachable. */
|
||||
height: calc(100vh - 120px);
|
||||
max-height: calc(100vh - 120px);
|
||||
}
|
||||
|
||||
/* Force scrollbar to always be visible */
|
||||
@@ -1189,14 +1284,8 @@ body.grey-icons-enabled .checkmark-no-grey {
|
||||
.board-list {
|
||||
scrollbar-gutter: stable;
|
||||
scrollbar-width: auto !important;
|
||||
min-height: 100vh; /* Force content to be tall enough to scroll */
|
||||
}
|
||||
|
||||
/* Ensure there's always content to scroll */
|
||||
.board-list::after {
|
||||
content: '';
|
||||
display: block;
|
||||
height: 100px;
|
||||
/* NOTE: no min-height/::after here — the bounded height above is what makes the
|
||||
list scroll; forcing it 100vh tall would defeat that (see #6488). */
|
||||
}
|
||||
|
||||
/* Ensure only one scrollbar is visible */
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
template(name="boardList")
|
||||
.wrapper
|
||||
.board-list-header
|
||||
|
||||
.boards-layout
|
||||
// Left menu
|
||||
.boards-left-menu
|
||||
@@ -77,26 +75,11 @@ template(name="boardList")
|
||||
.boards-right-grid
|
||||
.boards-path-header
|
||||
.path-left
|
||||
span.path-icon.emoji-icon {{currentMenuPath.icon}}
|
||||
span.path-text {{currentMenuPath.text}}
|
||||
if BoardMultiSelection.isActive
|
||||
span.multiselection-hint
|
||||
span.emoji-icon
|
||||
i.fa.fa-thumb-tack
|
||||
| {{_ 'multi-selection-active'}}
|
||||
span.path-title
|
||||
span.path-icon.emoji-icon {{currentMenuPath.icon}}
|
||||
span.path-text {{currentMenuPath.text}}
|
||||
.path-right
|
||||
.board-search
|
||||
span.emoji-icon
|
||||
i.fa.fa-search
|
||||
input.js-board-search-input(type="text" placeholder="{{_ 'search-boards'}}" value="{{boardSearch}}" autocomplete="off")
|
||||
if boardSearch
|
||||
a.board-search-clear.js-board-search-clear(title="{{_ 'filter-clear'}}")
|
||||
span.emoji-icon
|
||||
i.fa.fa-times
|
||||
a.board-header-btn.js-open-boards-sort(title="{{_ 'sort-boards'}}" class="{{#unless isBoardsSort 'custom'}}emphasis{{/unless}}")
|
||||
span.emoji-icon
|
||||
i.fa.fa-sort-alpha-asc
|
||||
span {{_ 'sort'}}
|
||||
//- Order (left -> right): Multi-Selection, Sort, Search boards.
|
||||
if canModifyBoards
|
||||
if hasBoardsSelected
|
||||
button.js-archive-selected-boards.board-header-btn
|
||||
@@ -107,13 +90,41 @@ template(name="boardList")
|
||||
span.emoji-icon
|
||||
i.fa.fa-clipboard
|
||||
span {{_ 'duplicate-board'}}
|
||||
a.board-header-btn.js-multiselection-activate(
|
||||
title="{{#if BoardMultiSelection.isActive}}{{_ 'multi-selection-on'}}{{else}}{{_ 'multi-selection'}}{{/if}}"
|
||||
class="{{#if BoardMultiSelection.isActive}}emphasis{{/if}}")
|
||||
span.emoji-icon
|
||||
//- Multi-Selection button, with the "click checkboxes" hint stacked
|
||||
directly BELOW it (own column) instead of beside the path title.
|
||||
.multiselection-group
|
||||
a.board-header-btn.js-multiselection-activate(
|
||||
title="{{#if BoardMultiSelection.isActive}}{{_ 'multi-selection-on'}}{{else}}{{_ 'multi-selection'}}{{/if}}"
|
||||
class="{{#if BoardMultiSelection.isActive}}emphasis{{/if}}")
|
||||
i.fa.fa-check-square-o
|
||||
if BoardMultiSelection.isActive
|
||||
a.board-header-btn-close.js-multiselection-reset(title="{{_ 'filter-clear'}}")
|
||||
span {{#if BoardMultiSelection.isActive}}{{_ 'multi-selection-on'}}{{else}}{{_ 'multi-selection'}}{{/if}}
|
||||
if BoardMultiSelection.isActive
|
||||
a.board-header-btn-close.js-multiselection-reset(title="{{_ 'filter-clear'}}")
|
||||
i.fa.fa-times-thin
|
||||
if BoardMultiSelection.isActive
|
||||
span.multiselection-hint
|
||||
span.emoji-icon
|
||||
i.fa.fa-thumb-tack
|
||||
| {{_ 'multi-selection-active'}}
|
||||
//- "Selected:" actions on the multi-selected boards: star them,
|
||||
or set one as the Home board (opened after login, #2220).
|
||||
.selected-actions
|
||||
span.selected-label {{_ 'selected-label'}}
|
||||
a.selected-action.js-star-selected(title="{{_ 'set-selected-starred'}}")
|
||||
i.fa.fa-star
|
||||
a.selected-action.js-home-selected(title="{{_ 'set-selected-home'}}")
|
||||
i.fa.fa-home
|
||||
a.board-header-btn.js-open-boards-sort(title="{{_ 'sort-boards'}}" class="{{#unless isBoardsSort 'custom'}}emphasis{{/unless}}")
|
||||
span.emoji-icon
|
||||
i.fa.fa-sort-alpha-asc
|
||||
span {{_ 'sort'}}
|
||||
//- Search boards is placed to the RIGHT of the Multi-Selection button.
|
||||
.board-search
|
||||
span.emoji-icon
|
||||
i.fa.fa-search
|
||||
input.js-board-search-input(type="text" placeholder="{{_ 'search-boards'}}" value="{{boardSearch}}" autocomplete="off")
|
||||
if boardSearch
|
||||
a.board-search-clear.js-board-search-clear(title="{{_ 'filter-clear'}}")
|
||||
span.emoji-icon
|
||||
i.fa.fa-times
|
||||
if boardsPaginationActive
|
||||
@@ -126,7 +137,7 @@ template(name="boardList")
|
||||
ul.board-list.clearfix.js-boards.dragscroll(class="{{#if isMiniScreen}}mobile-view{{/if}} {{#if BoardMultiSelection.isActive}}is-multiselection-active{{/if}}")
|
||||
li.js-add-board
|
||||
if isSelectedMenu 'templates'
|
||||
a.board-list-item.label(title="{{_ 'add-template-container'}}")
|
||||
a.board-list-item.label.is-template-container(title="{{_ 'add-template-container'}}")
|
||||
span.emoji-icon
|
||||
i.fa.fa-plus
|
||||
| {{_ 'add-template-container'}}
|
||||
@@ -188,6 +199,11 @@ template(name="boardList")
|
||||
if BoardMultiSelection.isActive
|
||||
.materialCheckBox.multi-selection-checkbox.js-toggle-board-multi-selection(
|
||||
class="{{#if BoardMultiSelection.isSelected _id}}is-checked{{/if}}")
|
||||
//- #2220: the Home board (opened after login) is badged with a
|
||||
home icon at the top centre of its tile.
|
||||
if isDefaultBoard
|
||||
span.board-home-badge(title="{{_ 'home-board-badge'}}")
|
||||
i.fa.fa-home
|
||||
span.board-handle.nodragscroll(title="{{boardWorkspaceDragHint}}")
|
||||
span.emoji-icon
|
||||
i.fa.fa-arrows
|
||||
@@ -228,6 +244,8 @@ template(name="boardList")
|
||||
title="{{_ 'star-board-title'}}")
|
||||
span.emoji-icon
|
||||
i.fa(class="fa-star{{#unless isStarred}}-o{{/unless}}")
|
||||
//- #2220 Home-board toggle is disabled: the icon and the
|
||||
//- set-default-board action are intentionally not rendered.
|
||||
|
||||
template(name="boardsSortPopup")
|
||||
ul.pop-over-list
|
||||
|
||||
@@ -542,6 +542,21 @@ Template.boardList.helpers({
|
||||
const boards = ReactiveCache.getBoards(query, {});
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
|
||||
// #2220: the Home board (opened after login) always appears FIRST in the
|
||||
// Starred view, even when it has not been explicitly starred.
|
||||
const withHomeFirst = (arr) => {
|
||||
if (tpl.selectedMenu.get() !== 'starred') return arr;
|
||||
if ((tpl.boardSearchVar.get() || '').trim()) return arr;
|
||||
const homeId =
|
||||
currentUser && typeof currentUser.getDefaultBoardId === 'function'
|
||||
? currentUser.getDefaultBoardId()
|
||||
: null;
|
||||
if (!homeId) return arr;
|
||||
const homeBoard = ReactiveCache.getBoard(homeId);
|
||||
if (!homeBoard || homeBoard.archived) return arr;
|
||||
return [homeBoard, ...arr.filter((b) => b && b._id !== homeId)];
|
||||
};
|
||||
|
||||
// #5799: in a sorted (non-custom) mode the server already computed the
|
||||
// current page (filtered by menu/search and sorted), so render exactly that
|
||||
// ordered page of board icons. Custom (manual drag order) falls through to
|
||||
@@ -552,9 +567,11 @@ Template.boardList.helpers({
|
||||
: 'custom';
|
||||
if (sortMode !== 'custom') {
|
||||
const paged = tpl.pagedBoardsVar.get();
|
||||
return (paged.ids || [])
|
||||
.map((id) => ReactiveCache.getBoard(id))
|
||||
.filter(Boolean);
|
||||
return withHomeFirst(
|
||||
(paged.ids || [])
|
||||
.map((id) => ReactiveCache.getBoard(id))
|
||||
.filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
let list = boards;
|
||||
@@ -591,11 +608,11 @@ Template.boardList.helpers({
|
||||
}
|
||||
|
||||
if (currentUser && typeof currentUser.sortBoardsForUser === 'function') {
|
||||
return currentUser.sortBoardsForUser(list);
|
||||
return withHomeFirst(currentUser.sortBoardsForUser(list));
|
||||
}
|
||||
return list
|
||||
.slice()
|
||||
.sort((a, b) => (a.title || '').localeCompare(b.title || ''));
|
||||
return withHomeFirst(
|
||||
list.slice().sort((a, b) => (a.title || '').localeCompare(b.title || '')),
|
||||
);
|
||||
},
|
||||
// #5174 / #4825: the board tiles' per-list card-count line and member avatar
|
||||
// row. Data comes from the one-shot getAllBoardsTileData method fetch in
|
||||
@@ -629,6 +646,11 @@ Template.boardList.helpers({
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
return user && user.hasStarred(this._id);
|
||||
},
|
||||
// #2220: is this the user's Home board (the one opened after login)?
|
||||
isDefaultBoard() {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
return user && user.isDefaultBoard(this._id);
|
||||
},
|
||||
isAdministrable() {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
return user && user.isBoardAdmin(this._id);
|
||||
@@ -916,6 +938,29 @@ Template.boardList.events({
|
||||
Meteor.call('toggleBoardStar', boardId);
|
||||
}
|
||||
},
|
||||
// "Selected:" star action: star every multi-selected board that isn't yet
|
||||
// starred (so it becomes Starred, never toggled back off by this button).
|
||||
'click .js-star-selected'(evt) {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
BoardMultiSelection.getSelectedBoardIds().forEach((id) => {
|
||||
if (user && !user.hasStarred(id)) {
|
||||
Meteor.call('toggleBoardStar', id);
|
||||
}
|
||||
});
|
||||
},
|
||||
// #2220 "Selected:" home action: set the first selected board as the Home
|
||||
// board (opened after login). Clicking it again when it is already Home clears
|
||||
// it (toggle), matching toggleDefaultBoard.
|
||||
'click .js-home-selected'(evt) {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
const ids = BoardMultiSelection.getSelectedBoardIds();
|
||||
if (ids.length) {
|
||||
Meteor.call('toggleDefaultBoard', ids[0]);
|
||||
}
|
||||
},
|
||||
// HTML5 DnD from boards to spaces
|
||||
// #5850: drag a (template) board onto an Org/Team/Domain target to share it.
|
||||
'dragover .js-share-target'(evt) {
|
||||
@@ -1103,6 +1148,10 @@ Template.boardList.events({
|
||||
},
|
||||
'click .js-multiselection-reset'(evt) {
|
||||
evt.preventDefault();
|
||||
// The reset X is now nested inside the activate button (matching the Swimlanes
|
||||
// view), so stop the click from bubbling to js-multiselection-activate — which
|
||||
// would otherwise immediately re-activate what we just disabled.
|
||||
evt.stopPropagation();
|
||||
BoardMultiSelection.disable();
|
||||
},
|
||||
'click .js-toggle-board-multi-selection'(evt) {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/* Board "Statistics" view — full width, like the other board views. Its text is
|
||||
selectable so any value can be highlighted and copied. */
|
||||
.stats-view {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
|
||||
.stats-view-content {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.stats-view-title {
|
||||
margin: 0 0 16px;
|
||||
font-size: 22px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.stats-view-section {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.stats-view-heading {
|
||||
margin: 0 0 8px;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
border-bottom: 2px solid #dbdbdb;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.stats-view-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.stats-view-table td {
|
||||
padding: 8px 4px;
|
||||
border-bottom: 1px solid #eee;
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
|
||||
.stats-view-table .stats-view-label {
|
||||
color: #666;
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
.stats-view-table .stats-view-value {
|
||||
font-weight: bold;
|
||||
text-align: right;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
template(name="statsView")
|
||||
.stats-view
|
||||
.stats-view-content
|
||||
h1.stats-view-title {{ boardTitle }}
|
||||
.stats-view-section
|
||||
h2.stats-view-heading {{_ 'board-status'}}
|
||||
table.stats-view-table
|
||||
tbody
|
||||
tr
|
||||
td.stats-view-label {{_ 'board-status-loading-mode'}}
|
||||
td.stats-view-value {{ loadingModeLabel }}
|
||||
tr
|
||||
td.stats-view-label {{_ 'swimlanes'}}
|
||||
td.stats-view-value {{ swimlaneCount }}
|
||||
tr
|
||||
td.stats-view-label {{_ 'lists'}}
|
||||
td.stats-view-value {{ listCount }}
|
||||
tr
|
||||
td.stats-view-label {{_ 'cards'}}
|
||||
td.stats-view-value {{ cardCount }}
|
||||
tr
|
||||
td.stats-view-label {{_ 'card-archived'}}
|
||||
td.stats-view-value {{ archivedCardCount }}
|
||||
tr
|
||||
td.stats-view-label {{_ 'labels'}}
|
||||
td.stats-view-value {{ labelCount }}
|
||||
tr
|
||||
td.stats-view-label {{_ 'members'}}
|
||||
td.stats-view-value {{ memberCount }}
|
||||
tr
|
||||
td.stats-view-label {{_ 'custom-fields'}}
|
||||
td.stats-view-value {{ customFieldCount }}
|
||||
.stats-view-section
|
||||
h2.stats-view-heading {{_ 'board-status-time-summary'}}
|
||||
table.stats-view-table
|
||||
tbody
|
||||
tr
|
||||
td.stats-view-label {{_ 'board-status-time-spent-total'}}
|
||||
td.stats-view-value {{ timeSpentTotal }}
|
||||
tr
|
||||
td.stats-view-label {{_ 'board-status-cards-with-time'}}
|
||||
td.stats-view-value {{ cardsWithTimeSpent }}
|
||||
tr
|
||||
td.stats-view-label {{_ 'board-status-overtime-cards'}}
|
||||
td.stats-view-value {{ overtimeCards }}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { TAPi18n } from '/imports/i18n';
|
||||
import { ReactiveVar } from 'meteor/reactive-var';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
|
||||
// Board view "Statistics" (Tilastot) — a full-width board view (like Swimlanes /
|
||||
// Lists / Calendar / Gantt / Table) showing the board's status: its card-loading
|
||||
// mode, counts (swimlanes, lists, cards, labels, members, custom fields) and a
|
||||
// time-spent summary. Counts come from the server `boardStatus` method so they are
|
||||
// accurate even in lazy mode, where the browser only holds the visible card window.
|
||||
Template.statsView.onCreated(function() {
|
||||
this.status = new ReactiveVar(null);
|
||||
this.autorun(() => {
|
||||
const boardId = Session.get('currentBoard');
|
||||
if (boardId) {
|
||||
Meteor.call('boardStatus', boardId, (err, res) => {
|
||||
if (!err && res) this.status.set(res);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Read one numeric field from the resolved status, or a placeholder while loading.
|
||||
function statsNum(key) {
|
||||
const s = Template.instance().status.get();
|
||||
return s ? (s[key] || 0) : '…';
|
||||
}
|
||||
|
||||
Template.statsView.events({
|
||||
// The board canvas uses drag-to-scroll (the `dragscroll` class), which otherwise
|
||||
// swallows pointer/touch drags and prevents selecting text. Stop these events from
|
||||
// reaching it so the browser's native selection works — with the mouse (drag) and
|
||||
// on touch (long-press) — and any value can be copied to the clipboard.
|
||||
'mousedown .stats-view'(event) {
|
||||
event.stopPropagation();
|
||||
},
|
||||
'touchstart .stats-view'(event) {
|
||||
event.stopPropagation();
|
||||
},
|
||||
});
|
||||
|
||||
Template.statsView.helpers({
|
||||
boardTitle() {
|
||||
const board = Utils.getCurrentBoard();
|
||||
return board ? board.title : '';
|
||||
},
|
||||
loadingModeLabel() {
|
||||
const s = Template.instance().status.get();
|
||||
if (!s) return '…';
|
||||
const inEffect = s.lazy
|
||||
? TAPi18n.__('cards-loading-lazy')
|
||||
: TAPi18n.__('cards-loading-all');
|
||||
return s.mode === 'auto' ? `${inEffect} (${TAPi18n.__('cards-loading-auto')})` : inEffect;
|
||||
},
|
||||
swimlaneCount() { return statsNum('swimlanes'); },
|
||||
listCount() { return statsNum('lists'); },
|
||||
cardCount() { return statsNum('cards'); },
|
||||
archivedCardCount() { return statsNum('archivedCards'); },
|
||||
labelCount() { return statsNum('labels'); },
|
||||
memberCount() { return statsNum('members'); },
|
||||
customFieldCount() { return statsNum('customFields'); },
|
||||
cardsWithTimeSpent() { return statsNum('cardsWithTimeSpent'); },
|
||||
overtimeCards() { return statsNum('overtimeCards'); },
|
||||
timeSpentTotal() {
|
||||
const s = Template.instance().status.get();
|
||||
if (!s) return '…';
|
||||
const n = Number(s.timeSpentTotal) || 0;
|
||||
return `${n} h`;
|
||||
},
|
||||
});
|
||||
@@ -63,18 +63,6 @@
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* Sortable column headers (Excel-like): clickable, with a sort-direction
|
||||
arrow appended by the sortIndicator helper. */
|
||||
.my-cards-board-table th.js-table-view-sort {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.my-cards-board-table th.js-table-view-sort:hover {
|
||||
color: #01628c;
|
||||
}
|
||||
|
||||
/* Leftmost "Edit" cell: an Edit link (pencil icon + text) styled like the one
|
||||
on the Admin Panel / People page, opening the card when clicked. */
|
||||
.my-cards-board-table td.table-view-cell-edit {
|
||||
|
||||
@@ -18,21 +18,21 @@ template(name="tableView")
|
||||
thead
|
||||
tr
|
||||
th(scope="col")
|
||||
th.js-table-view-sort(scope="col" data-sort="card") {{_ "card"}} {{sortIndicator 'card'}}
|
||||
th.js-table-view-sort(scope="col" data-sort="list") {{_ "list"}} {{sortIndicator 'list'}}
|
||||
th.js-table-view-sort(scope="col" data-sort="swimlane") {{_ "swimlane"}} {{sortIndicator 'swimlane'}}
|
||||
th(scope="col") {{_ "card"}}
|
||||
th(scope="col") {{_ "list"}}
|
||||
th(scope="col") {{_ "swimlane"}}
|
||||
unless isMiniScreen
|
||||
th(scope="col") {{_ "assignees"}}
|
||||
th(scope="col") {{_ "members"}}
|
||||
th(scope="col") {{_ "labels"}}
|
||||
if showReceivedColumn
|
||||
th.js-table-view-sort(scope="col" data-sort="received") {{_ "card-received"}} {{sortIndicator 'received'}}
|
||||
th(scope="col") {{_ "card-received"}}
|
||||
if showStartColumn
|
||||
th.js-table-view-sort(scope="col" data-sort="start") {{_ "card-start"}} {{sortIndicator 'start'}}
|
||||
th(scope="col") {{_ "card-start"}}
|
||||
if showDueColumn
|
||||
th.js-table-view-sort(scope="col" data-sort="due") {{_ "card-due"}} {{sortIndicator 'due'}}
|
||||
th(scope="col") {{_ "card-due"}}
|
||||
if showEndColumn
|
||||
th.js-table-view-sort(scope="col" data-sort="end") {{_ "card-end"}} {{sortIndicator 'end'}}
|
||||
th(scope="col") {{_ "card-end"}}
|
||||
tbody
|
||||
each row in rows
|
||||
tr
|
||||
|
||||
@@ -14,8 +14,6 @@ const rowsPerPage = 25;
|
||||
|
||||
Template.tableView.onCreated(function () {
|
||||
this.searchQuery = new ReactiveVar('');
|
||||
this.sortField = new ReactiveVar('card'); // card | list | swimlane | due
|
||||
this.sortDirection = new ReactiveVar(1); // 1 ascending, -1 descending
|
||||
this.page = new ReactiveVar(1);
|
||||
this.filteredRows = new ReactiveVar([]);
|
||||
|
||||
@@ -30,8 +28,6 @@ Template.tableView.onCreated(function () {
|
||||
}
|
||||
|
||||
const query = this.searchQuery.get().trim().toLowerCase();
|
||||
const field = this.sortField.get();
|
||||
const direction = this.sortDirection.get();
|
||||
|
||||
const rows = [];
|
||||
board.cards().forEach(card => {
|
||||
@@ -75,39 +71,11 @@ Template.tableView.onCreated(function () {
|
||||
});
|
||||
}
|
||||
|
||||
// Map a date sort field to the matching row property.
|
||||
const dateFieldProp = {
|
||||
received: 'receivedAt',
|
||||
start: 'startAt',
|
||||
due: 'dueAt',
|
||||
end: 'endAt',
|
||||
};
|
||||
|
||||
filtered = filtered.slice().sort((a, b) => {
|
||||
const dateProp = dateFieldProp[field];
|
||||
if (dateProp) {
|
||||
// Cards without the date sort last, regardless of direction.
|
||||
const av = a[dateProp] ? new Date(a[dateProp]).getTime() : Infinity;
|
||||
const bv = b[dateProp] ? new Date(b[dateProp]).getTime() : Infinity;
|
||||
return (av - bv) * direction;
|
||||
}
|
||||
let av;
|
||||
let bv;
|
||||
if (field === 'list') {
|
||||
av = a.listTitle;
|
||||
bv = b.listTitle;
|
||||
} else if (field === 'swimlane') {
|
||||
av = a.swimlaneTitle;
|
||||
bv = b.swimlaneTitle;
|
||||
} else {
|
||||
av = a.title;
|
||||
bv = b.title;
|
||||
}
|
||||
return (
|
||||
av.localeCompare(bv, undefined, { numeric: true, sensitivity: 'base' }) *
|
||||
direction
|
||||
);
|
||||
});
|
||||
// Fixed order: by card title, ascending. (Column-header click-to-sort was
|
||||
// removed; the Table view now always shows this stable order.)
|
||||
filtered = filtered.slice().sort((a, b) =>
|
||||
a.title.localeCompare(b.title, undefined, { numeric: true, sensitivity: 'base' }),
|
||||
);
|
||||
|
||||
this.filteredRows.set(filtered);
|
||||
});
|
||||
@@ -172,13 +140,6 @@ Template.tableView.helpers({
|
||||
const board = Utils.getCurrentBoard();
|
||||
return !!board && (board.allowsEndDate || board.allowsEndDateOnMinicard);
|
||||
},
|
||||
|
||||
// Excel-like sort arrow shown on the active sort column header.
|
||||
sortIndicator(field) {
|
||||
const tpl = Template.instance();
|
||||
if (tpl.sortField.get() !== field) return '';
|
||||
return tpl.sortDirection.get() === 1 ? '▲' : '▼';
|
||||
},
|
||||
});
|
||||
|
||||
Template.tableView.events({
|
||||
@@ -212,19 +173,6 @@ Template.tableView.events({
|
||||
if (current < totalPages) tpl.page.set(current + 1);
|
||||
},
|
||||
|
||||
'click .js-table-view-sort'(event, tpl) {
|
||||
event.preventDefault();
|
||||
const field = event.currentTarget.dataset.sort;
|
||||
if (!field) return;
|
||||
if (tpl.sortField.get() === field) {
|
||||
tpl.sortDirection.set(tpl.sortDirection.get() * -1);
|
||||
} else {
|
||||
tpl.sortField.set(field);
|
||||
tpl.sortDirection.set(1);
|
||||
}
|
||||
tpl.page.set(1);
|
||||
},
|
||||
|
||||
// Clicking the leftmost "Edit" link opens the Card Details popup on top of the
|
||||
// Board Table view (same mechanism as opening a card from search results).
|
||||
'click .js-table-view-edit-card'(event) {
|
||||
|
||||
@@ -62,20 +62,20 @@ template(name="attachmentGallery")
|
||||
.attachment-thumbnail-container.open-preview(data-attachment-id="{{_id}}" data-card-id="{{ meta.cardId }}")
|
||||
if link
|
||||
if(isImage)
|
||||
img.attachment-thumbnail(src="{{link}}" title="{{sanitize name}}")
|
||||
img.attachment-thumbnail(src="{{link}}" title="{{cleanFilename name}}")
|
||||
else if($eq extension 'svg')
|
||||
img.attachment-thumbnail(src="{{link}}" title="{{sanitize name}}" type="image/svg+xml")
|
||||
img.attachment-thumbnail(src="{{link}}" title="{{cleanFilename name}}" type="image/svg+xml")
|
||||
else if($eq extension 'mp3')
|
||||
video.attachment-thumbnail(title="{{sanitize name}}")
|
||||
video.attachment-thumbnail(title="{{cleanFilename name}}")
|
||||
source(src="{{link}}" type="audio/mpeg")
|
||||
else if($eq extension 'ogg')
|
||||
video.attachment-thumbnail(title="{{sanitize name}}")
|
||||
video.attachment-thumbnail(title="{{cleanFilename name}}")
|
||||
source(src="{{link}}" type="video/ogg")
|
||||
else if($eq extension 'webm')
|
||||
video.attachment-thumbnail(title="{{sanitize name}}")
|
||||
video.attachment-thumbnail(title="{{cleanFilename name}}")
|
||||
source(src="{{link}}" type="video/webm")
|
||||
else if($eq extension 'mp4')
|
||||
video.attachment-thumbnail(title="{{sanitize name}}")
|
||||
video.attachment-thumbnail(title="{{cleanFilename name}}")
|
||||
source(src="{{link}}" type="video/mp4")
|
||||
else
|
||||
span.attachment-thumbnail-text= extension
|
||||
@@ -83,15 +83,14 @@ template(name="attachmentGallery")
|
||||
.attachment-details-container
|
||||
.attachment-details
|
||||
div
|
||||
b
|
||||
= name
|
||||
b {{ cleanFilename name }}
|
||||
span.file-size ({{fileSize size}})
|
||||
if uploaderName
|
||||
div.attachment-uploader {{uploaderName}}
|
||||
if uploadedAt
|
||||
div.attachment-uploaded-at {{uploadedAt}}
|
||||
.attachment-actions
|
||||
a.js-download(href="{{link}}?download=true", download="{{name}}", title="{{_ 'download'}}")
|
||||
a.js-download(href="{{link}}?download=true", download="{{downloadFilename name}}", title="{{_ 'download'}}")
|
||||
i.fa.fa-arrow-down
|
||||
if currentUser.isBoardMember
|
||||
unless currentUser.isCommentOnly
|
||||
|
||||
@@ -6,6 +6,7 @@ import uploadProgressManager from '../../lib/uploadProgressManager';
|
||||
import { attachmentMigrationManager } from '/client/lib/attachmentMigrationManager';
|
||||
import Attachments from '/models/attachments';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
const { cleanFileName } = require('/imports/lib/fileNameDisplay');
|
||||
import { formatDateTime } from '/imports/lib/dateUtils';
|
||||
import { EscapeActions } from '/client/lib/escapeActions';
|
||||
|
||||
@@ -148,7 +149,9 @@ function openAttachmentViewer(attachmentId) {
|
||||
break;
|
||||
}
|
||||
|
||||
$('#attachment-name').text(attachment.name);
|
||||
// Show the cleaned name: URL-decoded, homoglyphs folded, invisible characters
|
||||
// and exploit markup removed (plain text; .text() escapes, so never HTML).
|
||||
$('#attachment-name').text(cleanFileName(attachment.name));
|
||||
$('#viewer-overlay').removeClass('hidden');
|
||||
}
|
||||
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
body.desktop-mode .card-details:not(.card-details-popup) {
|
||||
position: fixed;
|
||||
width: auto;
|
||||
max-width: 800px;
|
||||
max-width: 520px; /* #6465: narrower dock (was 800px, ~1/4 screen) */
|
||||
flex-basis: auto;
|
||||
border-radius: 3px;
|
||||
z-index: 2001;
|
||||
@@ -445,8 +445,8 @@ body.desktop-mode .card-details.card-details-collapsed {
|
||||
@media screen and (min-width: 801px) {
|
||||
.card-details {
|
||||
top: 97px;
|
||||
inset-inline-start: calc(50% - (600px / 2));
|
||||
width: 600px;
|
||||
inset-inline-start: calc(50% - (520px / 2)); /* #6465: narrower card (was 600px) */
|
||||
width: 520px;
|
||||
bottom: 0;
|
||||
position: fixed;
|
||||
resize: both;
|
||||
|
||||
@@ -63,6 +63,7 @@ import {
|
||||
getCurrentCardIdFromContext,
|
||||
getCurrentCardFromContext,
|
||||
} from '/client/lib/currentCard';
|
||||
import { isLazyCards } from '/client/lib/lazyCards';
|
||||
import uploadProgressManager from '../../lib/uploadProgressManager';
|
||||
import { CSSEvents } from '/client/lib/cssEvents';
|
||||
import { UnsavedEdits } from '/client/lib/unsavedEdits';
|
||||
@@ -374,6 +375,19 @@ Template.cardDetails.onCreated(function () {
|
||||
Meteor.subscribe('legacyBoardAttachments', board._id);
|
||||
}
|
||||
});
|
||||
|
||||
// In lazy (windowed) card loading the board/window publications only ship the
|
||||
// children of cards in the visible window, so an open card that scrolled out of
|
||||
// — or was just added to — a window could miss its own comments/attachments/
|
||||
// checklists. Subscribe to the open card's OWN live children so the card detail
|
||||
// is always complete and reactive, independent of the window (#6480). No-op in
|
||||
// 'all' mode, where the board publication already ships everything.
|
||||
this.autorun(() => {
|
||||
const cardId = Session.get('currentCard');
|
||||
if (cardId && isLazyCards()) {
|
||||
Meteor.subscribe('openCardData', cardId);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Template.cardDetails.onRendered(function () {
|
||||
@@ -1418,7 +1432,10 @@ Template.inlinedCardDescription.events({
|
||||
tpl.isOpen.set(true);
|
||||
},
|
||||
'keydown form textarea'(evt, tpl) {
|
||||
if (evt.keyCode === 13 && (evt.metaKey || evt.ctrlKey)) {
|
||||
// Ctrl/Cmd+Enter submits by default; plain Enter when the user enabled
|
||||
// "submit on Enter" in Member Settings (Shift+Enter then makes a newline).
|
||||
const submitOnEnter = !!ReactiveCache.getCurrentUser()?.hasSubmitOnEnter?.();
|
||||
if (isSubmitKey(evt, { submitOnEnter })) {
|
||||
tpl.find('button[type=submit]').click();
|
||||
}
|
||||
},
|
||||
@@ -1529,11 +1546,12 @@ Template.editCardTitleForm.events({
|
||||
Utils.showCopied(promise, $tooltip);
|
||||
},
|
||||
'keydown .js-edit-card-title'(event) {
|
||||
// #4236: match the card description field — Ctrl/Cmd+Enter saves; plain
|
||||
// Enter (and Shift+Enter) insert a new line instead of submitting. The old
|
||||
// handler submitted on any Enter without Shift, so Enter could not add a
|
||||
// newline in the title.
|
||||
if (isSubmitKey(event)) {
|
||||
// #4236: by default Ctrl/Cmd+Enter saves; plain Enter (and Shift+Enter)
|
||||
// insert a new line. A user who turns on "submit on Enter" in Member
|
||||
// Settings gets plain Enter to save (Shift+Enter for a newline) instead —
|
||||
// the pre-#4236 fast title-editing workflow (#6480 email feedback).
|
||||
const submitOnEnter = !!ReactiveCache.getCurrentUser()?.hasSubmitOnEnter?.();
|
||||
if (isSubmitKey(event, { submitOnEnter })) {
|
||||
$('.js-submit-edit-card-title-form').click();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,3 +1,60 @@
|
||||
/* #4759: Member Settings / Font popup — a button per installed font (each shown in
|
||||
its own font) and a row of size buttons (smaller ... 100% ... larger). Clicking a
|
||||
button applies immediately. */
|
||||
.ui-font-buttons,
|
||||
.ui-font-size-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin: 2px 0 10px 0;
|
||||
}
|
||||
.ui-font-btn,
|
||||
.ui-font-size-btn {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
text-decoration: none;
|
||||
}
|
||||
.ui-font-btn:hover,
|
||||
.ui-font-size-btn:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
.ui-font-btn.active,
|
||||
.ui-font-size-btn.active {
|
||||
background: #2980b9;
|
||||
border-color: #2980b9;
|
||||
color: #fff;
|
||||
}
|
||||
/* The "Unset" colour reset links look like buttons. */
|
||||
.color-wheel-wrap .js-reset-text-color,
|
||||
.color-wheel-wrap .js-reset-bg-color {
|
||||
display: inline-block;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
text-decoration: none;
|
||||
}
|
||||
.color-wheel-wrap .js-reset-text-color:hover,
|
||||
.color-wheel-wrap .js-reset-bg-color:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
.color-wheel-wrap .js-reset-text-color.disabled,
|
||||
.color-wheel-wrap .js-reset-bg-color.disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* #5514: native color-wheel (custom RGB / hex) picker wrapper, shared by the
|
||||
label / list / swimlane / card color popups. */
|
||||
.color-wheel-wrap {
|
||||
|
||||
@@ -203,6 +203,12 @@
|
||||
.minicard .minicard-title .viewer {
|
||||
display: block;
|
||||
word-wrap: break-word;
|
||||
/* #6465: the minicard title renders inside a .viewer, which inherits the
|
||||
global `.viewer { min-height: 2.5vh }` (layouts.css). On tall screens that
|
||||
reserves a big empty band under a one-line title, so half of every
|
||||
minicard was white space. Collapse it to fit the text (same fix already
|
||||
applied to the label .viewer above). */
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
.minicard .dates {
|
||||
|
||||
@@ -130,8 +130,8 @@ textarea.editor {
|
||||
}
|
||||
input[type="submit"],
|
||||
button {
|
||||
background: #000;
|
||||
background: linear-gradient(#000, #000);
|
||||
background: var(--theme-accent, #000);
|
||||
background: linear-gradient(var(--theme-accent, #000), var(--theme-accent, #000));
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: inline-block;
|
||||
@@ -150,14 +150,14 @@ input[type="submit"]:hover,
|
||||
button:hover,
|
||||
input[type="submit"]:focus,
|
||||
button:focus {
|
||||
background: #222;
|
||||
background: linear-gradient(#222, #222);
|
||||
background: var(--theme-accent, #222);
|
||||
background: linear-gradient(var(--theme-accent, #222), var(--theme-accent, #222));
|
||||
color: #fff;
|
||||
}
|
||||
input[type="submit"]:active,
|
||||
button:active {
|
||||
background: #111;
|
||||
background: linear-gradient(#111, #111);
|
||||
background: var(--theme-accent, #111);
|
||||
background: linear-gradient(var(--theme-accent, #111), var(--theme-accent, #111));
|
||||
box-shadow: inset 0 3px 6px rgba(0,0,0,0.3);
|
||||
color: #fff;
|
||||
}
|
||||
@@ -172,7 +172,7 @@ button:active:active {
|
||||
}
|
||||
input[type="submit"].primary,
|
||||
button.primary {
|
||||
background: #005377;
|
||||
background: var(--theme-accent, #005377);
|
||||
box-shadow: 0 1px 0 #4d4d4d;
|
||||
color: #fff;
|
||||
}
|
||||
@@ -180,11 +180,11 @@ input[type="submit"].primary:hover,
|
||||
button.primary:hover,
|
||||
input[type="submit"].primary:focus,
|
||||
button.primary:focus {
|
||||
background: #004766;
|
||||
background: var(--theme-accent, #004766);
|
||||
}
|
||||
input[type="submit"].primary:active,
|
||||
button.primary:active {
|
||||
background: #01628c;
|
||||
background: var(--theme-accent, #01628c);
|
||||
}
|
||||
input[type="submit"].negate,
|
||||
button.negate {
|
||||
|
||||
@@ -394,6 +394,7 @@ Template.list.onRendered(function () {
|
||||
// the old code compared against a scrollLeftMax of 0 and never fired, so
|
||||
// dragging a card toward an off-screen list never auto-scrolled the board.
|
||||
const { computeEdgeScroll, findLaneUnderPointer } = require('/imports/lib/boardAutoScroll');
|
||||
let scrolled = false;
|
||||
const lanes = document.querySelectorAll('.js-lists');
|
||||
const rects = Array.prototype.map.call(lanes, el => el.getBoundingClientRect());
|
||||
const laneIndex = findLaneUnderPointer(rects, event.clientX, event.clientY);
|
||||
@@ -406,7 +407,10 @@ Template.list.onRendered(function () {
|
||||
scrollSize: lanes[laneIndex].scrollWidth,
|
||||
clientSize: lanes[laneIndex].clientWidth,
|
||||
});
|
||||
if (nextLeft !== null) lanes[laneIndex].scrollLeft = nextLeft;
|
||||
if (nextLeft !== null) {
|
||||
lanes[laneIndex].scrollLeft = nextLeft;
|
||||
scrolled = true;
|
||||
}
|
||||
}
|
||||
// vertical auto-scroll stays on the board canvas
|
||||
const canvas = document.querySelector('.board-canvas');
|
||||
@@ -420,7 +424,23 @@ Template.list.onRendered(function () {
|
||||
scrollSize: canvas.scrollHeight,
|
||||
clientSize: canvas.clientHeight,
|
||||
});
|
||||
if (nextTop !== null) canvas.scrollTop = nextTop;
|
||||
if (nextTop !== null) {
|
||||
canvas.scrollTop = nextTop;
|
||||
scrolled = true;
|
||||
}
|
||||
}
|
||||
// #6477: after an auto-scroll, jQuery UI sortable's cached container/item
|
||||
// geometry is STALE — it snapshots positions at drag start and on its own
|
||||
// placeholder moves, never on our manual scroll here. With long/overflowing
|
||||
// lists (exactly what triggers auto-scroll) the drop then resolved against a
|
||||
// stale map and parked the placeholder in the WRONG swimlane's copy of the
|
||||
// list, so a card dragged between swimlanes snapped back into its source
|
||||
// swimlane (the stop handler's ui.item.parents('.swimlane') faithfully
|
||||
// persisted it). Re-cache positions so the placeholder and drop resolve
|
||||
// against the scrolled layout. Complements the DOM-mutation refresh in
|
||||
// listBody.js (#2769), which does not fire on scroll.
|
||||
if (scrolled) {
|
||||
$cards.sortable('refreshPositions');
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -366,7 +366,7 @@ Template.listBody.helpers({
|
||||
// just this list/swimlane window (grows with `limit` as the user scrolls)
|
||||
// plus its reactive total count. Default mode keeps everything in minimongo
|
||||
// already, so these subscriptions are skipped.
|
||||
if (isLazyCards() && list.boardId) {
|
||||
if (list.boardId && isLazyCards(list.boardId)) {
|
||||
tpl.subscribe('boardCardsWindow', list.boardId, mongoSelector, sortBy, limit);
|
||||
tpl.subscribe(
|
||||
'boardListCardCount',
|
||||
|
||||
@@ -101,6 +101,10 @@ template(name="listHeader")
|
||||
i.fa.fa-plus
|
||||
a.js-open-list-menu(title="{{_ 'listActionPopup-title'}}")
|
||||
i.fa.fa-bars
|
||||
//- #6465: far-right add-list button. Opens the inline Add List
|
||||
//- composer AFTER this list (right in LTR / left in RTL).
|
||||
a.js-add-list-here.list-header-plus-top(title="{{_ 'add-list'}}")
|
||||
i.fa.fa-plus-square-o
|
||||
|
||||
template(name="editListTitleForm")
|
||||
.list-composer
|
||||
|
||||
@@ -208,6 +208,20 @@ Template.listHeader.events({
|
||||
Utils.setListCollapseState(list, !status);
|
||||
},
|
||||
'click .js-open-list-menu': Popup.open('listAction'),
|
||||
// #6465: open the inline Add List composer after this list. Record the
|
||||
// swimlane it is displayed in (board-wide lists are not bound to one) so the
|
||||
// new list is created in the right swimlane; the composer renders after this
|
||||
// list in the DOM (right in LTR / left in RTL).
|
||||
'click .js-add-list-here'(event) {
|
||||
event.preventDefault();
|
||||
const list = Template.currentData();
|
||||
const swimlaneDom = $(event.currentTarget).closest('.js-swimlane');
|
||||
const swimlaneId = swimlaneDom.length
|
||||
? swimlaneDom.attr('id').replace('swimlane-', '')
|
||||
: null;
|
||||
Session.set('wekan-add-list-swimlane', swimlaneId);
|
||||
Session.set('wekan-add-list-after', list._id);
|
||||
},
|
||||
'click .js-add-card.list-header-plus-top'(event) {
|
||||
const listDom = $(event.target).parents(
|
||||
`#js-list-${Template.currentData()._id}`,
|
||||
@@ -373,8 +387,10 @@ Template.listMorePopup.events({
|
||||
],
|
||||
}).length === 0
|
||||
) {
|
||||
allCardIds.map(_id => Cards.remove(_id));
|
||||
Lists.remove(list._id);
|
||||
// Soft delete (docs/Features/Undo/Undo.md): mark the list + its cards deleted
|
||||
// (server cascades) instead of destroying them, so the delete is restorable
|
||||
// and undoable (#1023). Replaces the old hard Cards.remove / Lists.remove.
|
||||
Meteor.call('lists.softRemove', list._id);
|
||||
} else {
|
||||
const message = `${TAPi18n.__(
|
||||
'delete-linked-cards-before-this-list',
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/* Custom theme colors (docs/Theme/Theme.md) — the flat/clear categories let the
|
||||
* user pick their own colors, applied as CSS variables on :root by
|
||||
* globalThemeColor.js and consumed here. This is the initial, highest-impact
|
||||
* surface (the header bars, primary buttons, sidebar button); deeper theming can
|
||||
* grow from here. dark/special themes never set these variables. */
|
||||
|
||||
/* Flat: a single accent recolors the header bars and primary controls. The class
|
||||
* lives on <body>; #header / #header-quick-access are descendants. */
|
||||
.has-custom-theme-color #header,
|
||||
.has-custom-theme-color #header-quick-access {
|
||||
background: var(--theme-accent) !important;
|
||||
}
|
||||
|
||||
.has-custom-theme-color button[type=submit].primary,
|
||||
.has-custom-theme-color input[type=submit].primary,
|
||||
.has-custom-theme-color .sidebar .sidebar-content .sidebar-btn {
|
||||
background-color: var(--theme-accent) !important;
|
||||
}
|
||||
|
||||
/* Clear: a two-color slide (gradient) across the header bars. */
|
||||
.has-custom-theme-slide #header,
|
||||
.has-custom-theme-slide #header-quick-access {
|
||||
background: linear-gradient(135deg, var(--theme-accent), var(--theme-accent-2)) !important;
|
||||
}
|
||||
|
||||
/* Select Color picker: category groups with a visible name above each group of
|
||||
* colour swatches (docs/Theme/Theme.md). */
|
||||
.theme-color-picker .theme-category-group {
|
||||
margin-bottom: 8px;
|
||||
clear: both; /* start below the previous group's floated swatches */
|
||||
}
|
||||
.theme-color-picker .theme-category-label {
|
||||
display: block;
|
||||
width: 100%;
|
||||
clear: both; /* drop below the previous group's floats... */
|
||||
text-align: left; /* ...so Flat/Clear/Dark/Special all sit at the left */
|
||||
font-weight: bold;
|
||||
margin: 2px 0 4px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
/* No empty band above the first (Flat) title. */
|
||||
.theme-color-picker .theme-category-group:first-child .theme-category-label {
|
||||
margin-top: 0;
|
||||
}
|
||||
/* Tighten the Default (no override) row above the groups (Member Settings). */
|
||||
.theme-color-picker ul.pop-over-list {
|
||||
margin: 0 0 4px;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -88,17 +88,9 @@
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
.board-color-belize.js-new-search {
|
||||
background-color: #216694 !important;
|
||||
color: #fff !important;
|
||||
border: none !important;
|
||||
}
|
||||
.board-color-belize.js-new-search:hover {
|
||||
background-color: #1a4d6a !important;
|
||||
}
|
||||
.board-color-belize.js-new-search:active {
|
||||
background-color: #0d334d !important;
|
||||
}
|
||||
/* The new-search button uses the .primary button style, so it follows the theme
|
||||
override colour (default blue #005377, or the board/global theme). The old
|
||||
hardcoded belize (#216694) rules were removed. */
|
||||
.global-search-operator {
|
||||
font-family: Courier;
|
||||
}
|
||||
|
||||
@@ -71,8 +71,8 @@ template(name="globalSearch")
|
||||
aria-label="{{_ 'globalSearch-title'}}"
|
||||
autofocus dir="auto"
|
||||
)
|
||||
button.js-new-search.board-color-belize(type="button" aria-label="{{_ 'globalSearch-title'}}" style="background: #216694 !important; background-color: #216694 !important; color: #fff !important; border: none !important;")
|
||||
span(aria-hidden="true") 🧹
|
||||
button.primary.js-new-search(type="submit" aria-label="{{_ 'trello-clear-job'}}")
|
||||
| {{_ 'trello-clear-job'}}
|
||||
if searching
|
||||
+spinner
|
||||
else if hasResults
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { Tracker } from 'meteor/tracker';
|
||||
import { Session } from 'meteor/session';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
|
||||
// #5778 + docs/Theme/Theme.md: apply the user's optional GLOBAL theme color to the
|
||||
// whole UI (All Boards, Search, Admin Panel, My Cards, etc.) via a `board-color-<name>`
|
||||
// class on <body>, and apply CUSTOM colors (flat = 1 accent, clear = 2 = a gradient)
|
||||
// as CSS variables consumed by customTheme.css.
|
||||
//
|
||||
// While viewing a single board, that board's own color owns the page, so we do NOT
|
||||
// put the global color class on <body> there; but we DO expose the ACTIVE context's
|
||||
// custom colors (the board's when on a board, the user's global override otherwise)
|
||||
// so custom-colored boards and the custom global theme both render.
|
||||
|
||||
Meteor.startup(() => {
|
||||
let appliedClass = null;
|
||||
|
||||
function applyClass(colorClass) {
|
||||
if (colorClass === appliedClass) return;
|
||||
try {
|
||||
if (appliedClass) document.body.classList.remove(appliedClass);
|
||||
if (colorClass) document.body.classList.add(colorClass);
|
||||
} catch (_) {
|
||||
// document.body may not exist yet in exotic embeddings; ignore.
|
||||
}
|
||||
appliedClass = colorClass;
|
||||
}
|
||||
|
||||
function applyCustom(custom) {
|
||||
try {
|
||||
const root = document.documentElement;
|
||||
const c1 = custom && custom[0];
|
||||
const c2 = custom && custom[1];
|
||||
if (c1) {
|
||||
root.style.setProperty('--theme-accent', c1);
|
||||
document.body.classList.add('has-custom-theme-color');
|
||||
} else {
|
||||
root.style.removeProperty('--theme-accent');
|
||||
document.body.classList.remove('has-custom-theme-color');
|
||||
}
|
||||
if (c2) {
|
||||
root.style.setProperty('--theme-accent-2', c2);
|
||||
document.body.classList.add('has-custom-theme-slide');
|
||||
} else {
|
||||
root.style.removeProperty('--theme-accent-2');
|
||||
document.body.classList.remove('has-custom-theme-slide');
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
Tracker.autorun(() => {
|
||||
const user = Meteor.user();
|
||||
const globalColor = user && user.profile && user.profile.globalThemeColor;
|
||||
|
||||
if (globalColor) {
|
||||
// #5778: a global override wins EVERYWHERE — including board pages. The header
|
||||
// and .board-wrapper also prefer it (see header.jade / boardBody.jade), so the
|
||||
// whole UI takes the chosen theme; unset it to return to per-board colors.
|
||||
applyClass(`board-color-${globalColor}`);
|
||||
applyCustom((user && user.profile && user.profile.globalThemeCustomColors) || []);
|
||||
return;
|
||||
}
|
||||
|
||||
// No global override: on a board expose that board's own custom colors (its color
|
||||
// class already lives on .board-wrapper/#header); off a board, apply nothing.
|
||||
const boardId = Session.get('currentBoard');
|
||||
const board = boardId ? ReactiveCache.getBoard(boardId) : null;
|
||||
applyClass(null);
|
||||
applyCustom((board && board.customThemeColors) || []);
|
||||
});
|
||||
});
|
||||
@@ -5,8 +5,16 @@
|
||||
z-index: 17;
|
||||
}
|
||||
#header #header-main-bar {
|
||||
height: 40px;
|
||||
padding: 7px 10px 0;
|
||||
/* 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,
|
||||
instead of drifting to the middle as floats wrap. */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
min-height: 40px;
|
||||
/* No right padding: the right gap is owned by the hamburger's margin so it can
|
||||
match the ~8px gap to the right of the top-bar user name. (top right bottom left) */
|
||||
padding: 7px 0 0 10px;
|
||||
}
|
||||
#header #header-main-bar h1 {
|
||||
font-size: 20px;
|
||||
@@ -39,15 +47,17 @@
|
||||
opacity: 0.9;
|
||||
}
|
||||
#header #header-main-bar .board-header-btns {
|
||||
display: block;
|
||||
/* Flex so the buttons inside each group lay out (their float is ignored inside a
|
||||
flex container) and the groups themselves are flex items of the header bar. */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 3px;
|
||||
width: auto;
|
||||
}
|
||||
#header #header-main-bar .board-header-btns.left {
|
||||
float: inline-start;
|
||||
}
|
||||
#header #header-main-bar .board-header-btns.right {
|
||||
float: inline-end;
|
||||
/* Always hug the right edge (and, if the row wraps, the right of its row). */
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
#header #header-main-bar .board-header-btn {
|
||||
border-radius: 3px;
|
||||
@@ -60,6 +70,15 @@
|
||||
line-height: 28px;
|
||||
margin: 0 12px;
|
||||
}
|
||||
/* The sidebar hamburger sits at the far right; drop its wide 12px side margins so
|
||||
there is no button-sized empty space to its left or right. The cog before it also
|
||||
drops its right margin so the two sit snugly together at the edge. */
|
||||
#header #header-main-bar .board-header-btn.js-toggle-sidebar {
|
||||
margin-inline-start: 2px;
|
||||
/* 8px to the right edge — the same small gap as to the right of the user name in
|
||||
the top bar (.header-user-bar-name margin-inline-end). */
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
#header #header-main-bar .board-header-btn i.fa {
|
||||
float: inline-start;
|
||||
display: block;
|
||||
|
||||
@@ -5,7 +5,7 @@ template(name="header")
|
||||
Reddit "subreddit" bar.
|
||||
The first link goes to the boards page.
|
||||
if currentUser
|
||||
#header-quick-access(class=currentBoard.colorClass role="navigation" aria-label="{{_ 'all-boards'}}")
|
||||
#header-quick-access(class="{{#if currentUser.globalThemeColorClass}}{{currentUser.globalThemeColorClass}}{{else}}{{currentBoard.colorClass}}{{/if}}" role="navigation" aria-label="{{_ 'all-boards'}}")
|
||||
// Home icon - always at left side of logo
|
||||
span.home-icon.allBoards
|
||||
a(href="{{pathFor 'home'}}")
|
||||
@@ -24,11 +24,13 @@ template(name="header")
|
||||
div#headerIsSettingDatabaseCallDone
|
||||
img(src="{{pathFor '/logo-header.png'}}" alt="{{currentSetting.productName}}" title="{{currentSetting.productName}}")
|
||||
|
||||
// Zoom controls - always visible
|
||||
.zoom-controls
|
||||
span.zoom-level.js-zoom-level-click(title="{{_ 'click-to-change-zoom'}}")
|
||||
span.zoom-display {{zoomLevel}}%
|
||||
input.zoom-input.js-zoom-input(type="number" value=zoomLevel min="50" max="300" step="10" style="display: none;")
|
||||
// Mobile/desktop mode toggle (swapped with zoom)
|
||||
.mobile-mode-toggle
|
||||
a.board-header-btn.js-mobile-mode-toggle(title="{{_ 'mobile-desktop-toggle'}}" class="{{#if mobileMode}}mobile-active{{else}}desktop-active{{/if}}")
|
||||
i.mobile-icon(class="{{#if mobileMode}}active{{/if}}")
|
||||
i.fa.fa-mobile
|
||||
i.desktop-icon(class="{{#unless mobileMode}}active{{/unless}}")
|
||||
i.fa.fa-desktop
|
||||
|
||||
// Drag handles toggle - between zoom and mobile mode toggle
|
||||
a.board-header-btn.js-toggle-desktop-drag-handles(title="{{_ 'show-desktop-drag-handles'}}")
|
||||
@@ -78,12 +80,11 @@ template(name="header")
|
||||
//
|
||||
i.fa.fa-plus(title="Create a new board")
|
||||
|
||||
.mobile-mode-toggle
|
||||
a.board-header-btn.js-mobile-mode-toggle(title="{{_ 'mobile-desktop-toggle'}}" class="{{#if mobileMode}}mobile-active{{else}}desktop-active{{/if}}")
|
||||
i.mobile-icon(class="{{#if mobileMode}}active{{/if}}")
|
||||
i.fa.fa-mobile
|
||||
i.desktop-icon(class="{{#unless mobileMode}}active{{/unless}}")
|
||||
i.fa.fa-desktop
|
||||
// Zoom controls (swapped with the mobile/desktop toggle)
|
||||
.zoom-controls
|
||||
span.zoom-level.js-zoom-level-click(title="{{_ 'click-to-change-zoom'}}")
|
||||
span.zoom-display {{zoomLevel}}%
|
||||
input.zoom-input.js-zoom-input(type="number" value=zoomLevel min="50" max="300" step="10" style="display: none;")
|
||||
|
||||
// Notifications
|
||||
+notifications
|
||||
@@ -95,7 +96,7 @@ template(name="header")
|
||||
|
||||
+headerUserBar
|
||||
|
||||
#header(class=currentBoard.colorClass)
|
||||
#header(class="{{#if currentUser.globalThemeColorClass}}{{currentUser.globalThemeColorClass}}{{else}}{{currentBoard.colorClass}}{{/if}}")
|
||||
//-
|
||||
The main bar is a colorful bar that provide all the meta-data for the
|
||||
current page. This bar is contextual based.
|
||||
|
||||
@@ -272,6 +272,27 @@ body.mobile-mode #content {
|
||||
float: inline-end;
|
||||
font-size: clamp(18px, 4vw, 24px);
|
||||
}
|
||||
/* Archived boards modal: full-width panel that extends to the bottom of the browser
|
||||
window (so archived boards show full width with maximum vertical space). It is the
|
||||
only modal that contains .archived-lists. */
|
||||
#modal .modal-content:has(.archived-lists) {
|
||||
width: 96vw;
|
||||
max-width: 96vw;
|
||||
/* The header bar(s) sit above the modal (higher z-index), so start the archive
|
||||
panel BELOW them — otherwise its top (and the close X) is hidden under the top
|
||||
header bar. ~96px clears both the quick-access bar and the board header bar; on
|
||||
the All Boards page (one bar) this leaves a small top gap. Then extend to the
|
||||
bottom of the browser window. */
|
||||
margin: 96px auto 0;
|
||||
min-height: calc(100vh - 106px);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
/* Each archived board is a full-width row. */
|
||||
#modal .modal-content .archived-lists,
|
||||
#modal .modal-content .archived-lists .archived-lists-item {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
/* #6465: heading sizes back to the 6.09 values (22/18/16px) — the vw clamps
|
||||
resolved to 24/20/18px on desktop and compounded the density regression. */
|
||||
h1 {
|
||||
|
||||
@@ -171,6 +171,12 @@
|
||||
overflow: visible;
|
||||
height: auto;
|
||||
flex: 1;
|
||||
/* Auto-filling columns so as many languages as possible are visible at once when
|
||||
there is browser width; collapses to a single column on narrow windows. */
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
|
||||
align-content: start;
|
||||
column-gap: 4px;
|
||||
}
|
||||
|
||||
/* Ensure content div in language popup contains all items */
|
||||
@@ -197,8 +203,10 @@
|
||||
top: auto !important;
|
||||
inset-inline-start: auto !important;
|
||||
inset-inline-end: 20px !important;
|
||||
width: auto !important;
|
||||
max-width: 450px !important;
|
||||
/* Wide enough for multiple language columns when the browser is wide, but never
|
||||
wider than ~90% of the viewport (falls back to a single narrow column). */
|
||||
width: min(90vw, 1100px) !important;
|
||||
max-width: 90vw !important;
|
||||
height: 100vh !important;
|
||||
max-height: 100vh !important;
|
||||
min-height: 300px !important;
|
||||
|
||||
@@ -9,7 +9,13 @@ Popup.template.events({
|
||||
Popup.close();
|
||||
},
|
||||
'click .js-confirm'() {
|
||||
this.__afterConfirmAction.call(this);
|
||||
// #6479: the action is stored on the Popup instance (see Popup.afterConfirm),
|
||||
// not on this data context, so a re-rendered/immutable context can't lose it.
|
||||
// Fall back to the legacy per-context field for any external caller.
|
||||
const action = Popup._afterConfirmAction || this.__afterConfirmAction;
|
||||
if (typeof action === 'function') {
|
||||
action.call(this);
|
||||
}
|
||||
},
|
||||
// #5942: On mobile/touch, tapping inside the card-detail sub-popups (assign
|
||||
// user / set due date) made the popup DISAPPEAR. The document-level
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Template } from 'meteor/templating';
|
||||
|
||||
const { cleanFileName, sanitizeDownloadFileName } = require('/imports/lib/fileNameDisplay');
|
||||
|
||||
// Global helpers for showing filenames safely EVERYWHERE (card attachments, admin
|
||||
// panel, etc.). No template needed — a filename is always shown as a plain, clean
|
||||
// string:
|
||||
// {{cleanFilename name}} -> URL-decoded, invisible chars removed, exploit
|
||||
// markup (HTML/JS/XML) removed, whitespace collapsed
|
||||
// {{downloadFilename name}} -> the same, but never empty (falls back to "download")
|
||||
if (!Template.__safeFilenameHelpersRegistered) {
|
||||
Template.registerHelper('cleanFilename', name => cleanFileName(name));
|
||||
Template.registerHelper('downloadFilename', name => sanitizeDownloadFileName(name));
|
||||
Template.__safeFilenameHelpersRegistered = true;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//- Shared "Select Color" picker (docs/Theme/Theme.md), used by both Board Settings
|
||||
and Member Settings. Colors are shown as VISIBLE swatches grouped by category, with
|
||||
the category name as a header above each group; the native color wheel(s) appear
|
||||
for the flat (1) / clear (2) categories once such a color is selected. `data.scope`
|
||||
is 'board' or 'global'.
|
||||
template(name="themeColorPicker")
|
||||
form.theme-color-picker.edit-label
|
||||
if isGlobal
|
||||
ul.pop-over-list
|
||||
li(class="{{#if isNoneSelected}}active{{/if}}")
|
||||
a.js-theme-none
|
||||
i.fa.fa-ban
|
||||
| {{_ 'theme-default'}}
|
||||
if isNoneSelected
|
||||
i.fa.fa-check
|
||||
each themeGroups
|
||||
.theme-category-group
|
||||
.theme-category-label {{label}}
|
||||
.board-backgrounds-list.clearfix
|
||||
each colors
|
||||
.board-background-select.js-select-theme(data-color="{{name}}")
|
||||
span.background-box(class="board-color-{{name}}")
|
||||
span {{name}}
|
||||
if selected
|
||||
i.fa.fa-check
|
||||
if showCustom
|
||||
.color-wheel-wrap
|
||||
label {{_ 'custom-color'}}
|
||||
each customWheels
|
||||
input.js-theme-wheel(type="color" data-index="{{index}}" value="{{value}}" title="{{_ 'custom-color'}}")
|
||||
.theme-preview
|
||||
span.background-box(class="{{previewClass}}" style="{{previewStyle}}")
|
||||
@@ -0,0 +1,166 @@
|
||||
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 { BOARD_COLORS } from '/models/metadata/colors';
|
||||
import {
|
||||
THEME_CATEGORY_ORDER,
|
||||
categoryOf,
|
||||
colorsInCategory,
|
||||
allowsCustomColor,
|
||||
customColorCount,
|
||||
} from '/models/lib/themeCategories';
|
||||
|
||||
// Shared "Select Color" picker (docs/Theme/Theme.md): VISIBLE color swatches grouped
|
||||
// by category (with the category name above each group) + native color wheel(s) for
|
||||
// the flat/clear categories once such a color is selected. data.scope is 'board'
|
||||
// (writes board.color) or 'global' (writes the user's #5778 global override).
|
||||
|
||||
const DEFAULT_WHEEL = ['#2980b9', '#6dd5fa']; // stock flat accent / second slide stop
|
||||
|
||||
function readCurrent(scope) {
|
||||
if (scope === 'board') {
|
||||
const b = Utils.getCurrentBoard();
|
||||
return { color: (b && b.color) || BOARD_COLORS[0], custom: (b && b.customThemeColors) || [] };
|
||||
}
|
||||
const u = ReactiveCache.getCurrentUser();
|
||||
return {
|
||||
color: (u && u.getGlobalThemeColor && u.getGlobalThemeColor()) || null,
|
||||
custom: (u && u.getGlobalThemeCustomColors && u.getGlobalThemeCustomColors()) || [],
|
||||
};
|
||||
}
|
||||
|
||||
Template.themeColorPicker.onCreated(function () {
|
||||
const scope = this.data && this.data.scope === 'board' ? 'board' : 'global';
|
||||
const cur = readCurrent(scope);
|
||||
this.scope = scope;
|
||||
this.color = new ReactiveVar(cur.color); // null for global = no override
|
||||
this.customColors = new ReactiveVar((cur.custom || []).slice());
|
||||
});
|
||||
|
||||
Template.themeColorPicker.helpers({
|
||||
isGlobal() {
|
||||
return Template.instance().scope === 'global';
|
||||
},
|
||||
isNoneSelected() {
|
||||
const tpl = Template.instance();
|
||||
return tpl.scope === 'global' && !tpl.color.get();
|
||||
},
|
||||
// Visible swatches grouped by category, each group labelled with its category name.
|
||||
themeGroups() {
|
||||
const cur = Template.instance().color.get();
|
||||
return THEME_CATEGORY_ORDER.map(key => ({
|
||||
key,
|
||||
label: TAPi18n.__(`theme-category-${key}`),
|
||||
colors: colorsInCategory(key).map(name => ({ name, selected: name === cur })),
|
||||
}));
|
||||
},
|
||||
showCustom() {
|
||||
const cur = Template.instance().color.get();
|
||||
return cur ? allowsCustomColor(categoryOf(cur)) : false;
|
||||
},
|
||||
customWheels() {
|
||||
const tpl = Template.instance();
|
||||
const cur = tpl.color.get();
|
||||
const n = cur ? customColorCount(categoryOf(cur)) : 0;
|
||||
const cc = tpl.customColors.get();
|
||||
const wheels = [];
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
wheels.push({ index: i, value: toHex(cc[i]) || DEFAULT_WHEEL[i] || '#0079bf' });
|
||||
}
|
||||
return wheels;
|
||||
},
|
||||
previewClass() {
|
||||
const color = Template.instance().color.get();
|
||||
return color ? `board-color-${color}` : '';
|
||||
},
|
||||
// Inline preview of custom colors (the stock board-color-* CSS can't show arbitrary
|
||||
// colors until the CSS-variable refactor; the preview swatch shows them directly).
|
||||
previewStyle() {
|
||||
const tpl = Template.instance();
|
||||
const cur = tpl.color.get();
|
||||
const cat = cur ? categoryOf(cur) : null;
|
||||
if (!cat || !allowsCustomColor(cat)) return '';
|
||||
const cc = tpl.customColors.get();
|
||||
if (!cc.some(Boolean)) return '';
|
||||
if (customColorCount(cat) === 2 && cc[0] && cc[1]) {
|
||||
return `background: linear-gradient(135deg, ${cc[0]}, ${cc[1]}) !important;`;
|
||||
}
|
||||
if (cc[0]) return `background: ${cc[0]} !important;`;
|
||||
return '';
|
||||
},
|
||||
});
|
||||
|
||||
// Collect the custom colors to save: [] unless the user actually engaged a wheel, in
|
||||
// which case read every wheel's current value (untouched ones contribute their shown
|
||||
// default) so the result is a complete set of the category's expected count.
|
||||
function gatherCustom(tpl) {
|
||||
const color = tpl.color.get();
|
||||
const cat = color ? categoryOf(color) : null;
|
||||
if (!cat || !allowsCustomColor(cat)) return [];
|
||||
if (!tpl.customColors.get().some(Boolean)) return [];
|
||||
const n = customColorCount(cat);
|
||||
const wheels = Array.from(tpl.findAll('.js-theme-wheel'))
|
||||
.map(w => w.value)
|
||||
.filter(isHexColor)
|
||||
.slice(0, n);
|
||||
return wheels.length === n ? wheels : [];
|
||||
}
|
||||
|
||||
// Apply the current selection immediately (no Save button): board scope writes
|
||||
// board.color, global scope calls setGlobalThemeColor. `color`/`custom` are read
|
||||
// from the reactive state so the same helper serves the swatch click and the wheel.
|
||||
function applySelection(tpl) {
|
||||
const color = tpl.color.get() || colorsInCategory(THEME_CATEGORY_ORDER[0])[0];
|
||||
const custom = gatherCustom(tpl);
|
||||
if (tpl.scope === 'board') {
|
||||
const b = Utils.getCurrentBoard();
|
||||
if (b) {
|
||||
Promise.resolve(b.setColor(color, custom)).catch(e => {
|
||||
if (process.env.DEBUG === 'true') console.error('board setColor error', e);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
Meteor.call('setGlobalThemeColor', color, custom, err => {
|
||||
if (err && process.env.DEBUG === 'true') console.error('setGlobalThemeColor error', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Template.themeColorPicker.events({
|
||||
// Clicking a color swatch applies that theme IMMEDIATELY (there is no Save button).
|
||||
'click .js-select-theme'(event, tpl) {
|
||||
const color = event.currentTarget.dataset.color;
|
||||
if (!color) return;
|
||||
tpl.color.set(color);
|
||||
tpl.customColors.set([]); // a fresh theme -> its stock colors
|
||||
applySelection(tpl);
|
||||
},
|
||||
// Live-update the preview while dragging the wheel...
|
||||
'input .js-theme-wheel'(event, tpl) {
|
||||
const idx = parseInt(event.currentTarget.dataset.index, 10);
|
||||
const val = event.currentTarget.value;
|
||||
if (!isHexColor(val)) return;
|
||||
const cc = tpl.customColors.get().slice();
|
||||
cc[idx] = val;
|
||||
tpl.customColors.set(cc);
|
||||
},
|
||||
// ...and apply the custom color when the wheel is committed (avoids spamming the
|
||||
// server on every intermediate value during the drag).
|
||||
'change .js-theme-wheel'(event, tpl) {
|
||||
applySelection(tpl);
|
||||
},
|
||||
// Clear the global override (Default) — applies immediately too.
|
||||
'click .js-theme-none'(event, tpl) {
|
||||
event.preventDefault();
|
||||
tpl.color.set(null);
|
||||
tpl.customColors.set([]);
|
||||
if (tpl.scope === 'board') {
|
||||
const b = Utils.getCurrentBoard();
|
||||
if (b) b.setColor(BOARD_COLORS[0], []);
|
||||
} else {
|
||||
Meteor.call('setGlobalThemeColor', null, null);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
/* Member Settings / Font (#4759): apply the user's chosen UI font. The class lives
|
||||
* on <body> and the --wekan-ui-font variable is set by uiFont.js only for a
|
||||
* validated, whitelisted font. Form controls are listed explicitly because their
|
||||
* UA default font-family does not inherit. Icon fonts (Font Awesome etc.) set their
|
||||
* own font-family on the element, so they are unaffected. */
|
||||
body.has-ui-font,
|
||||
body.has-ui-font button,
|
||||
body.has-ui-font input,
|
||||
body.has-ui-font textarea,
|
||||
body.has-ui-font select,
|
||||
body.has-ui-font .wekan-form-control {
|
||||
font-family: var(--wekan-ui-font) !important;
|
||||
}
|
||||
|
||||
/* Font size preset (#4759): scale the root font size. Elements using relative units
|
||||
* (em/rem/%) scale with it; the variable is a percentage set by uiFont.js only for a
|
||||
* known preset. 'default'/unset removes it. */
|
||||
body.has-ui-font-size {
|
||||
font-size: var(--wekan-ui-font-size) !important;
|
||||
}
|
||||
|
||||
/* Text color (#4759): the variable is a validated hex set by uiFont.js. Icon fonts
|
||||
* keep their own color rules; this recolors ordinary UI text + form controls. */
|
||||
body.has-ui-text-color,
|
||||
body.has-ui-text-color button,
|
||||
body.has-ui-text-color input,
|
||||
body.has-ui-text-color textarea,
|
||||
body.has-ui-text-color select,
|
||||
body.has-ui-text-color .wekan-form-control {
|
||||
color: var(--wekan-ui-text-color) !important;
|
||||
}
|
||||
|
||||
/* Text background color (#4759). */
|
||||
body.has-ui-bg-color,
|
||||
body.has-ui-bg-color input,
|
||||
body.has-ui-bg-color textarea,
|
||||
body.has-ui-bg-color select,
|
||||
body.has-ui-bg-color .wekan-form-control {
|
||||
background-color: var(--wekan-ui-bg-color) !important;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { Tracker } from 'meteor/tracker';
|
||||
import { fontFamilyValue, fontSizeValue, colorValue } from '/models/lib/uiFonts';
|
||||
|
||||
// #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
|
||||
// consumes them). fontFamilyValue()/fontSizeValue() return '' for anything not on the
|
||||
// curated whitelist/presets, so only known, safe values can ever reach the DOM — the
|
||||
// saved values are also validated server-side. Unset => nothing applied (defaults).
|
||||
|
||||
Meteor.startup(() => {
|
||||
function toggle(varName, className, value) {
|
||||
try {
|
||||
if (value) {
|
||||
document.documentElement.style.setProperty(varName, value);
|
||||
document.body.classList.add(className);
|
||||
} else {
|
||||
document.documentElement.style.removeProperty(varName);
|
||||
document.body.classList.remove(className);
|
||||
}
|
||||
} catch (_) {
|
||||
// document not ready in exotic embeddings; ignore.
|
||||
}
|
||||
}
|
||||
|
||||
Tracker.autorun(() => {
|
||||
const user = Meteor.user();
|
||||
const profile = (user && user.profile) || {};
|
||||
toggle('--wekan-ui-font', 'has-ui-font', fontFamilyValue(profile.uiFont));
|
||||
toggle('--wekan-ui-font-size', 'has-ui-font-size', fontSizeValue(profile.uiFontSize));
|
||||
toggle('--wekan-ui-text-color', 'has-ui-text-color', colorValue(profile.uiTextColor));
|
||||
toggle('--wekan-ui-bg-color', 'has-ui-bg-color', colorValue(profile.uiTextBgColor));
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,8 @@ section#notifications-drawer {
|
||||
position: fixed;
|
||||
top: 48px;
|
||||
inset-inline-end: 0;
|
||||
width: 400px;
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
background-color: #fafafa;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.15);
|
||||
border-radius: 2px;
|
||||
@@ -17,19 +18,20 @@ section#notifications-drawer .header {
|
||||
position: fixed;
|
||||
top: 48px;
|
||||
inset-inline-end: 0;
|
||||
width: calc(400px - 32px);
|
||||
width: 100vw;
|
||||
box-sizing: border-box;
|
||||
padding: 8px 16px;
|
||||
background: #ededed;
|
||||
border-bottom: 1px solid #dbdbdb;
|
||||
z-index: 2;
|
||||
/* Flex row: hamburger (left), title (centre), X (right), all vertically centred. */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
section#notifications-drawer .header .notification-menu-toggle {
|
||||
position: absolute;
|
||||
inset-inline-start: 16px;
|
||||
top: calc(50% - 12px);
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
color: #333;
|
||||
color: #000 !important; /* black hamburger menu (override themed white link colour) */
|
||||
line-height: 24px;
|
||||
}
|
||||
section#notifications-drawer .header .notification-menu-toggle:hover {
|
||||
@@ -83,18 +85,33 @@ section#notifications-drawer .header .notification-menu .menu-item .menu-icon {
|
||||
margin-inline-end: 8px;
|
||||
font-size: 16px;
|
||||
}
|
||||
/* Menu-item icons (envelope / copy / trash / check) black. */
|
||||
section#notifications-drawer .header .notification-menu .menu-item .menu-icon,
|
||||
section#notifications-drawer .header .notification-menu .menu-item .menu-icon i.fa,
|
||||
section#notifications-drawer .header .notification-menu .menu-item .check-icon,
|
||||
section#notifications-drawer .header .notification-menu .menu-item .check-icon i.fa {
|
||||
color: #000 !important;
|
||||
}
|
||||
section#notifications-drawer .header h5 {
|
||||
flex: 1; /* fill the middle so the title is centred, X sits at the right */
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
section#notifications-drawer .header .close {
|
||||
position: absolute;
|
||||
top: calc(50% - 12px);
|
||||
inset-inline-end: 12px;
|
||||
/* Flex item at the right edge, aligned with the title. position: static cancels
|
||||
the GLOBAL `.close { position: absolute; top: 5px }` rule (boardBody.css) that
|
||||
otherwise pulls the X out of the header row and drops it below the title. */
|
||||
position: static;
|
||||
margin-inline-start: 8px;
|
||||
font-size: 24px;
|
||||
height: 24px;
|
||||
line-height: 24px;
|
||||
opacity: 1;
|
||||
color: #000 !important; /* black X (override themed white link colour) */
|
||||
}
|
||||
section#notifications-drawer .header .close i.fa,
|
||||
section#notifications-drawer .header .notification-menu-toggle i.fa {
|
||||
color: inherit !important;
|
||||
}
|
||||
|
||||
section#notifications-drawer ul.notifications {
|
||||
|
||||
@@ -30,14 +30,14 @@ template(name="rulesList")
|
||||
else
|
||||
= title
|
||||
div.rules-btns-group
|
||||
button.primary.wide.js-goto-details(type="submit")
|
||||
button.primary.wide.js-goto-details(type="submit" data-rule-id="{{_id}}")
|
||||
i.fa.fa-eye
|
||||
| {{_ 'r-view-rule'}}
|
||||
if canAdmin
|
||||
button.primary.wide.js-edit-rule(type="submit")
|
||||
button.primary.wide.js-edit-rule(type="submit" data-rule-id="{{_id}}")
|
||||
i.fa.fa-pencil
|
||||
| {{_ 'r-edit-rule'}}
|
||||
button.primary.wide.js-delete-rule(type="submit")
|
||||
button.primary.wide.js-delete-rule(type="submit" data-rule-id="{{_id}}")
|
||||
i.fa.fa-trash
|
||||
| {{_ 'r-delete-rule'}}
|
||||
else
|
||||
|
||||
@@ -78,14 +78,20 @@ function sanitizeObject(obj) {
|
||||
}
|
||||
|
||||
Template.rulesMain.events({
|
||||
'click .js-delete-rule'() {
|
||||
const rule = Template.currentData();
|
||||
'click .js-delete-rule'(event) {
|
||||
// #6490: the button lives in the rulesList child template's `each`, but this
|
||||
// handler is on the parent rulesMain, so Template.currentData() returned the
|
||||
// PARENT's data context (not the clicked rule) — the rule id resolved to null,
|
||||
// so delete failed server-side with "Match error: Expected string, got null".
|
||||
// Read the rule id from the button's explicit data-rule-id instead.
|
||||
const ruleId = event.currentTarget.getAttribute('data-rule-id');
|
||||
if (!ruleId) return;
|
||||
// Delete the rule + its trigger + action in one server call. Doing this
|
||||
// client-side as three separate Collection.remove() calls failed with 403
|
||||
// "Access denied" whenever a trigger/action document's boardId did not
|
||||
// resolve to a board in the allow() rule; the method authorizes once and
|
||||
// removes all three server-side.
|
||||
Meteor.call('rules.deleteRule', rule._id);
|
||||
Meteor.call('rules.deleteRule', ruleId);
|
||||
},
|
||||
'click .js-goto-trigger'(event, tpl) {
|
||||
event.preventDefault();
|
||||
@@ -141,8 +147,12 @@ Template.rulesMain.events({
|
||||
},
|
||||
'click .js-goto-details'(event, tpl) {
|
||||
event.preventDefault();
|
||||
const rule = Template.currentData();
|
||||
tpl.ruleId.set(rule._id);
|
||||
// #6490: same as delete — Template.currentData() returned the parent context
|
||||
// here, so "View rule" always opened the same (first) rule regardless of which
|
||||
// one was clicked. Read the clicked rule's id from its data-rule-id.
|
||||
const ruleId = event.currentTarget.getAttribute('data-rule-id');
|
||||
if (!ruleId) return;
|
||||
tpl.ruleId.set(ruleId);
|
||||
tpl.rulesCurrentTab.set('ruleDetails');
|
||||
},
|
||||
});
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { TAPi18n } from '/imports/i18n';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import Actions from '/models/actions';
|
||||
import Rules from '/models/rules';
|
||||
|
||||
@@ -31,15 +31,7 @@ template(name="adminFeatures")
|
||||
ul.setting-detail
|
||||
li.layout-form
|
||||
.title {{_ 'cards-loading'}}
|
||||
.form-group
|
||||
select#cards-loading.js-cards-loading
|
||||
each cardsLoadingOptions
|
||||
if this.selected
|
||||
option(value="{{value}}" selected) {{_ label}}
|
||||
else
|
||||
option(value="{{value}}") {{_ label}}
|
||||
p.description {{_ 'cards-loading-description'}}
|
||||
p.description {{_ 'cards-loading-lazy-note'}}
|
||||
if isSecurityActive
|
||||
ul.setting-detail
|
||||
li
|
||||
|
||||
@@ -21,13 +21,6 @@ Template.adminFeatures.helpers({
|
||||
isNotificationsActive() {
|
||||
return Template.instance().activePane.get() === 'notifications';
|
||||
},
|
||||
cardsLoadingOptions() {
|
||||
const current = (ReactiveCache.getCurrentSetting() || {}).cardsLoading || 'all';
|
||||
return [
|
||||
{ value: 'all', label: 'cards-loading-all', selected: current === 'all' },
|
||||
{ value: 'lazy', label: 'cards-loading-lazy', selected: current === 'lazy' },
|
||||
];
|
||||
},
|
||||
renderLinksAsPlainText() {
|
||||
return (ReactiveCache.getCurrentSetting() || {}).renderLinksAsPlainText;
|
||||
},
|
||||
@@ -75,13 +68,6 @@ Template.adminFeatures.events({
|
||||
'click .js-features-menu'(event, tpl) {
|
||||
tpl.activePane.set(event.currentTarget.dataset.id);
|
||||
},
|
||||
'change .js-cards-loading'(event) {
|
||||
const value = event.currentTarget.value === 'lazy' ? 'lazy' : 'all';
|
||||
const setting = ReactiveCache.getCurrentSetting();
|
||||
if (setting) {
|
||||
Settings.update(setting._id, { $set: { cardsLoading: value } });
|
||||
}
|
||||
},
|
||||
'click .js-toggle-render-links-as-plain-text'() {
|
||||
toggleSettingField('renderLinksAsPlainText');
|
||||
},
|
||||
|
||||
@@ -1,60 +1,51 @@
|
||||
/* Search + pagination controls for the admin report tabs (files, rules,
|
||||
boards, cards). Styled to match the admin People panel (peopleBody.css). */
|
||||
|
||||
.admin-report-search {
|
||||
/* One controls row per report: the search field on the left, the pagination
|
||||
controls pushed to the right (margin-inline-start:auto mirrors automatically in
|
||||
RTL). No separate Search button — typing + Enter searches. The controls use the
|
||||
theme accent (Member Settings → Change color sets --theme-accent on :root),
|
||||
falling back to WeKan blue when no custom theme is chosen. */
|
||||
.admin-report-controls {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 34px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.admin-report-search input {
|
||||
flex: 1;
|
||||
.admin-report-controls input {
|
||||
flex: 0 1 320px;
|
||||
max-width: 320px;
|
||||
padding: 0 8px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.admin-report-search button {
|
||||
min-width: 90px;
|
||||
height: 34px;
|
||||
background: #01628c;
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 0.4vw;
|
||||
cursor: pointer;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.admin-report-search button:hover {
|
||||
background: #005377;
|
||||
}
|
||||
|
||||
.admin-report-search button:active {
|
||||
background: #004766;
|
||||
}
|
||||
|
||||
.admin-report-pagination {
|
||||
margin-inline-start: auto; /* push pagination to the end of the controls row */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.admin-report-pagination button {
|
||||
min-width: 32px;
|
||||
height: 30px;
|
||||
border: 1px solid #bbb;
|
||||
background: #fff;
|
||||
border: 1px solid var(--theme-accent, #01628c);
|
||||
background: transparent;
|
||||
color: var(--theme-accent, #01628c);
|
||||
border-radius: 0.4vw;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-report-pagination button:hover:not(.disabled) {
|
||||
background: var(--theme-accent, #01628c);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.admin-report-pagination button.active {
|
||||
background: #01628c;
|
||||
border-color: #01628c;
|
||||
background: var(--theme-accent, #01628c);
|
||||
border-color: var(--theme-accent, #01628c);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@@ -66,5 +57,45 @@
|
||||
.admin-report-page-info {
|
||||
min-width: 72px;
|
||||
text-align: center;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* Problems page: menu separator between the event streams (Summary/Security/
|
||||
Speed/Tests) and the moved report items, and the read-only event table. */
|
||||
.admin-reports-content .side-menu .admin-reports-separator hr {
|
||||
margin: 6px 0;
|
||||
border: 0;
|
||||
border-top: 1px solid rgba(0,0,0,0.15);
|
||||
}
|
||||
.admin-event-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.admin-event-controls input {
|
||||
flex: 0 1 320px;
|
||||
max-width: 320px;
|
||||
height: 34px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
.admin-event-controls .admin-event-pagination {
|
||||
margin-inline-start: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.admin-event-controls .admin-event-pagination a {
|
||||
cursor: pointer;
|
||||
color: var(--theme-accent, #01628c);
|
||||
font-weight: bold;
|
||||
padding: 0 6px;
|
||||
}
|
||||
.admin-event-controls .admin-event-pagination a.disabled { opacity: 0.4; pointer-events: none; }
|
||||
table.admin-event-table { width: 100%; border-collapse: collapse; }
|
||||
table.admin-event-table th, table.admin-event-table td {
|
||||
text-align: start; padding: 4px 8px; border-bottom: 1px solid rgba(0,0,0,0.08); vertical-align: top;
|
||||
}
|
||||
table.admin-event-table .admin-event-at { white-space: nowrap; }
|
||||
table.admin-event-table .admin-event-sev[data-sev="high"],
|
||||
table.admin-event-table .admin-event-sev[data-sev="critical"] { color: #c0392b; font-weight: bold; }
|
||||
table.admin-event-table .admin-event-detail { word-break: break-word; }
|
||||
@@ -6,6 +6,34 @@ template(name="adminReports")
|
||||
.content-body
|
||||
.side-menu
|
||||
ul
|
||||
li
|
||||
a.js-report-summary(data-id="report-summary")
|
||||
i.fa.fa-list
|
||||
| {{_ 'summary'}}
|
||||
|
||||
li
|
||||
a.js-report-security(data-id="report-security")
|
||||
i.fa.fa-shield
|
||||
| {{_ 'securityReportTitle'}}
|
||||
|
||||
li
|
||||
a.js-report-speed(data-id="report-speed")
|
||||
i.fa.fa-tachometer
|
||||
| {{_ 'speedReportTitle'}}
|
||||
|
||||
li
|
||||
a.js-report-tests(data-id="report-tests")
|
||||
i.fa.fa-flask
|
||||
| {{_ 'testsReportTitle'}}
|
||||
|
||||
li
|
||||
a.js-report-cpu(data-id="report-cpu")
|
||||
i.fa.fa-tachometer
|
||||
| {{_ 'cpuReportTitle'}}
|
||||
|
||||
li.admin-reports-separator
|
||||
hr
|
||||
|
||||
li
|
||||
a.js-report-broken(data-id="report-broken")
|
||||
i.fa.fa-chain-broken
|
||||
@@ -39,6 +67,16 @@ template(name="adminReports")
|
||||
.main-body
|
||||
if loading.get
|
||||
+spinner
|
||||
else if showSummary.get
|
||||
+problemsSummary
|
||||
else if showSecurity.get
|
||||
+eventStreamReport(stream="security")
|
||||
else if showSpeed.get
|
||||
+eventStreamReport(stream="speed")
|
||||
else if showTests.get
|
||||
+eventStreamReport(stream="tests")
|
||||
else if showCpu.get
|
||||
+eventStreamReport(stream="cpu")
|
||||
else if showBrokenCardsReport.get
|
||||
+brokenCardsReport
|
||||
else if showFilesReport.get
|
||||
@@ -63,17 +101,14 @@ template(name="brokenCardsReport")
|
||||
|
||||
template(name="rulesReport")
|
||||
h1 {{_ 'rulesReportTitle'}}
|
||||
.admin-report-search
|
||||
input.js-rules-search-input(type="text" placeholder="{{_ 'search'}}")
|
||||
button.js-rules-search-button
|
||||
i.fa.fa-search
|
||||
| {{_ 'search'}}
|
||||
.admin-report-pagination
|
||||
button.js-rules-prev-page(class="{{#if hasPrevPage}}{{else}}disabled{{/if}}")
|
||||
| <
|
||||
span.admin-report-page-info {{currentPage}} / {{totalPages}}
|
||||
button.js-rules-next-page(class="{{#if hasNextPage}}{{else}}disabled{{/if}}")
|
||||
| >
|
||||
.admin-report-controls
|
||||
input.js-rules-search-input(type="search" placeholder="{{_ 'search'}}")
|
||||
.admin-report-pagination
|
||||
button.js-rules-prev-page(class="{{#if hasPrevPage}}{{else}}disabled{{/if}}")
|
||||
| <
|
||||
span.admin-report-page-info {{currentPage}} / {{totalPages}}
|
||||
button.js-rules-next-page(class="{{#if hasNextPage}}{{else}}disabled{{/if}}")
|
||||
| >
|
||||
if resultsCount
|
||||
table
|
||||
thead
|
||||
@@ -95,17 +130,14 @@ template(name="rulesReport")
|
||||
|
||||
template(name="filesReport")
|
||||
h1 {{_ 'filesReportTitle'}}
|
||||
.admin-report-search
|
||||
input.js-files-search-input(type="text" placeholder="{{_ 'search'}}")
|
||||
button.js-files-search-button
|
||||
i.fa.fa-search
|
||||
| {{_ 'search'}}
|
||||
.admin-report-pagination
|
||||
button.js-files-prev-page(class="{{#if hasPrevPage}}{{else}}disabled{{/if}}")
|
||||
| <
|
||||
span.admin-report-page-info {{currentPage}} / {{totalPages}}
|
||||
button.js-files-next-page(class="{{#if hasNextPage}}{{else}}disabled{{/if}}")
|
||||
| >
|
||||
.admin-report-controls
|
||||
input.js-files-search-input(type="search" placeholder="{{_ 'search'}}")
|
||||
.admin-report-pagination
|
||||
button.js-files-prev-page(class="{{#if hasPrevPage}}{{else}}disabled{{/if}}")
|
||||
| <
|
||||
span.admin-report-page-info {{currentPage}} / {{totalPages}}
|
||||
button.js-files-next-page(class="{{#if hasNextPage}}{{else}}disabled{{/if}}")
|
||||
| >
|
||||
if resultsCount
|
||||
table
|
||||
thead
|
||||
@@ -120,7 +152,7 @@ template(name="filesReport")
|
||||
each att in results
|
||||
tbody
|
||||
tr
|
||||
td {{ att.name }}
|
||||
td {{ cleanFilename att.name }}
|
||||
td.right {{ fileSize att.size }}
|
||||
td {{ att.type }}
|
||||
td {{ att._id }}
|
||||
@@ -131,17 +163,14 @@ template(name="filesReport")
|
||||
|
||||
template(name="cardsReport")
|
||||
h1 {{_ 'cardsReportTitle'}}
|
||||
.admin-report-search
|
||||
input.js-cards-search-input(type="text" placeholder="{{_ 'search'}}")
|
||||
button.js-cards-search-button
|
||||
i.fa.fa-search
|
||||
| {{_ 'search'}}
|
||||
.admin-report-pagination
|
||||
button.js-cards-prev-page(class="{{#if hasPrevPage}}{{else}}disabled{{/if}}")
|
||||
| <
|
||||
span.admin-report-page-info {{currentPage}} / {{totalPages}}
|
||||
button.js-cards-next-page(class="{{#if hasNextPage}}{{else}}disabled{{/if}}")
|
||||
| >
|
||||
.admin-report-controls
|
||||
input.js-cards-search-input(type="search" placeholder="{{_ 'search'}}")
|
||||
.admin-report-pagination
|
||||
button.js-cards-prev-page(class="{{#if hasPrevPage}}{{else}}disabled{{/if}}")
|
||||
| <
|
||||
span.admin-report-page-info {{currentPage}} / {{totalPages}}
|
||||
button.js-cards-next-page(class="{{#if hasNextPage}}{{else}}disabled{{/if}}")
|
||||
| >
|
||||
if resultsCount
|
||||
table.table
|
||||
thead
|
||||
@@ -167,17 +196,14 @@ template(name="cardsReport")
|
||||
|
||||
template(name="impersonationReport")
|
||||
h1 {{_ 'impersonationReportTitle'}}
|
||||
.admin-report-search
|
||||
input.js-impersonation-search-input(type="text" placeholder="{{_ 'search'}}")
|
||||
button.js-impersonation-search-button
|
||||
i.fa.fa-search
|
||||
| {{_ 'search'}}
|
||||
.admin-report-pagination
|
||||
button.js-impersonation-prev-page(class="{{#if hasPrevPage}}{{else}}disabled{{/if}}")
|
||||
| <
|
||||
span.admin-report-page-info {{currentPage}} / {{totalPages}}
|
||||
button.js-impersonation-next-page(class="{{#if hasNextPage}}{{else}}disabled{{/if}}")
|
||||
| >
|
||||
.admin-report-controls
|
||||
input.js-impersonation-search-input(type="search" placeholder="{{_ 'search'}}")
|
||||
.admin-report-pagination
|
||||
button.js-impersonation-prev-page(class="{{#if hasPrevPage}}{{else}}disabled{{/if}}")
|
||||
| <
|
||||
span.admin-report-page-info {{currentPage}} / {{totalPages}}
|
||||
button.js-impersonation-next-page(class="{{#if hasNextPage}}{{else}}disabled{{/if}}")
|
||||
| >
|
||||
if resultsCount
|
||||
table.table
|
||||
thead
|
||||
@@ -204,17 +230,14 @@ template(name="impersonationReport")
|
||||
|
||||
template(name="boardsReport")
|
||||
h1 {{_ 'boardsReportTitle'}}
|
||||
.admin-report-search
|
||||
input.js-boards-search-input(type="text" placeholder="{{_ 'search'}}")
|
||||
button.js-boards-search-button
|
||||
i.fa.fa-search
|
||||
| {{_ 'search'}}
|
||||
.admin-report-pagination
|
||||
button.js-boards-prev-page(class="{{#if hasPrevPage}}{{else}}disabled{{/if}}")
|
||||
| <
|
||||
span.admin-report-page-info {{currentPage}} / {{totalPages}}
|
||||
button.js-boards-next-page(class="{{#if hasNextPage}}{{else}}disabled{{/if}}")
|
||||
| >
|
||||
.admin-report-controls
|
||||
input.js-boards-search-input(type="search" placeholder="{{_ 'search'}}")
|
||||
.admin-report-pagination
|
||||
button.js-boards-prev-page(class="{{#if hasPrevPage}}{{else}}disabled{{/if}}")
|
||||
| <
|
||||
span.admin-report-page-info {{currentPage}} / {{totalPages}}
|
||||
button.js-boards-next-page(class="{{#if hasNextPage}}{{else}}disabled{{/if}}")
|
||||
| >
|
||||
if resultsCount
|
||||
table.table
|
||||
thead
|
||||
@@ -239,3 +262,40 @@ template(name="boardsReport")
|
||||
td {{teams board.teams }}
|
||||
else
|
||||
div {{_ 'no-results' }}
|
||||
|
||||
template(name="eventStreamReport")
|
||||
.global-search-results-list-wrapper
|
||||
h1 {{streamTitle}}
|
||||
.admin-event-controls
|
||||
input.js-event-search(type="search" placeholder="{{_ 'search'}}" value="{{searchTerm}}")
|
||||
span.admin-event-pagination
|
||||
a.js-event-prev(class="{{prevDisabled}}") ‹
|
||||
span.admin-event-page {{currentPage}} / {{totalPages}}
|
||||
a.js-event-next(class="{{nextDisabled}}") ›
|
||||
if rowCount
|
||||
table.admin-event-table
|
||||
thead
|
||||
tr
|
||||
th {{_ 'event-datetime'}}
|
||||
th {{_ 'event-category'}}
|
||||
th {{_ 'event-bleed'}}
|
||||
th {{_ 'event-severity'}}
|
||||
th {{_ 'event-action'}}
|
||||
th {{_ 'event-source'}}
|
||||
th {{_ 'username'}}
|
||||
th {{_ 'event-detail'}}
|
||||
tbody
|
||||
each row in rows
|
||||
tr
|
||||
td.admin-event-at {{formatAt row.at}}
|
||||
td {{row.category}}
|
||||
td {{row.bleed}}
|
||||
td.admin-event-sev(data-sev="{{row.severity}}") {{row.severity}}
|
||||
td {{row.action}}
|
||||
td {{row.source}}
|
||||
td
|
||||
if row.userId
|
||||
a.edit-user.js-event-edit-user(data-user-id="{{ row.userId }}") {{ userName row.userId }}
|
||||
td.admin-event-detail {{row.detail}}
|
||||
else
|
||||
p {{_ 'no-new-problems'}}
|
||||
@@ -67,6 +67,12 @@ function reportConfig(tmpl) {
|
||||
|
||||
Template.adminReports.onCreated(function () {
|
||||
this.subscription = null;
|
||||
// Problems page opens on the Summary tab (the acknowledge checkbox list).
|
||||
this.showSummary = new ReactiveVar(true);
|
||||
this.showSecurity = new ReactiveVar(false);
|
||||
this.showSpeed = new ReactiveVar(false);
|
||||
this.showTests = new ReactiveVar(false);
|
||||
this.showCpu = new ReactiveVar(false);
|
||||
this.showFilesReport = new ReactiveVar(false);
|
||||
this.showBrokenCardsReport = new ReactiveVar(false);
|
||||
this.showOrphanedFilesReport = new ReactiveVar(false);
|
||||
@@ -115,8 +121,21 @@ Template.adminReports.onCreated(function () {
|
||||
const searchTerm = cfg.search.get();
|
||||
const limit = REPORTS_PER_PAGE;
|
||||
const skip = (cfg.page.get() - 1) * REPORTS_PER_PAGE;
|
||||
this.subscription = Meteor.subscribe(cfg.pub, searchTerm, limit, skip, () => {
|
||||
this.loading.set(false);
|
||||
this.subscription = Meteor.subscribe(cfg.pub, searchTerm, limit, skip, {
|
||||
onReady: () => {
|
||||
this.loading.set(false);
|
||||
},
|
||||
// A publication that errors (or is stopped) must never leave the report
|
||||
// stuck on the loading spinner forever: clear the spinner and, on a real
|
||||
// error, surface it. Without this an admin only ever saw an endless spinner
|
||||
// whenever a report subscription failed on the server.
|
||||
onStop: (error) => {
|
||||
if (error) {
|
||||
console.error(`Report subscription '${cfg.pub}' failed:`, error);
|
||||
this.error.set(error.reason || error.message || String(error));
|
||||
}
|
||||
this.loading.set(false);
|
||||
},
|
||||
});
|
||||
Meteor.call(cfg.countMethod, searchTerm, (error, count) => {
|
||||
if (error) {
|
||||
@@ -137,6 +156,21 @@ Template.adminReports.onCreated(function () {
|
||||
});
|
||||
|
||||
Template.adminReports.helpers({
|
||||
showSummary() {
|
||||
return Template.instance().showSummary;
|
||||
},
|
||||
showSecurity() {
|
||||
return Template.instance().showSecurity;
|
||||
},
|
||||
showSpeed() {
|
||||
return Template.instance().showSpeed;
|
||||
},
|
||||
showTests() {
|
||||
return Template.instance().showTests;
|
||||
},
|
||||
showCpu() {
|
||||
return Template.instance().showCpu;
|
||||
},
|
||||
showFilesReport() {
|
||||
return Template.instance().showFilesReport;
|
||||
},
|
||||
@@ -205,6 +239,21 @@ Template.adminReports.helpers({
|
||||
});
|
||||
|
||||
Template.adminReports.events({
|
||||
'click a.js-report-summary'(event) {
|
||||
switchMenu(event, Template.instance());
|
||||
},
|
||||
'click a.js-report-security'(event) {
|
||||
switchMenu(event, Template.instance());
|
||||
},
|
||||
'click a.js-report-speed'(event) {
|
||||
switchMenu(event, Template.instance());
|
||||
},
|
||||
'click a.js-report-tests'(event) {
|
||||
switchMenu(event, Template.instance());
|
||||
},
|
||||
'click a.js-report-cpu'(event) {
|
||||
switchMenu(event, Template.instance());
|
||||
},
|
||||
'click a.js-report-broken'(event) {
|
||||
switchMenu(event, Template.instance());
|
||||
},
|
||||
@@ -238,15 +287,10 @@ Template.adminReports.events({
|
||||
'click .js-impersonation-next-page'(event, tmpl) { goNextPage(event, tmpl, 'report-impersonation'); },
|
||||
|
||||
// --- Search (one input + button per report, mirrors the People panel) ---
|
||||
'click .js-files-search-button'(event, tmpl) { runSearch(tmpl, 'report-files', '.js-files-search-input'); },
|
||||
'keydown .js-files-search-input'(event, tmpl) { if (event.keyCode === 13 && !event.shiftKey) runSearch(tmpl, 'report-files', '.js-files-search-input'); },
|
||||
'click .js-rules-search-button'(event, tmpl) { runSearch(tmpl, 'report-rules', '.js-rules-search-input'); },
|
||||
'keydown .js-rules-search-input'(event, tmpl) { if (event.keyCode === 13 && !event.shiftKey) runSearch(tmpl, 'report-rules', '.js-rules-search-input'); },
|
||||
'click .js-boards-search-button'(event, tmpl) { runSearch(tmpl, 'report-boards', '.js-boards-search-input'); },
|
||||
'keydown .js-boards-search-input'(event, tmpl) { if (event.keyCode === 13 && !event.shiftKey) runSearch(tmpl, 'report-boards', '.js-boards-search-input'); },
|
||||
'click .js-cards-search-button'(event, tmpl) { runSearch(tmpl, 'report-cards', '.js-cards-search-input'); },
|
||||
'keydown .js-cards-search-input'(event, tmpl) { if (event.keyCode === 13 && !event.shiftKey) runSearch(tmpl, 'report-cards', '.js-cards-search-input'); },
|
||||
'click .js-impersonation-search-button'(event, tmpl) { runSearch(tmpl, 'report-impersonation', '.js-impersonation-search-input'); },
|
||||
'keydown .js-impersonation-search-input'(event, tmpl) { if (event.keyCode === 13 && !event.shiftKey) runSearch(tmpl, 'report-impersonation', '.js-impersonation-search-input'); },
|
||||
});
|
||||
|
||||
@@ -285,6 +329,11 @@ function switchMenu(event, tmpl) {
|
||||
const target = $(event.target);
|
||||
if (!target.hasClass('active')) {
|
||||
tmpl.loading.set(true);
|
||||
tmpl.showSummary.set(false);
|
||||
tmpl.showSecurity.set(false);
|
||||
tmpl.showSpeed.set(false);
|
||||
tmpl.showTests.set(false);
|
||||
tmpl.showCpu.set(false);
|
||||
tmpl.showFilesReport.set(false);
|
||||
tmpl.showBrokenCardsReport.set(false);
|
||||
tmpl.showOrphanedFilesReport.set(false);
|
||||
@@ -301,7 +350,24 @@ function switchMenu(event, tmpl) {
|
||||
const targetID = target.data('id');
|
||||
tmpl.activeReport.set(targetID);
|
||||
|
||||
if ('report-broken' === targetID) {
|
||||
// Summary + the Security/Speed/Tests streams load their own data (via methods,
|
||||
// not a subscription), so just show them and clear the spinner.
|
||||
if ('report-summary' === targetID) {
|
||||
tmpl.showSummary.set(true);
|
||||
tmpl.loading.set(false);
|
||||
} else if ('report-security' === targetID) {
|
||||
tmpl.showSecurity.set(true);
|
||||
tmpl.loading.set(false);
|
||||
} else if ('report-speed' === targetID) {
|
||||
tmpl.showSpeed.set(true);
|
||||
tmpl.loading.set(false);
|
||||
} else if ('report-tests' === targetID) {
|
||||
tmpl.showTests.set(true);
|
||||
tmpl.loading.set(false);
|
||||
} else if ('report-cpu' === targetID) {
|
||||
tmpl.showCpu.set(true);
|
||||
tmpl.loading.set(false);
|
||||
} else if ('report-broken' === targetID) {
|
||||
tmpl.showBrokenCardsReport.set(true);
|
||||
tmpl.subscription = Meteor.subscribe(
|
||||
'brokenCards',
|
||||
@@ -343,10 +409,31 @@ function switchMenu(event, tmpl) {
|
||||
|
||||
Template.filesReport.helpers({
|
||||
results() {
|
||||
return collectionResults(Attachments, { name: 1 });
|
||||
// The filename cell renders via the global {{cleanFilename}} helper: the name
|
||||
// is URL-decoded, homoglyphs are folded, and invisible / exploit characters
|
||||
// are removed, so it is always shown as a plain, readable name.
|
||||
//
|
||||
// Prefer the UNDERLYING reactive minimongo collection (Attachments.collection):
|
||||
// the 'attachmentsList' publication delivers the page via this.added, and a
|
||||
// plain Mongo.Collection cursor reacts to those adds and yields plain docs,
|
||||
// whereas the ostrio FilesCursor (Attachments.find()) does not reliably re-run
|
||||
// in a Blaze helper. Fall back to the wrapper if .collection is unavailable and
|
||||
// NEVER throw: a throwing helper would abort the whole filesReport render
|
||||
// (blank pane instead of the report + its "no results" state).
|
||||
const coll = (Attachments && Attachments.collection) || Attachments;
|
||||
try {
|
||||
return collectionResults(coll, { name: 1 });
|
||||
} catch (e) {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
resultsCount() {
|
||||
return collectionResultsCount(Attachments);
|
||||
const coll = (Attachments && Attachments.collection) || Attachments;
|
||||
try {
|
||||
return collectionResultsCount(coll);
|
||||
} catch (e) {
|
||||
return 0;
|
||||
}
|
||||
},
|
||||
fileSize(size) {
|
||||
return fileSizeHelper(size);
|
||||
@@ -429,7 +516,8 @@ Template.boardsReport.helpers({
|
||||
|
||||
Template.cardsReport.helpers({
|
||||
results() {
|
||||
return collectionResults(Cards, { boardId: 1, sort: 1 });
|
||||
// Match the server publication's index-backed sort (see cards.js).
|
||||
return collectionResults(Cards, { boardId: 1, createdAt: -1 });
|
||||
},
|
||||
resultsCount() {
|
||||
return collectionResultsCount(Cards);
|
||||
@@ -516,3 +604,80 @@ Template.brokenCardsReport.events({
|
||||
tpl.search.previousPage();
|
||||
},
|
||||
});
|
||||
|
||||
// --- Read-only event stream report (Security / Speed / Tests) ---
|
||||
// Reads the eventlog collection through the admin-only eventLogPage/eventLogCount
|
||||
// methods and shows a paginated, searchable, READ-ONLY table (no acknowledge —
|
||||
// that lives only on the Summary page). See docs/Security/Remediation/WeKan.md.
|
||||
const EVENTS_PER_PAGE = 25;
|
||||
|
||||
function eventStreamTitleKey(stream) {
|
||||
return { security: 'securityReportTitle', speed: 'speedReportTitle', tests: 'testsReportTitle', cpu: 'cpuReportTitle' }[stream] || 'summary';
|
||||
}
|
||||
|
||||
Template.eventStreamReport.onCreated(function () {
|
||||
this.stream = this.data.stream;
|
||||
this.page = new ReactiveVar(1);
|
||||
this.total = new ReactiveVar(0);
|
||||
this.rows = new ReactiveVar([]);
|
||||
this.search = new ReactiveVar('');
|
||||
this.load = () => {
|
||||
const stream = this.stream;
|
||||
const search = this.search.get();
|
||||
const page = this.page.get();
|
||||
Meteor.call('eventLogCount', stream, search, (err, count) => {
|
||||
if (!err) this.total.set(count || 0);
|
||||
});
|
||||
Meteor.call('eventLogPage', stream, EVENTS_PER_PAGE, (page - 1) * EVENTS_PER_PAGE, search, (err, rows) => {
|
||||
if (!err) this.rows.set(Array.isArray(rows) ? rows : []);
|
||||
});
|
||||
};
|
||||
this.load();
|
||||
});
|
||||
|
||||
Template.eventStreamReport.helpers({
|
||||
rows() { return Template.instance().rows.get(); },
|
||||
rowCount() { return (Template.instance().rows.get() || []).length; },
|
||||
currentPage() { return Template.instance().page.get(); },
|
||||
totalPages() { return Math.max(1, Math.ceil(Template.instance().total.get() / EVENTS_PER_PAGE)); },
|
||||
searchTerm() { return Template.instance().search.get(); },
|
||||
streamTitle() { return TAPi18n.__(eventStreamTitleKey(Template.instance().stream)); },
|
||||
prevDisabled() { return Template.instance().page.get() <= 1 ? 'disabled' : ''; },
|
||||
nextDisabled() {
|
||||
const t = Template.instance();
|
||||
return t.page.get() >= Math.max(1, Math.ceil(t.total.get() / EVENTS_PER_PAGE)) ? 'disabled' : '';
|
||||
},
|
||||
formatAt(at) {
|
||||
if (!at) return '';
|
||||
try { return new Date(at).toISOString().replace('T', ' ').slice(0, 19); } catch (e) { return String(at); }
|
||||
},
|
||||
// The user who triggered the event (e.g. who uploaded a sanitized file). Empty
|
||||
// for events with no associated user.
|
||||
userName(userId) {
|
||||
if (!userId) return '';
|
||||
return ReactiveCache.getUser(userId)?.username || userId;
|
||||
},
|
||||
});
|
||||
|
||||
Template.eventStreamReport.events({
|
||||
// Clicking the uploader opens the same "Edit user" popup as Admin Panel / People.
|
||||
'click .js-event-edit-user'(event) {
|
||||
event.preventDefault();
|
||||
const userId = event.currentTarget.getAttribute('data-user-id');
|
||||
if (userId) {
|
||||
Popup.open('editUser').call({ userId }, event);
|
||||
}
|
||||
},
|
||||
'input .js-event-search'(event, tmpl) {
|
||||
tmpl.search.set(event.currentTarget.value.trim());
|
||||
tmpl.page.set(1);
|
||||
tmpl.load();
|
||||
},
|
||||
'click .js-event-prev'(event, tmpl) {
|
||||
if (tmpl.page.get() > 1) { tmpl.page.set(tmpl.page.get() - 1); tmpl.load(); }
|
||||
},
|
||||
'click .js-event-next'(event, tmpl) {
|
||||
const totalPages = Math.max(1, Math.ceil(tmpl.total.get() / EVENTS_PER_PAGE));
|
||||
if (tmpl.page.get() < totalPages) { tmpl.page.set(tmpl.page.get() + 1); tmpl.load(); }
|
||||
},
|
||||
});
|
||||
@@ -54,6 +54,12 @@ template(name='statistics')
|
||||
tr
|
||||
th {{_ 'Reactivity_mode'}}
|
||||
td {{statistics.mongo.reactivity}}
|
||||
tr
|
||||
th {{_ 'Reactivity_order'}}
|
||||
td {{statistics.mongo.reactivityOrder}}
|
||||
tr
|
||||
th {{_ 'DDP_transport'}}
|
||||
td {{statistics.mongo.ddpTransport}}
|
||||
tr
|
||||
th {{_ 'OS_Type'}}
|
||||
td {{statistics.os.type}}
|
||||
|
||||
@@ -38,7 +38,7 @@ table tr:nth-child(even) {
|
||||
}
|
||||
.ext-box button {
|
||||
min-width: 90px;
|
||||
background: #01628c;
|
||||
background: var(--theme-accent, #01628c);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 0.4vw;
|
||||
@@ -47,11 +47,11 @@ table tr:nth-child(even) {
|
||||
}
|
||||
|
||||
.ext-box button:hover {
|
||||
background: #005377;
|
||||
background: var(--theme-accent, #005377);
|
||||
}
|
||||
|
||||
.ext-box button:active {
|
||||
background: #004766;
|
||||
background: var(--theme-accent, #004766);
|
||||
}
|
||||
.content-wrapper {
|
||||
margin-top: 10px;
|
||||
@@ -93,30 +93,30 @@ table td:first-child {
|
||||
min-width: auto;
|
||||
}
|
||||
#divAddOrRemoveTeam {
|
||||
background: #01628c;
|
||||
background: var(--theme-accent, #01628c);
|
||||
display: none;
|
||||
}
|
||||
#addOrRemoveTeam {
|
||||
background: #01628c;
|
||||
background: var(--theme-accent, #01628c);
|
||||
color: #fff;
|
||||
}
|
||||
#addOrRemoveTeam:hover {
|
||||
background: #005377;
|
||||
background: var(--theme-accent, #005377);
|
||||
}
|
||||
#divAddOrRemoveTeamContainer {
|
||||
display: none;
|
||||
margin: auto;
|
||||
width: 50%;
|
||||
border: 3px solid #01628c;
|
||||
border: 3px solid var(--theme-accent, #01628c);
|
||||
padding: 10px;
|
||||
}
|
||||
#cancelBtn {
|
||||
margin-inline-start: 5% !important;
|
||||
background: #01628c;
|
||||
background: var(--theme-accent, #01628c);
|
||||
color: #fff;
|
||||
}
|
||||
#cancelBtn:hover {
|
||||
background: #005377;
|
||||
background: var(--theme-accent, #005377);
|
||||
}
|
||||
#deleteAction {
|
||||
margin-inline-start: 5% !important;
|
||||
@@ -150,7 +150,7 @@ table td:first-child {
|
||||
.unlock-all-btn {
|
||||
margin-top: 20px;
|
||||
margin-inline-start: 15px;
|
||||
background-color: #01628c;
|
||||
background-color: var(--theme-accent, #01628c);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 0.4vw;
|
||||
@@ -164,7 +164,7 @@ table td:first-child {
|
||||
}
|
||||
|
||||
.unlock-all-btn:hover {
|
||||
background-color: #005377;
|
||||
background-color: var(--theme-accent, #005377);
|
||||
}
|
||||
|
||||
.unlock-all-btn .fa {
|
||||
@@ -185,7 +185,7 @@ table td:first-child {
|
||||
top: 10%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background-color: #01628c;
|
||||
background-color: var(--theme-accent, #01628c);
|
||||
color: white;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
@@ -206,7 +206,7 @@ table td:first-child {
|
||||
}
|
||||
|
||||
.text-green {
|
||||
color: #01628c;
|
||||
color: var(--theme-accent, #01628c);
|
||||
}
|
||||
|
||||
.js-toggle-lock-status {
|
||||
@@ -223,15 +223,16 @@ table td:first-child {
|
||||
.people-pagination button {
|
||||
min-width: 32px;
|
||||
height: 30px;
|
||||
border: 1px solid #bbb;
|
||||
background: #fff;
|
||||
border: 1px solid var(--theme-accent, #01628c);
|
||||
background: transparent;
|
||||
color: var(--theme-accent, #01628c);
|
||||
border-radius: 0.4vw;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.people-pagination button.active {
|
||||
background: #01628c;
|
||||
border-color: #01628c;
|
||||
background: var(--theme-accent, #01628c);
|
||||
border-color: var(--theme-accent, #01628c);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@@ -256,15 +257,16 @@ table td:first-child {
|
||||
.org-pagination button {
|
||||
min-width: 32px;
|
||||
height: 30px;
|
||||
border: 1px solid #bbb;
|
||||
background: #fff;
|
||||
border: 1px solid var(--theme-accent, #01628c);
|
||||
background: transparent;
|
||||
color: var(--theme-accent, #01628c);
|
||||
border-radius: 0.4vw;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.org-pagination button.active {
|
||||
background: #01628c;
|
||||
border-color: #01628c;
|
||||
background: var(--theme-accent, #01628c);
|
||||
border-color: var(--theme-accent, #01628c);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@@ -289,15 +291,16 @@ table td:first-child {
|
||||
.team-pagination button {
|
||||
min-width: 32px;
|
||||
height: 30px;
|
||||
border: 1px solid #bbb;
|
||||
background: #fff;
|
||||
border: 1px solid var(--theme-accent, #01628c);
|
||||
background: transparent;
|
||||
color: var(--theme-accent, #01628c);
|
||||
border-radius: 0.4vw;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.team-pagination button.active {
|
||||
background: #01628c;
|
||||
border-color: #01628c;
|
||||
background: var(--theme-accent, #01628c);
|
||||
border-color: var(--theme-accent, #01628c);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
@@ -416,8 +419,9 @@ table td:first-child {
|
||||
min-width: 32px;
|
||||
height: 30px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #bbb;
|
||||
background: #fff;
|
||||
border: 1px solid var(--theme-accent, #01628c);
|
||||
background: transparent;
|
||||
color: var(--theme-accent, #01628c);
|
||||
border-radius: 0.4vw;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -433,7 +437,3 @@ table td:first-child {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
th.js-domain-sort {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
@@ -261,8 +261,8 @@ template(name="domainGeneral")
|
||||
table
|
||||
thead
|
||||
tr
|
||||
th.js-domain-sort(scope="col" data-sort="domain") {{_ 'domain'}} {{domainSortIndicator 'domain'}}
|
||||
th.js-domain-sort(scope="col" data-sort="count") {{_ 'domain-user-count'}} {{domainSortIndicator 'count'}}
|
||||
th(scope="col") {{_ 'domain'}}
|
||||
th(scope="col") {{_ 'domain-user-count'}}
|
||||
tbody
|
||||
if domainList.length
|
||||
each domainList
|
||||
|
||||
@@ -1895,16 +1895,12 @@ Template.settingsUserPopup.helpers({
|
||||
// is never loaded into the browser.
|
||||
Template.domainGeneral.onCreated(function () {
|
||||
this.searchQuery = new ReactiveVar('');
|
||||
this.sortField = new ReactiveVar('domain'); // domain | count
|
||||
this.sortDirection = new ReactiveVar(1); // 1 ascending, -1 descending
|
||||
this.page = new ReactiveVar(1);
|
||||
this.pageData = new ReactiveVar({ rows: [], total: 0, totalPages: 1 });
|
||||
|
||||
this.autorun(() => {
|
||||
const params = {
|
||||
search: this.searchQuery.get(),
|
||||
sortField: this.sortField.get(),
|
||||
sortDirection: this.sortDirection.get(),
|
||||
page: this.page.get(),
|
||||
perPage: domainsPerPage,
|
||||
};
|
||||
@@ -1937,12 +1933,6 @@ Template.domainGeneral.helpers({
|
||||
const tpl = Template.instance();
|
||||
return tpl.page.get() < (tpl.pageData.get().totalPages || 1);
|
||||
},
|
||||
// Excel-like sort arrow on the active column, matching the Board Table view.
|
||||
domainSortIndicator(field) {
|
||||
const tpl = Template.instance();
|
||||
if (tpl.sortField.get() !== field) return '';
|
||||
return tpl.sortDirection.get() === 1 ? '▲' : '▼';
|
||||
},
|
||||
});
|
||||
|
||||
Template.domainGeneral.events({
|
||||
@@ -1968,16 +1958,4 @@ Template.domainGeneral.events({
|
||||
const current = tpl.page.get();
|
||||
if (current < (tpl.pageData.get().totalPages || 1)) tpl.page.set(current + 1);
|
||||
},
|
||||
'click .js-domain-sort'(event, tpl) {
|
||||
event.preventDefault();
|
||||
const field = event.currentTarget.dataset.sort;
|
||||
if (!field) return;
|
||||
if (tpl.sortField.get() === field) {
|
||||
tpl.sortDirection.set(tpl.sortDirection.get() * -1);
|
||||
} else {
|
||||
tpl.sortField.set(field);
|
||||
tpl.sortDirection.set(1);
|
||||
}
|
||||
tpl.page.set(1);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
template(name="problemsSummary")
|
||||
.global-search-results-list-wrapper
|
||||
h1 {{_ 'summary'}}
|
||||
if hasProblems
|
||||
p {{_ 'problems-summary-help'}}
|
||||
.admin-problem-banner
|
||||
each area in areas
|
||||
label.admin-problem-item
|
||||
input.js-problem-check(type="checkbox" data-stream="{{area.stream}}")
|
||||
i.fa.fa-exclamation-triangle
|
||||
span.admin-problem-path {{_ 'admin-panel'}} → {{_ 'problems'}} → {{areaLabel area.stream}}
|
||||
span.admin-problem-count {{area.count}} {{_ 'new-problems'}}
|
||||
.admin-problem-actions
|
||||
button.js-ack-checked.primary {{_ 'acknowledge'}}
|
||||
else
|
||||
p {{_ 'no-new-problems'}}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { TAPi18n } from '/imports/i18n';
|
||||
|
||||
// Admin Panel → Problems → Summary page: a checkbox list of problem areas
|
||||
// (Security / Speed / Tests) with each area's menu path and its count of NEW
|
||||
// (unacknowledged) problems, plus ONE Acknowledge button that acknowledges every
|
||||
// checked area. This is the ONLY place problems are acknowledged — the Security /
|
||||
// Speed / Tests detail pages are read-only.
|
||||
// Design: docs/Security/Remediation/WeKan.md. Data: the admin-only
|
||||
// eventLogProblemAreas / acknowledgeEventLog methods (models/eventLog.js).
|
||||
|
||||
const STREAM_LABEL_KEY = {
|
||||
security: 'securityReportTitle',
|
||||
speed: 'speedReportTitle',
|
||||
tests: 'testsReportTitle',
|
||||
};
|
||||
|
||||
Template.problemsSummary.onCreated(function () {
|
||||
this.areas = new ReactiveVar([]);
|
||||
this.reload = () => {
|
||||
Meteor.call('eventLogProblemAreas', (err, res) => {
|
||||
if (!err) this.areas.set(Array.isArray(res) ? res : []);
|
||||
});
|
||||
};
|
||||
this.reload();
|
||||
this.timer = Meteor.setInterval(this.reload, 30000);
|
||||
});
|
||||
|
||||
Template.problemsSummary.onDestroyed(function () {
|
||||
if (this.timer) Meteor.clearInterval(this.timer);
|
||||
});
|
||||
|
||||
Template.problemsSummary.helpers({
|
||||
areas() {
|
||||
return Template.instance().areas.get();
|
||||
},
|
||||
hasProblems() {
|
||||
return (Template.instance().areas.get() || []).length > 0;
|
||||
},
|
||||
areaLabel(stream) {
|
||||
return TAPi18n.__(STREAM_LABEL_KEY[stream] || stream);
|
||||
},
|
||||
});
|
||||
|
||||
Template.problemsSummary.events({
|
||||
'click .js-ack-checked'(event, templateInstance) {
|
||||
const streams = Array.from(templateInstance.findAll('.js-problem-check:checked'))
|
||||
.map(el => el.getAttribute('data-stream'))
|
||||
.filter(Boolean);
|
||||
if (!streams.length) return;
|
||||
Meteor.call('acknowledgeEventLog', streams, () => templateInstance.reload());
|
||||
},
|
||||
});
|
||||
@@ -137,19 +137,19 @@
|
||||
|
||||
/* Admin panel buttons should use theme darker color */
|
||||
.setting-content .content-body .main-body .setting-detail button.btn {
|
||||
background: #005377;
|
||||
background: var(--theme-accent, #005377);
|
||||
color: #fff;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.setting-content .content-body .main-body .setting-detail button.btn:hover,
|
||||
.setting-content .content-body .main-body .setting-detail button.btn:focus {
|
||||
background: #004766;
|
||||
background: var(--theme-accent, #004766);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.setting-content .content-body .main-body .setting-detail button.btn:active {
|
||||
background: #01628c;
|
||||
background: var(--theme-accent, #01628c);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
|
||||
@@ -76,3 +76,48 @@
|
||||
.iphone-device #header-quick-access .setting-header-btns .setting-header-btn i.fa {
|
||||
font-size: 18px !important;
|
||||
}
|
||||
|
||||
/* Admin Panel problem banner (Reports → Security/Speed/Tests new-problem info).
|
||||
Design: docs/Security/Remediation/WeKan.md. Shown under the settingHeaderBar h1;
|
||||
each area names its menu path + new count and has an Acknowledge button. */
|
||||
.admin-problem-banner {
|
||||
margin: 6px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.admin-problem-banner .admin-problem-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
background: #fdecea;
|
||||
color: #611a15;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
.admin-problem-banner .admin-problem-item .fa-exclamation-triangle {
|
||||
color: #c0392b;
|
||||
}
|
||||
.admin-problem-banner .admin-problem-path {
|
||||
font-weight: bold;
|
||||
}
|
||||
.admin-problem-banner .admin-problem-count {
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
.admin-problem-banner .js-ack-problems {
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Problems button turns red when there are new (unacknowledged) Security/Speed/
|
||||
Tests problems (settingHeader.js problemsClass). */
|
||||
.setting-header-btn.problems.has-problems,
|
||||
.setting-header-btn.problems.has-problems:hover {
|
||||
background-color: #c0392b;
|
||||
color: #fff;
|
||||
}
|
||||
.setting-header-btn.problems.has-problems i,
|
||||
.setting-header-btn.problems.has-problems span {
|
||||
color: #fff;
|
||||
}
|
||||
@@ -19,11 +19,6 @@ template(name="settingHeaderBar")
|
||||
i.fa.fa-flask
|
||||
span {{_ 'features'}}
|
||||
|
||||
a.setting-header-btn.informations(class=isAdminReportsActive href="{{pathFor 'admin-reports'}}")
|
||||
span.emoji-icon
|
||||
i.fa.fa-file-text-o
|
||||
span {{_ 'reports'}}
|
||||
|
||||
a.setting-header-btn.informations(class=isAttachmentsActive href="{{pathFor 'attachments'}}")
|
||||
span.emoji-icon
|
||||
i.fa.fa-paperclip
|
||||
@@ -39,6 +34,11 @@ template(name="settingHeaderBar")
|
||||
i.fa.fa-info-circle
|
||||
span {{_ 'info'}}
|
||||
|
||||
a.setting-header-btn.problems(class="{{isProblemsActive}} {{problemsClass}}" href="{{pathFor 'admin-reports'}}")
|
||||
span.emoji-icon
|
||||
i.fa.fa-exclamation-triangle
|
||||
span {{_ 'problems'}}
|
||||
|
||||
else
|
||||
a.setting-header-btn.js-log-in(
|
||||
title="{{_ 'log-in'}}")
|
||||
|
||||
@@ -1,6 +1,32 @@
|
||||
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
|
||||
|
||||
// Reactive "are there any new problems?" flag, polled from the admin-only
|
||||
// eventLogProblemAreas method, so the Problems button turns red when there are
|
||||
// unacknowledged Security/Speed/Tests problems.
|
||||
const hasProblemsVar = new ReactiveVar(false);
|
||||
let problemsTimer = null;
|
||||
function refreshProblems() {
|
||||
Meteor.call('eventLogProblemAreas', (err, res) => {
|
||||
hasProblemsVar.set(!err && Array.isArray(res) && res.length > 0);
|
||||
});
|
||||
}
|
||||
|
||||
Template.settingHeaderBar.onCreated(function () {
|
||||
refreshProblems();
|
||||
problemsTimer = Meteor.setInterval(refreshProblems, 30000);
|
||||
});
|
||||
Template.settingHeaderBar.onDestroyed(function () {
|
||||
if (problemsTimer) Meteor.clearInterval(problemsTimer);
|
||||
});
|
||||
|
||||
Template.settingHeaderBar.helpers({
|
||||
isProblemsActive() {
|
||||
return FlowRouter.getRouteName() === 'admin-reports' ? 'active' : '';
|
||||
},
|
||||
// 'has-problems' → red background (see settingHeader.css) when there are new problems.
|
||||
problemsClass() {
|
||||
return hasProblemsVar.get() ? 'has-problems' : '';
|
||||
},
|
||||
isSettingsActive() {
|
||||
return FlowRouter.getRouteName() === 'setting' ? 'active' : '';
|
||||
},
|
||||
@@ -10,9 +36,6 @@ Template.settingHeaderBar.helpers({
|
||||
isFeaturesActive() {
|
||||
return FlowRouter.getRouteName() === 'admin-features' ? 'active' : '';
|
||||
},
|
||||
isAdminReportsActive() {
|
||||
return FlowRouter.getRouteName() === 'admin-reports' ? 'active' : '';
|
||||
},
|
||||
isAttachmentsActive() {
|
||||
return FlowRouter.getRouteName() === 'attachments' ? 'active' : '';
|
||||
},
|
||||
|
||||
@@ -31,7 +31,10 @@ Template.translation.onCreated(function () {
|
||||
this.autorun(() => {
|
||||
const limitTranslations = this.page.get() * translationsPerPage;
|
||||
|
||||
this.subscribe('translation', this.findTranslationsOptions.get(), 0, () => {
|
||||
// #perf: pass the infinite-scroll window (not 0). A hardcoded 0 meant
|
||||
// Mongo `limit: 0` = NO limit, so the Admin Panel → Translation page loaded
|
||||
// the ENTIRE Translations collection (all strings × all languages) at once.
|
||||
this.subscribe('translation', this.findTranslationsOptions.get(), limitTranslations, () => {
|
||||
this.loadNextPageLocked = false;
|
||||
const nextPeakBefore = this.infiniteScrolling.getNextPeak();
|
||||
this.calculateNextPeak();
|
||||
|
||||
@@ -29,6 +29,16 @@
|
||||
.sidebar .sidebar-content {
|
||||
padding: 0 12px;
|
||||
}
|
||||
/* Sidebar text inputs (Filter List by Title, Filter by card title, advanced
|
||||
filter, etc.) fill the padded content width, so there is equal empty space on
|
||||
both sides (the .sidebar-content 12px padding) instead of the browser's
|
||||
default narrow width that left a large gap on the right. box-sizing keeps the
|
||||
border inside the width so it never overflows the panel. */
|
||||
.sidebar .sidebar-content input[type="text"],
|
||||
.sidebar .sidebar-content input[type="search"] {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.sidebar .sidebar-content .hide-btn {
|
||||
display: none;
|
||||
}
|
||||
@@ -191,7 +201,12 @@ body.grey-icons-enabled .sidebar .sidebar-content ul.sidebar-list li > a .fa.fa-
|
||||
}
|
||||
.board-sidebar {
|
||||
display: none;
|
||||
width: 30vw;
|
||||
/* Keep a sensible sidebar width at every screen size. A bare 30vw scaled with
|
||||
the viewport, so on very wide screens it grew to ~double the intended width
|
||||
while looking right only at mid widths; clamp keeps the mid-width look and
|
||||
caps it (and floors it) instead of scaling without bound. Phones (<800px)
|
||||
are handled by the 100vw rule in the media query below. */
|
||||
width: clamp(320px, 30vw, 560px);
|
||||
z-index: 100;
|
||||
transition: top 0.1s, right 0.1s, width 0.1s;
|
||||
}
|
||||
@@ -203,6 +218,36 @@ body.grey-icons-enabled .sidebar .sidebar-content ul.sidebar-list li > a .fa.fa-
|
||||
Restore/Delete links) sits unreachable UNDER the card window. */
|
||||
z-index: 2002;
|
||||
}
|
||||
/* Right sidebar resize handle: a thin grab strip on the LEFT edge of the open
|
||||
sidebar; drag left/right to change the sidebar width. Desktop only (hidden on
|
||||
phones by the media query below, where the sidebar is full width). */
|
||||
.sidebar-resize-handle {
|
||||
display: none;
|
||||
}
|
||||
.board-sidebar.is-open .sidebar-resize-handle {
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
inset-inline-start: -3px;
|
||||
width: 6px;
|
||||
cursor: col-resize;
|
||||
z-index: 2003; /* above the open sidebar (2002) and its content */
|
||||
background: transparent;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
.sidebar-resize-handle:hover {
|
||||
background: rgba(0, 123, 255, 0.4);
|
||||
box-shadow: 0 0 4px rgba(0, 123, 255, 0.3);
|
||||
}
|
||||
.sidebar-resize-handle:active {
|
||||
background: rgba(0, 123, 255, 0.6);
|
||||
box-shadow: 0 0 6px rgba(0, 123, 255, 0.4);
|
||||
}
|
||||
body.sidebar-resizing-active {
|
||||
user-select: none;
|
||||
cursor: col-resize;
|
||||
}
|
||||
.board-widget h4 {
|
||||
margin: 5px 0;
|
||||
}
|
||||
@@ -305,10 +350,16 @@ body.grey-icons-enabled .sidebar .sidebar-content ul.sidebar-list li > a .fa.fa-
|
||||
bottom: 0;
|
||||
inset-inline-start: 0;
|
||||
inset-inline-end: 0;
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
/* !important so a desktop drag-resize inline width never leaks into mobile,
|
||||
where the sidebar must stay full width. */
|
||||
width: 100vw !important;
|
||||
max-width: 100vw !important;
|
||||
z-index: 1000;
|
||||
}
|
||||
/* No dragging to resize on phones — the sidebar is full width there. */
|
||||
.board-sidebar .sidebar-resize-handle {
|
||||
display: none;
|
||||
}
|
||||
/* Normalise sidebar text on phones so the headings are not oversized and the
|
||||
small text is not tiny — keep them close to the board's text size so board
|
||||
and sidebar read at one consistent size. */
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
template(name="sidebar")
|
||||
.board-sidebar.sidebar(class="{{#if isOpen}}is-open{{/if}} {{#unless isVerticalScrollbars}}no-scrollbars{{/unless}}")
|
||||
//- Drag the sidebar's LEFT edge to resize its width (desktop only; hidden on
|
||||
mobile where the sidebar is full width). Saved to the user profile (or
|
||||
localStorage for anonymous users on a public board).
|
||||
.sidebar-resize-handle.js-sidebar-resize-handle.nodragscroll(title="{{_ 'drag-to-resize-sidebar'}}")
|
||||
//a.sidebar-tongue.js-toggle-sidebar(
|
||||
//
|
||||
class="{{#if isTongueHidden}}is-hidden{{/if}}",
|
||||
@@ -148,13 +152,9 @@ template(name="boardDomainRow")
|
||||
text(x="50%" y="11" text-anchor="middle" dominant-baseline="middle" font-size="16")= domain
|
||||
|
||||
template(name="boardChangeColorPopup")
|
||||
.board-backgrounds-list.clearfix
|
||||
each backgroundColors
|
||||
.board-background-select.js-select-background
|
||||
span.background-box(class="board-color-{{this}}")
|
||||
span {{this}}
|
||||
if isSelected
|
||||
i.fa.fa-check
|
||||
//- docs/Theme/Theme.md: category + theme dropdowns with custom color wheels for
|
||||
the flat/clear categories, shared with Member Settings / Change Color.
|
||||
+themeColorPicker(scope="board")
|
||||
|
||||
template(name="boardBackgroundsPopup")
|
||||
.board-backgrounds-manage
|
||||
|
||||
@@ -11,7 +11,6 @@ import Attachments from '/models/attachments';
|
||||
import { generateUniversalAttachmentUrl } from '/models/lib/universalUrlGenerator';
|
||||
import Integrations from '/models/integrations';
|
||||
import Lists from '/models/lists';
|
||||
import { BOARD_COLORS } from '/models/metadata/colors';
|
||||
import { Filter } from '/client/lib/filter';
|
||||
import { EscapeActions } from '/client/lib/escapeActions';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
@@ -139,7 +138,137 @@ Template.sidebar.onCreated(function() {
|
||||
};
|
||||
});
|
||||
|
||||
// ── Right sidebar drag-to-resize (desktop only) ──────────────────────────────
|
||||
// Drag the sidebar's left edge to change its width, like resizing a spreadsheet
|
||||
// column (mirrors the list resize in client/components/lists/list.js). The width
|
||||
// is saved to the user profile for logged-in users, or browser localStorage for
|
||||
// anonymous users on a public board. On phones the sidebar stays full width.
|
||||
const MIN_SIDEBAR_WIDTH = 280;
|
||||
const SIDEBAR_WIDTH_STORAGE_KEY = 'wekan-sidebar-width';
|
||||
const isMobileSidebar = () => window.innerWidth <= 800;
|
||||
const maxSidebarWidth = () =>
|
||||
Math.max(MIN_SIDEBAR_WIDTH, Math.min(900, window.innerWidth - 80));
|
||||
|
||||
function readSavedSidebarWidth() {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
if (user) return user.getSidebarWidth();
|
||||
try {
|
||||
const v = window.localStorage.getItem(SIDEBAR_WIDTH_STORAGE_KEY);
|
||||
return v ? parseInt(v, 10) : undefined;
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function saveSidebarWidth(width) {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
if (user) {
|
||||
Meteor.call('setSidebarWidth', width, err => {
|
||||
if (err) console.error('Error saving sidebar width:', err);
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
window.localStorage.setItem(SIDEBAR_WIDTH_STORAGE_KEY, String(width));
|
||||
} catch (e) {
|
||||
/* localStorage unavailable (private mode) — width just isn't persisted */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applySavedSidebarWidth($sidebar) {
|
||||
// On phones the sidebar is full width (a !important CSS rule), so never set an
|
||||
// inline width there; clear any leftover so shrinking desktop -> mobile works.
|
||||
if (isMobileSidebar()) {
|
||||
$sidebar[0].style.removeProperty('width');
|
||||
return;
|
||||
}
|
||||
const w = readSavedSidebarWidth();
|
||||
if (typeof w === 'number' && w >= MIN_SIDEBAR_WIDTH) {
|
||||
$sidebar[0].style.width = `${Math.min(w, maxSidebarWidth())}px`;
|
||||
} else {
|
||||
$sidebar[0].style.removeProperty('width'); // fall back to the CSS clamp
|
||||
}
|
||||
}
|
||||
|
||||
Template.sidebar.onRendered(function() {
|
||||
const tpl = this;
|
||||
const $sidebar = tpl.$('.board-sidebar');
|
||||
const $handle = tpl.$('.js-sidebar-resize-handle');
|
||||
if (!$sidebar.length || !$handle.length) return;
|
||||
|
||||
// Apply the saved width when the current user (and thus the saved value)
|
||||
// resolves, and re-apply on window resize so crossing the mobile breakpoint
|
||||
// clears/restores the inline width.
|
||||
tpl.autorun(() => {
|
||||
ReactiveCache.getCurrentUser(); // reactive dependency on login state
|
||||
applySavedSidebarWidth($sidebar);
|
||||
});
|
||||
const onWinResize = () => applySavedSidebarWidth($sidebar);
|
||||
window.addEventListener('resize', onWinResize);
|
||||
|
||||
let isResizing = false;
|
||||
let startX = 0;
|
||||
let startWidth = 0;
|
||||
let lastWidth = 0;
|
||||
|
||||
const getPageX = (e) => {
|
||||
const oe = e.originalEvent || e;
|
||||
if (oe.touches && oe.touches.length) return oe.touches[0].pageX;
|
||||
if (oe.changedTouches && oe.changedTouches.length) return oe.changedTouches[0].pageX;
|
||||
return e.pageX;
|
||||
};
|
||||
// Direction: the sidebar docks to the logical inline-end. In LTR that is the
|
||||
// right, so its left-edge handle widens the panel when dragged left; RTL puts
|
||||
// the panel on the left, mirroring the direction.
|
||||
const isRtl = () =>
|
||||
(document.documentElement.getAttribute('dir') || document.dir) === 'rtl';
|
||||
|
||||
const startResize = (e) => {
|
||||
if (isMobileSidebar()) return; // no resize on phones
|
||||
isResizing = true;
|
||||
startX = getPageX(e);
|
||||
startWidth = $sidebar.outerWidth();
|
||||
lastWidth = startWidth;
|
||||
$('body').addClass('sidebar-resizing-active');
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
const doResize = (e) => {
|
||||
if (!isResizing) return;
|
||||
const delta = (startX - getPageX(e)) * (isRtl() ? -1 : 1);
|
||||
const w = Math.max(MIN_SIDEBAR_WIDTH, Math.min(maxSidebarWidth(), startWidth + delta));
|
||||
lastWidth = w;
|
||||
$sidebar[0].style.width = `${w}px`;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
const stopResize = (e) => {
|
||||
if (!isResizing) return;
|
||||
isResizing = false;
|
||||
$('body').removeClass('sidebar-resizing-active');
|
||||
saveSidebarWidth(Math.round(lastWidth));
|
||||
if (e && e.preventDefault) e.preventDefault();
|
||||
};
|
||||
|
||||
$handle.on('mousedown', startResize);
|
||||
$(document).on('mousemove', doResize);
|
||||
$(document).on('mouseup', stopResize);
|
||||
// Native touch API so { passive: false } applies and preventDefault works.
|
||||
$handle[0].addEventListener('touchstart', startResize, { passive: false });
|
||||
document.addEventListener('touchmove', doResize, { passive: false });
|
||||
document.addEventListener('touchend', stopResize, { passive: false });
|
||||
|
||||
tpl._sidebarResizeCleanup = () => {
|
||||
window.removeEventListener('resize', onWinResize);
|
||||
$(document).off('mousemove', doResize);
|
||||
$(document).off('mouseup', stopResize);
|
||||
document.removeEventListener('touchmove', doResize);
|
||||
document.removeEventListener('touchend', stopResize);
|
||||
};
|
||||
});
|
||||
|
||||
Template.sidebar.onDestroyed(function() {
|
||||
if (this._sidebarResizeCleanup) this._sidebarResizeCleanup();
|
||||
clearSidebarInstance(this);
|
||||
Sidebar = null;
|
||||
});
|
||||
@@ -967,25 +1096,8 @@ function draggableMembersLabelsWidgets() {
|
||||
Template.membersWidget.onRendered(draggableMembersLabelsWidgets);
|
||||
Template.labelsWidget.onRendered(draggableMembersLabelsWidgets);
|
||||
|
||||
Template.boardChangeColorPopup.helpers({
|
||||
backgroundColors() {
|
||||
return BOARD_COLORS;
|
||||
},
|
||||
isSelected() {
|
||||
const currentBoard = Utils.getCurrentBoard();
|
||||
return currentBoard.color === Template.currentData().toString();
|
||||
},
|
||||
});
|
||||
|
||||
Template.boardChangeColorPopup.events({
|
||||
async 'click .js-select-background'(evt) {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
const currentBoard = Utils.getCurrentBoard();
|
||||
const newColor = this.toString();
|
||||
await currentBoard.setColor(newColor);
|
||||
},
|
||||
});
|
||||
// docs/Theme/Theme.md: the board color popup now renders the shared themeColorPicker
|
||||
// (scope="board"); its logic lives in client/components/main/themeColorPicker.js.
|
||||
|
||||
Template.boardChangeBackgroundImagePopup.events({
|
||||
async submit(event, tpl) {
|
||||
|
||||
@@ -67,8 +67,15 @@
|
||||
padding: 0;
|
||||
font-weight: bold;
|
||||
min-height: 33px;
|
||||
height: auto;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
/* #6465: the gray swimlane header bar looked cut off at non-100% board zoom
|
||||
(transform: scale on .board-wrapper). `overflow: hidden` clipped the header
|
||||
to a slightly-too-short box once the scaled layout rounded, so the bar/text
|
||||
appeared sliced. Let it grow to fit (height:auto + min-height floor) and not
|
||||
clip vertically. Swimlane names are short, so dropping the ellipsis here is
|
||||
fine; a long name wraps (word-wrap) instead of being cut. */
|
||||
overflow: visible;
|
||||
-o-text-overflow: ellipsis;
|
||||
text-overflow: ellipsis;
|
||||
word-wrap: break-word;
|
||||
|
||||
@@ -15,12 +15,27 @@ template(name="swimlane")
|
||||
each lists
|
||||
+miniList(this)
|
||||
else
|
||||
if currentUser.isBoardMember
|
||||
unless currentUser.isCommentOnly
|
||||
+addListForm
|
||||
//- #6465: no standing Add List column. Empty swimlane gets the inline
|
||||
//- composer at the start; otherwise it opens after a list via its header
|
||||
//- add-list button (see isAddListAfter).
|
||||
if swimlaneHasNoLists
|
||||
if currentUser.isBoardMember
|
||||
unless currentUser.isCommentOnly
|
||||
//- #6465: an empty swimlane shows only a + button; the create-list
|
||||
//- form appears only after it is clicked. (Non-empty swimlanes use
|
||||
//- the per-list-header add-list button instead.)
|
||||
if isEmptyAddListOpen _id
|
||||
+addListInline
|
||||
else
|
||||
.list.list-composer.js-list-composer.js-empty-add-list-button(class="{{#if isMiniScreen}}mini-list{{/if}}")
|
||||
.list-header-add
|
||||
a.open-list-composer.js-open-empty-add-list(title="{{_ 'add-list'}}" data-swimlane="{{_id}}")
|
||||
i.fa.fa-plus
|
||||
each lists
|
||||
if visible this
|
||||
+list(this)
|
||||
if isAddListAfter _id
|
||||
+addListInline
|
||||
if currentCardIsInThisList _id ../_id
|
||||
+cardDetails(currentCard)
|
||||
|
||||
@@ -38,14 +53,26 @@ template(name="listsGroup")
|
||||
each lists
|
||||
+miniList(this)
|
||||
else
|
||||
//- Issue #6142: allow adding a list in Lists (board-view-lists) mode too.
|
||||
if defaultSwimlane
|
||||
if currentUser.isBoardMember
|
||||
unless currentUser.isCommentOnly
|
||||
+addListForm(defaultSwimlane)
|
||||
//- #6465: Lists view — empty board gets the inline composer at the start;
|
||||
//- otherwise it opens after a list via that list's header add-list button.
|
||||
if boardHasNoLists
|
||||
if defaultSwimlane
|
||||
if currentUser.isBoardMember
|
||||
unless currentUser.isCommentOnly
|
||||
//- #6465: empty board shows only a + button; the create-list form
|
||||
//- appears only after clicking it.
|
||||
if isEmptyAddListOpen defaultSwimlane._id
|
||||
+addListInline(defaultSwimlane)
|
||||
else
|
||||
.list.list-composer.js-list-composer.js-empty-add-list-button(class="{{#if isMiniScreen}}mini-list{{/if}}")
|
||||
.list-header-add
|
||||
a.open-list-composer.js-open-empty-add-list(title="{{_ 'add-list'}}" data-swimlane="{{defaultSwimlane._id}}")
|
||||
i.fa.fa-plus
|
||||
each lists
|
||||
if visible this
|
||||
+list(this)
|
||||
if isAddListAfter _id
|
||||
+addListInline(defaultSwimlane)
|
||||
if currentCardIsInThisList _id null
|
||||
+cardDetails(currentCard)
|
||||
|
||||
@@ -77,6 +104,33 @@ template(name="addListForm")
|
||||
a.open-list-composer.js-open-inlined-form(title="{{_ 'add-list'}}")
|
||||
i.fa.fa-plus
|
||||
|
||||
//- #6465: always-open inline Add List composer, rendered AFTER a list (right in
|
||||
//- LTR / left in RTL, via the flex-row lists lane) when that list's header
|
||||
//- add-list button is clicked, or at the start of an empty swimlane/board.
|
||||
template(name="addListInline")
|
||||
.list.list-composer.js-list-composer.js-add-list-inline(class="{{#if isMiniScreen}}mini-list{{/if}}")
|
||||
.list-header-add
|
||||
form.js-add-list-inline-form
|
||||
input.list-name-input.full-line(type="text" placeholder="{{_ 'add-list'}}"
|
||||
autocomplete="off" autofocus)
|
||||
//- #6465 follow-up: restore the previous "add after which list"
|
||||
//- position selector, pre-selecting the list whose header button opened
|
||||
//- this composer, so the position can still be chosen explicitly.
|
||||
if swimlaneLists.length
|
||||
| {{_ 'add-after-list'}}
|
||||
select.list-position-input.full-line
|
||||
each swimlaneLists
|
||||
option(value="{{_id}}" selected="{{#if isDefaultAfterList _id}}selected{{/if}}") {{title}}
|
||||
.edit-controls.clearfix
|
||||
button.primary.confirm(type="submit") {{_ 'save'}}
|
||||
a.js-close-add-list-inline(title="{{_ 'close'}}")
|
||||
i.fa.fa-times-thin
|
||||
unless currentBoard.isTemplatesBoard
|
||||
unless currentBoard.isTemplateBoard
|
||||
span.quiet
|
||||
| {{_ 'or'}}
|
||||
a.js-list-template {{_ 'template'}}
|
||||
|
||||
template(name="moveSwimlanePopup")
|
||||
if currentUser
|
||||
unless currentUser.isWorker
|
||||
|
||||
@@ -6,6 +6,7 @@ import { CSSEvents } from '/client/lib/cssEvents';
|
||||
import { Filter } from '/client/lib/filter';
|
||||
import { EscapeActions } from '/client/lib/escapeActions';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import { TAPi18n } from '/imports/i18n';
|
||||
import { defaultSwimlaneIdForBoard } from '/client/components/lists/listAddHelpers';
|
||||
const { calculateIndex } = Utils;
|
||||
|
||||
@@ -71,11 +72,45 @@ function saveSorting(ui) {
|
||||
sort: sortIndex.base,
|
||||
};
|
||||
|
||||
// Check if the list was dropped in a different swimlane
|
||||
const isDifferentSwimlane = targetSwimlaneId && targetSwimlaneId !== originalSwimlaneId;
|
||||
// Check if the list was dropped in a different swimlane.
|
||||
//
|
||||
// #6484: only rebind a list to a swimlane when it was ALREADY swimlane-scoped
|
||||
// (originalSwimlaneId set) and actually crossed into a DIFFERENT swimlane. A
|
||||
// board-wide list has swimlaneId === null / getEffectiveSwimlaneId() === null
|
||||
// and renders under EVERY swimlane (see filterCardsByListAndSwimlane and
|
||||
// currentListIsInThisSwimlane: a null swimlaneId matches every swimlane). Since
|
||||
// targetSwimlaneId is ALWAYS the swimlane the list is shown under in swimlanes
|
||||
// view, the old `targetSwimlaneId !== originalSwimlaneId` was true for ANY nudge
|
||||
// of a board-wide list, so it set list.swimlaneId AND moved every card to that
|
||||
// one swimlane — the list then vanished from all the other swimlanes. Requiring
|
||||
// originalSwimlaneId keeps board-wide lists board-wide; genuinely
|
||||
// swimlane-scoped lists can still be moved between swimlanes.
|
||||
const isDifferentSwimlane =
|
||||
originalSwimlaneId &&
|
||||
targetSwimlaneId &&
|
||||
targetSwimlaneId !== originalSwimlaneId;
|
||||
|
||||
// If the list was dropped in a different swimlane, update the swimlaneId
|
||||
if (isDifferentSwimlane) {
|
||||
// #6478: moving a list to a DIFFERENT swimlane is high-impact — it re-homes
|
||||
// every card in the list — and is easy to trigger accidentally on touch (the
|
||||
// reporter merged lists this way and needed ~15 min to recover). Confirm on
|
||||
// touch/small screens before applying; on decline, revert the drag by
|
||||
// re-rendering from the server and abort. (Ctrl+Z can also undo it after the
|
||||
// fact — see userPositionHistory.undoLast.)
|
||||
if (
|
||||
(Utils.isMiniScreen() || Utils.isTouchScreenOrShowDesktopDragHandles()) &&
|
||||
typeof window !== 'undefined' &&
|
||||
typeof window.confirm === 'function' &&
|
||||
// eslint-disable-next-line no-alert
|
||||
!window.confirm(TAPi18n.__('confirm-move-list-to-swimlane'))
|
||||
) {
|
||||
Meteor.subscribe('board', list.boardId, false);
|
||||
$('.js-swimlane').each(function () {
|
||||
$(this).addClass('dragscroll');
|
||||
});
|
||||
return;
|
||||
}
|
||||
updateData.swimlaneId = targetSwimlaneId;
|
||||
|
||||
// Move all cards in the list to the new swimlane
|
||||
@@ -679,6 +714,24 @@ Template.swimlane.helpers({
|
||||
}
|
||||
return combined;
|
||||
},
|
||||
// #6465: the add-list composer is no longer a standing column. It opens AFTER a
|
||||
// specific list when that list's header add-list button is clicked (the target
|
||||
// list _id is kept in Session 'wekan-add-list-after'). Rendered after the list
|
||||
// in the DOM, it appears to the RIGHT of the list in LTR and to the LEFT in RTL
|
||||
// (the lists lane is a flex row that reverses under dir=rtl).
|
||||
isAddListAfter(listId) {
|
||||
return Session.get('wekan-add-list-after') === listId;
|
||||
},
|
||||
// Empty swimlane / empty board: there are no list headers to host the button,
|
||||
// so offer a + button that reveals the inline composer only when clicked.
|
||||
swimlaneHasNoLists() {
|
||||
return !this.myLists || this.myLists().length === 0;
|
||||
},
|
||||
// #6465: is the empty-state create-list form open for this swimlane? (Set by the
|
||||
// + button; until then only the button shows.)
|
||||
isEmptyAddListOpen(swimlaneId) {
|
||||
return Session.get('wekan-add-list-empty') === swimlaneId;
|
||||
},
|
||||
collapseSwimlane() {
|
||||
return Utils.getSwimlaneCollapseState(this);
|
||||
},
|
||||
@@ -742,7 +795,19 @@ Template.swimlane.helpers({
|
||||
},
|
||||
});
|
||||
|
||||
// #6465: open the empty-state create-list form for a swimlane (shared by the
|
||||
// Swimlanes and Lists views). Until clicked, an empty swimlane/board shows only the
|
||||
// + button; this reveals the inline composer, scoped to the clicked swimlane.
|
||||
function openEmptyAddList(event) {
|
||||
event.preventDefault();
|
||||
const swimlaneId = $(event.currentTarget).data('swimlane') || null;
|
||||
Session.set('wekan-add-list-after', null);
|
||||
Session.set('wekan-add-list-swimlane', swimlaneId);
|
||||
Session.set('wekan-add-list-empty', swimlaneId);
|
||||
}
|
||||
|
||||
Template.swimlane.events({
|
||||
'click .js-open-empty-add-list': openEmptyAddList,
|
||||
// Click-and-drag action
|
||||
'mousedown .board-canvas'(evt, tpl) {
|
||||
const noDragInside = ['a', 'input', 'textarea', 'p'].concat(
|
||||
@@ -848,6 +913,84 @@ Template.addListForm.events({
|
||||
'click .js-list-template': Popup.open('searchElement'),
|
||||
});
|
||||
|
||||
// #6465: the inline add-list composer that opens after a specific list (or at the
|
||||
// start of an empty swimlane). Its data context is the swimlane it creates the
|
||||
// list in; the target list (for positioning) is Session 'wekan-add-list-after'.
|
||||
Template.addListInline.onCreated(function () {
|
||||
this.currentSwimlane = Template.currentData();
|
||||
});
|
||||
|
||||
Template.addListInline.helpers({
|
||||
// #6465 follow-up: the lists of the swimlane this composer opens in, so the
|
||||
// "add after which list" position selector (restored) can be shown.
|
||||
swimlaneLists() {
|
||||
const swimlaneId =
|
||||
Session.get('wekan-add-list-swimlane') ||
|
||||
Template.instance().currentSwimlane?._id;
|
||||
if (!swimlaneId) return [];
|
||||
return ReactiveCache.getLists(
|
||||
{ swimlaneId, archived: false },
|
||||
{ sort: { sort: 1 } },
|
||||
);
|
||||
},
|
||||
// Pre-select the list whose header add-list button opened this composer.
|
||||
isDefaultAfterList(listId) {
|
||||
return Session.get('wekan-add-list-after') === listId;
|
||||
},
|
||||
});
|
||||
|
||||
Template.addListInline.events({
|
||||
async submit(evt, tpl) {
|
||||
evt.preventDefault();
|
||||
const titleInput = tpl.find('.list-name-input');
|
||||
const title = titleInput?.value.trim();
|
||||
if (!title) return;
|
||||
|
||||
// Position: honour the explicit "add after list" selector when present
|
||||
// (its options mirror the swimlane's lists, defaulting to the one whose
|
||||
// header opened this composer), and derive nextListId from the following
|
||||
// option so the new list is inserted between them. Fall back to the header
|
||||
// button's target (Session) for the empty-board/start case.
|
||||
const positionInput = tpl.find('.list-position-input');
|
||||
let afterListId;
|
||||
let nextListId = null;
|
||||
if (positionInput && positionInput.value) {
|
||||
afterListId = positionInput.value.trim();
|
||||
const next = positionInput.options[positionInput.selectedIndex + 1];
|
||||
nextListId = next ? next.value : null;
|
||||
} else {
|
||||
afterListId = Session.get('wekan-add-list-after') || null;
|
||||
}
|
||||
|
||||
try {
|
||||
await Meteor.callAsync('createListAfter', {
|
||||
title,
|
||||
boardId: Session.get('currentBoard'),
|
||||
// The header button records the swimlane the list is displayed in; the
|
||||
// empty-swimlane composer falls back to its own swimlane data context.
|
||||
swimlaneId: Session.get('wekan-add-list-swimlane') || tpl.currentSwimlane?._id,
|
||||
afterListId,
|
||||
nextListId,
|
||||
type: 'list',
|
||||
});
|
||||
titleInput.value = '';
|
||||
titleInput.focus();
|
||||
// Empty-state flow: the swimlane now has a list, so drop the empty-composer
|
||||
// flag — if it becomes empty again the + button (not the form) shows first.
|
||||
Session.set('wekan-add-list-empty', null);
|
||||
} catch (error) {
|
||||
console.error('Failed to create list:', error);
|
||||
}
|
||||
},
|
||||
// Restore the "or template" option: create the list from a template.
|
||||
'click .js-list-template': Popup.open('searchElement'),
|
||||
'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);
|
||||
},
|
||||
});
|
||||
|
||||
Template.listsGroup.helpers({
|
||||
// Issue #6142: the add-list composer needs a swimlane as its data context
|
||||
// (it creates the list in that swimlane). In Lists mode the listsGroup data
|
||||
@@ -859,6 +1002,18 @@ Template.listsGroup.helpers({
|
||||
if (!swimlaneId) return null;
|
||||
return ReactiveCache.getSwimlane({ _id: swimlaneId });
|
||||
},
|
||||
// #6465: same add-list-after-a-list behaviour as the swimlanes view.
|
||||
isAddListAfter(listId) {
|
||||
return Session.get('wekan-add-list-after') === listId;
|
||||
},
|
||||
boardHasNoLists() {
|
||||
const board = Template.currentData();
|
||||
return !board || !board.lists || board.lists().length === 0;
|
||||
},
|
||||
// #6465: is the empty-board create-list form open? (Set by the + button.)
|
||||
isEmptyAddListOpen(swimlaneId) {
|
||||
return Session.get('wekan-add-list-empty') === swimlaneId;
|
||||
},
|
||||
currentCardIsInThisList(listId, swimlaneId) {
|
||||
return currentCardIsInThisList(listId, swimlaneId);
|
||||
},
|
||||
@@ -883,6 +1038,11 @@ Template.listsGroup.helpers({
|
||||
},
|
||||
});
|
||||
|
||||
Template.listsGroup.events({
|
||||
// #6465: empty board (Lists view) + button reveals the inline composer.
|
||||
'click .js-open-empty-add-list': openEmptyAddList,
|
||||
});
|
||||
|
||||
Template.listsGroup.onRendered(function () {
|
||||
const tpl = this;
|
||||
const boardBodyEl2 = tpl.firstNode?.parentElement?.closest?.('.board-body') || document.querySelector('.board-body');
|
||||
|
||||
@@ -98,7 +98,7 @@ template(name="changeAvatarPopup")
|
||||
| {{_ 'allowed-avatar-filetypes'}} {{Meteor.settings.public.avatarsUploadMimeTypes}}
|
||||
br
|
||||
| {{_ 'invalid-file'}}
|
||||
button.full.js-upload-avatar
|
||||
button.full.primary.js-upload-avatar
|
||||
i.fa.fa-upload
|
||||
| {{_ 'upload-avatar' }}
|
||||
|
||||
@@ -137,7 +137,7 @@ template(name="adminChangeAvatarPopup")
|
||||
| {{_ 'allowed-avatar-filetypes'}} {{Meteor.settings.public.avatarsUploadMimeTypes}}
|
||||
br
|
||||
| {{_ 'invalid-file'}}
|
||||
button.full.js-upload-avatar
|
||||
button.full.primary.js-upload-avatar
|
||||
i.fa.fa-upload
|
||||
| {{_ 'upload-avatar' }}
|
||||
|
||||
|
||||
@@ -44,10 +44,7 @@ template(name="memberMenuPopup")
|
||||
a.board-header-btn.js-open-archived-board
|
||||
i.fa.fa-archive
|
||||
span {{_ 'archives'}}
|
||||
li
|
||||
a.js-notifications-drawer-toggle
|
||||
i.fa.fa-bell
|
||||
| {{_ 'notifications'}}
|
||||
//- Notifications removed from the member menu: same as the header bell icon.
|
||||
if currentSetting.customHelpLinkUrl
|
||||
li
|
||||
a(href="{{currentSetting.customHelpLinkUrl}}", title="{{_ 'help'}}", target="_blank", rel="noopener noreferrer")
|
||||
@@ -79,6 +76,14 @@ template(name="memberMenuPopup")
|
||||
a.js-change-settings
|
||||
i.fa.fa-cog
|
||||
| {{_ 'change-settings'}}
|
||||
li
|
||||
a.js-change-color
|
||||
i.fa.fa-paint-brush
|
||||
| {{_ 'change-color'}}
|
||||
li
|
||||
a.js-change-font
|
||||
i.fa.fa-font
|
||||
| {{_ 'change-font'}}
|
||||
li
|
||||
a.js-change-avatar
|
||||
i.fa.fa-picture-o
|
||||
@@ -181,6 +186,43 @@ template(name="changeLanguagePopup")
|
||||
| (RTL)
|
||||
if isCurrentLanguage
|
||||
i.fa.fa-check
|
||||
|
||||
template(name="changeColorPopup")
|
||||
//- #5778: global theme color override, saved to the user profile. Works like
|
||||
Board Settings / Change Color, but applies to the whole UI (All Boards, Search,
|
||||
Admin Panel, etc). The shared picker (docs/Theme/Theme.md) offers category +
|
||||
theme dropdowns and custom color wheels for flat/clear themes.
|
||||
+themeColorPicker(scope="global")
|
||||
|
||||
template(name="changeFontPopup")
|
||||
//- #4759: pick a UI font that ALREADY EXISTS in this browser. One button per
|
||||
detected font, each shown in its OWN font (names are plain text, Blaze-escaped,
|
||||
validated to a curated whitelist server-side). Font size is a row of buttons
|
||||
(smaller on the left, 100% in the middle, larger on the right). Clicking a font
|
||||
or size applies it IMMEDIATELY (no Save button).
|
||||
form.change-font.edit-label
|
||||
label {{_ 'font'}}
|
||||
.ui-font-buttons
|
||||
a.ui-font-btn.js-ui-font-btn(data-font="" class="{{#if isNoneSelected}}active{{/if}}") {{_ 'font-default'}}
|
||||
each detectedFonts
|
||||
a.ui-font-btn.js-ui-font-btn(data-font="{{name}}" style="{{optionStyle}}" class="{{#if selected}}active{{/if}}") {{name}}
|
||||
//- Preview (pangram + numbers) right below the font-name buttons.
|
||||
.font-preview(style=previewStyle) {{_ 'font-preview-text'}}
|
||||
label {{_ 'font-size'}}
|
||||
.ui-font-size-buttons
|
||||
each fontSizes
|
||||
a.ui-font-size-btn.js-ui-font-size-btn(data-size="{{key}}" class="{{#if selected}}active{{/if}}") {{label}}
|
||||
//- #4759: text + text-background color from a color wheel, each unsettable.
|
||||
//- Applied immediately when the wheel is committed / reset.
|
||||
label {{_ 'text-color'}}
|
||||
.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}}") {{_ 'unset-color'}}
|
||||
label {{_ 'text-background-color'}}
|
||||
.color-wheel-wrap
|
||||
input.js-ui-bg-color(type="color" value=bgColorHex title="{{_ 'text-background-color'}}")
|
||||
a.js-reset-bg-color(class="{{#unless hasBgColor}}disabled{{/unless}}") {{_ 'unset-color'}}
|
||||
|
||||
template(name="changeSettingsPopup")
|
||||
ul.pop-over-list
|
||||
li
|
||||
@@ -189,6 +231,12 @@ template(name="changeSettingsPopup")
|
||||
| {{_ 'show-desktop-drag-handles'}}
|
||||
if isShowDesktopDragHandles
|
||||
i.fa.fa-check
|
||||
li
|
||||
a.js-toggle-submit-on-enter(title="{{_ 'submit-on-enter-description'}}")
|
||||
i.fa.fa-level-down
|
||||
| {{_ 'submit-on-enter'}}
|
||||
if isSubmitOnEnter
|
||||
i.fa.fa-check
|
||||
unless currentUser.isWorker
|
||||
li
|
||||
label.bold.clear
|
||||
|
||||
@@ -4,6 +4,9 @@ import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
|
||||
import AccountSettings from '/models/accountSettings';
|
||||
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';
|
||||
|
||||
Template.headerUserBar.events({
|
||||
'click .js-open-header-member-menu': Popup.open('memberMenu'),
|
||||
@@ -72,13 +75,12 @@ Template.memberMenuPopup.events({
|
||||
'click .js-invite-people': Popup.open('invitePeople'),
|
||||
'click .js-edit-profile': Popup.open('editProfile'),
|
||||
'click .js-change-settings': Popup.open('changeSettings'),
|
||||
'click .js-change-color': Popup.open('changeColor'),
|
||||
'click .js-change-font': Popup.open('changeFont'),
|
||||
'click .js-change-avatar': Popup.open('changeAvatar'),
|
||||
'click .js-change-password': Popup.open('changePassword'),
|
||||
'click .js-change-language': Popup.open('changeLanguage'),
|
||||
'click .js-support': Popup.open('support'),
|
||||
'click .js-notifications-drawer-toggle'() {
|
||||
Session.set('showNotificationsDrawer', !Session.get('showNotificationsDrawer'));
|
||||
},
|
||||
'click .js-toggle-grey-icons'(event) {
|
||||
event.preventDefault();
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
@@ -320,6 +322,10 @@ Template.changeLanguagePopup.events({
|
||||
});
|
||||
|
||||
Template.changeSettingsPopup.helpers({
|
||||
isSubmitOnEnter() {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
return currentUser ? currentUser.hasSubmitOnEnter() : false;
|
||||
},
|
||||
rescueCardDescription() {
|
||||
const currentUser = ReactiveCache.getCurrentUser();
|
||||
if (currentUser) {
|
||||
@@ -379,6 +385,12 @@ Template.changeSettingsPopup.events({
|
||||
window.localStorage.setItem('showDesktopDragHandles', 'true');
|
||||
}
|
||||
},
|
||||
'click .js-toggle-submit-on-enter'() {
|
||||
// Saved to the user profile (Member Settings only; requires a logged-in user).
|
||||
if (ReactiveCache.getCurrentUser()) {
|
||||
Meteor.call('toggleSubmitOnEnter');
|
||||
}
|
||||
},
|
||||
'click .js-rescue-card-description'() {
|
||||
Meteor.call('toggleRescueCardDescription')
|
||||
},
|
||||
@@ -413,3 +425,129 @@ Template.changeSettingsPopup.events({
|
||||
Popup.back();
|
||||
},
|
||||
});
|
||||
|
||||
// #5778 + docs/Theme/Theme.md: the Member Menu / Change Color popup renders the
|
||||
// shared themeColorPicker (scope="global"); all of its logic lives there.
|
||||
|
||||
// #4759: Member Menu / Font. A dropdown of fonts detected as installed in this
|
||||
// browser; the value is stored to the profile and validated server-side. The first
|
||||
// option unsets the custom font.
|
||||
Template.changeFontPopup.onCreated(function () {
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
this.available = detectAvailableFonts(); // ordered subset of the curated whitelist
|
||||
this.selected = new ReactiveVar((user && user.getUiFont && user.getUiFont()) || '');
|
||||
this.selectedSize = new ReactiveVar((user && user.getUiFontSize && user.getUiFontSize()) || 'default');
|
||||
// null = use default (unset); a hex string = custom color.
|
||||
this.textColor = new ReactiveVar((user && user.getUiTextColor && user.getUiTextColor()) || null);
|
||||
this.bgColor = new ReactiveVar((user && user.getUiTextBgColor && user.getUiTextBgColor()) || null);
|
||||
});
|
||||
|
||||
Template.changeFontPopup.helpers({
|
||||
detectedFonts() {
|
||||
const tpl = Template.instance();
|
||||
const sel = tpl.selected.get();
|
||||
return tpl.available.map(name => ({
|
||||
name,
|
||||
selected: name === sel,
|
||||
optionStyle: `font-family: "${name}", sans-serif;`,
|
||||
}));
|
||||
},
|
||||
isNoneSelected() {
|
||||
return !Template.instance().selected.get();
|
||||
},
|
||||
fontSizes() {
|
||||
const cur = Template.instance().selectedSize.get();
|
||||
// Ordered by percent so the buttons read: smaller ... 100% (default) ... larger.
|
||||
return [...UI_FONT_SIZES].sort((a, b) => a.percent - b.percent).map(s => ({
|
||||
key: s.key,
|
||||
label: TAPi18n.__(`font-size-${s.key}`),
|
||||
selected: s.key === cur,
|
||||
}));
|
||||
},
|
||||
// Wheel <input type=color> needs a hex value even when unset — show a sensible
|
||||
// default so the wheel opens somewhere reasonable.
|
||||
textColorHex() {
|
||||
return Template.instance().textColor.get() || '#000000';
|
||||
},
|
||||
bgColorHex() {
|
||||
return Template.instance().bgColor.get() || '#ffffff';
|
||||
},
|
||||
hasTextColor() {
|
||||
return !!Template.instance().textColor.get();
|
||||
},
|
||||
hasBgColor() {
|
||||
return !!Template.instance().bgColor.get();
|
||||
},
|
||||
// Preview reflects the chosen font, size, text color and background 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 bg = colorValue(tpl.bgColor.get());
|
||||
return [
|
||||
family && `font-family: ${family};`,
|
||||
size && `font-size: ${size};`,
|
||||
color && `color: ${color};`,
|
||||
bg && `background-color: ${bg};`,
|
||||
].filter(Boolean).join('');
|
||||
},
|
||||
});
|
||||
|
||||
// Apply the current text/background colors immediately (no Save button).
|
||||
function applyUiColors(tpl) {
|
||||
Meteor.call('setUiColors', tpl.textColor.get(), tpl.bgColor.get(), err => {
|
||||
if (err && process.env.DEBUG === 'true') console.error('setUiColors error', err);
|
||||
});
|
||||
}
|
||||
|
||||
Template.changeFontPopup.events({
|
||||
// Click a font-name button -> apply that font immediately ('' = default/unset).
|
||||
'click .js-ui-font-btn'(event, tpl) {
|
||||
event.preventDefault();
|
||||
const font = event.currentTarget.dataset.font || null;
|
||||
tpl.selected.set(font || '');
|
||||
Meteor.call('setUiFont', font, err => {
|
||||
if (err && process.env.DEBUG === 'true') console.error('setUiFont error', err);
|
||||
});
|
||||
},
|
||||
// Click a size button -> apply that size immediately.
|
||||
'click .js-ui-font-size-btn'(event, tpl) {
|
||||
event.preventDefault();
|
||||
const size = event.currentTarget.dataset.size || 'default';
|
||||
tpl.selectedSize.set(size);
|
||||
Meteor.call('setUiFontSize', size, err => {
|
||||
if (err && process.env.DEBUG === 'true') console.error('setUiFontSize error', err);
|
||||
});
|
||||
},
|
||||
// Live-preview while dragging the wheel...
|
||||
'input .js-ui-text-color'(event, tpl) {
|
||||
const v = event.currentTarget.value;
|
||||
if (isHexColor6(v)) tpl.textColor.set(v);
|
||||
},
|
||||
'input .js-ui-bg-color'(event, tpl) {
|
||||
const v = event.currentTarget.value;
|
||||
if (isHexColor6(v)) tpl.bgColor.set(v);
|
||||
},
|
||||
// ...and apply the color when the wheel is committed.
|
||||
'change .js-ui-text-color'(event, tpl) {
|
||||
const v = event.currentTarget.value;
|
||||
if (isHexColor6(v)) tpl.textColor.set(v);
|
||||
applyUiColors(tpl);
|
||||
},
|
||||
'change .js-ui-bg-color'(event, tpl) {
|
||||
const v = event.currentTarget.value;
|
||||
if (isHexColor6(v)) tpl.bgColor.set(v);
|
||||
applyUiColors(tpl);
|
||||
},
|
||||
'click .js-reset-text-color'(event, tpl) {
|
||||
event.preventDefault();
|
||||
tpl.textColor.set(null); // back to default
|
||||
applyUiColors(tpl);
|
||||
},
|
||||
'click .js-reset-bg-color'(event, tpl) {
|
||||
event.preventDefault();
|
||||
tpl.bgColor.set(null);
|
||||
applyUiColors(tpl);
|
||||
},
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import '/client/components/boards/boardsList.jade';
|
||||
import '/client/components/boards/miniboard.jade';
|
||||
import '/client/components/boards/originalPositionsView.jade';
|
||||
import '/client/components/boards/tableView.jade';
|
||||
import '/client/components/boards/statsView.jade';
|
||||
|
||||
import '/client/components/boards/boardArchive.js';
|
||||
import '/client/components/boards/boardBody.js';
|
||||
@@ -14,6 +15,7 @@ import '/client/components/boards/boardHeader.js';
|
||||
import '/client/components/boards/boardsList.js';
|
||||
import '/client/components/boards/originalPositionsView.js';
|
||||
import '/client/components/boards/tableView.js';
|
||||
import '/client/components/boards/statsView.js';
|
||||
|
||||
import '/client/components/boards/boardBody.css';
|
||||
import '/client/components/boards/dependencyOverlay.css';
|
||||
@@ -23,4 +25,5 @@ import '/client/components/boards/boardsList.css';
|
||||
import '/client/components/boards/calendarView.css';
|
||||
import '/client/components/boards/originalPositionsView.css';
|
||||
import '/client/components/boards/tableView.css';
|
||||
import '/client/components/boards/statsView.css';
|
||||
|
||||
@@ -5,6 +5,7 @@ import '/client/components/main/dueCards.jade';
|
||||
import '/client/components/main/editor.jade';
|
||||
import '/client/components/main/globalSearch.jade';
|
||||
import '/client/components/main/header.jade';
|
||||
import '/client/components/main/themeColorPicker.jade';
|
||||
import '/client/components/main/keyboardShortcuts.jade';
|
||||
import '/client/components/main/layouts.jade';
|
||||
import '/client/components/main/myCards.jade';
|
||||
@@ -29,6 +30,9 @@ import '/client/components/main/dueCards.js';
|
||||
import '/client/components/main/editor.js';
|
||||
import '/client/components/main/globalSearch.js';
|
||||
import '/client/components/main/header.js';
|
||||
import '/client/components/main/globalThemeColor.js';
|
||||
import '/client/components/main/themeColorPicker.js';
|
||||
import '/client/components/main/uiFont.js';
|
||||
import '/client/components/main/layouts.js';
|
||||
import '/client/components/main/myCards.js';
|
||||
import '/client/components/main/popup.js';
|
||||
|
||||
@@ -23,6 +23,8 @@ import '/client/components/settings/migrationProgress.js';
|
||||
import '/client/components/settings/peopleBody.js';
|
||||
import '/client/components/settings/settingBody.js';
|
||||
import '/client/components/settings/settingHeader.js';
|
||||
import '/client/components/settings/problemsSummary.jade';
|
||||
import '/client/components/settings/problemsSummary.js';
|
||||
import '/client/components/settings/translationBody.js';
|
||||
|
||||
import '/client/components/settings/attachments.css';
|
||||
|
||||
@@ -185,6 +185,17 @@ class DateFilter {
|
||||
|
||||
_getMongoSelector() {
|
||||
this._dep.depend();
|
||||
// #6483: cards with NO due/end/received date store the field as null — that
|
||||
// is exactly what the noDate filter matches ({dueAt: null}). A comparison
|
||||
// filter such as Overdue/"past" is `{$lte: now}`, and `$lte` MATCHES null
|
||||
// under FerretDB and minimongo (null sorts before dates; neither implements
|
||||
// MongoDB's type-bracketing), so "Overdue" listed every card that has no due
|
||||
// date at all. Require a real, non-null value for any comparison selector so
|
||||
// only actual dates match. The noDate filter (this._filter === null) returns
|
||||
// null unchanged and is unaffected.
|
||||
if (this._filter && typeof this._filter === 'object') {
|
||||
return { ...this._filter, $ne: null };
|
||||
}
|
||||
return this._filter;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { UI_FONTS } from '/models/lib/uiFonts';
|
||||
|
||||
// #4759: detect which curated UI fonts are actually installed in THIS browser, using
|
||||
// the canvas text-width comparison technique. A candidate font is "available" if
|
||||
// rendering a probe string in it produces a different width than the generic base
|
||||
// families — i.e. the browser actually had the font and didn't fall back.
|
||||
//
|
||||
// Returns an ordered subset of UI_FONTS. Purely a detection helper; the value the
|
||||
// user picks is still validated server-side against the whitelist.
|
||||
|
||||
const BASE_FONTS = ['monospace', 'sans-serif', 'serif'];
|
||||
const PROBE = 'mmmmmmmmmmlliWWWWWW';
|
||||
const PROBE_SIZE = '72px';
|
||||
|
||||
export function detectAvailableFonts(candidates = UI_FONTS) {
|
||||
let ctx;
|
||||
try {
|
||||
ctx = document.createElement('canvas').getContext('2d');
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
if (!ctx) return [];
|
||||
|
||||
const baseWidths = {};
|
||||
BASE_FONTS.forEach(base => {
|
||||
ctx.font = `${PROBE_SIZE} ${base}`;
|
||||
baseWidths[base] = ctx.measureText(PROBE).width;
|
||||
});
|
||||
|
||||
return candidates.filter(font =>
|
||||
BASE_FONTS.some(base => {
|
||||
ctx.font = `${PROBE_SIZE} "${font}", ${base}`;
|
||||
return ctx.measureText(PROBE).width !== baseWidths[base];
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -16,6 +16,8 @@ import { Template } from 'meteor/templating';
|
||||
import { ReactiveVar } from 'meteor/reactive-var';
|
||||
import { Tracker } from 'meteor/tracker';
|
||||
import { EscapeActions } from '/client/lib/escapeActions';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { isSubmitKey } from '/models/lib/editorSubmitKey';
|
||||
import {
|
||||
openForm,
|
||||
closeForm,
|
||||
@@ -77,7 +79,10 @@ Template.inlinedForm.events({
|
||||
openForm(currentlyOpenedForm, tpl);
|
||||
},
|
||||
'keydown form textarea'(evt, tpl) {
|
||||
if (evt.keyCode === 13 && (evt.metaKey || evt.ctrlKey)) {
|
||||
// Ctrl/Cmd+Enter submits by default; a user who enabled "submit on Enter"
|
||||
// in Member Settings submits on plain Enter (Shift+Enter for a newline).
|
||||
const submitOnEnter = !!ReactiveCache.getCurrentUser()?.hasSubmitOnEnter?.();
|
||||
if (isSubmitKey(evt, { submitOnEnter })) {
|
||||
tpl.find('button[type=submit]').click();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { Session } from 'meteor/session';
|
||||
import { Template } from 'meteor/templating';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
|
||||
@@ -307,6 +308,27 @@ hotkeys('-', archiveCard);
|
||||
// https://github.com/wekan/wekan/pull/5589#issuecomment-2516776519
|
||||
hotkeys('\xf7', archiveCard);
|
||||
|
||||
// #6478: Undo / Redo the caller's last position change (card / list / swimlane
|
||||
// move) with Ctrl+Z / Ctrl+Y (also Cmd+Z / Cmd+Shift+Z on macOS). The hotkeys
|
||||
// filter above disables shortcuts inside inputs/textareas/contentEditable, so
|
||||
// native text undo/redo keeps working while typing. Only enabled for members who
|
||||
// can modify the board.
|
||||
function undoRedoLast(method) {
|
||||
const boardId = Session.get('currentBoard');
|
||||
if (!boardId || !Utils.canModifyBoard()) {
|
||||
return;
|
||||
}
|
||||
Meteor.call(method, boardId, () => {});
|
||||
}
|
||||
hotkeys('ctrl+z, command+z', event => {
|
||||
event.preventDefault();
|
||||
undoRedoLast('userPositionHistory.undoLast');
|
||||
});
|
||||
hotkeys('ctrl+y, ctrl+shift+z, command+shift+z', event => {
|
||||
event.preventDefault();
|
||||
undoRedoLast('userPositionHistory.redoLast');
|
||||
});
|
||||
|
||||
hotkeys('n', (event) => {
|
||||
event.preventDefault();
|
||||
const cardId = getSelectedCardId();
|
||||
|
||||
+27
-7
@@ -1,6 +1,6 @@
|
||||
import { Mongo } from 'meteor/mongo';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { Filter } from '/client/lib/filter';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import { listCardsSelector } from '/models/lib/swimlaneFilter';
|
||||
const { windowCountId: windowCountIdImpl, resolveCardsLoadingMode } = require('/models/lib/cardsLoading');
|
||||
|
||||
@@ -10,19 +10,39 @@ const { windowCountId: windowCountIdImpl, resolveCardsLoadingMode } = require('/
|
||||
// in). Only used in lazy card-loading mode.
|
||||
export const BoardListCardCounts = new Mongo.Collection('boardListCardCounts');
|
||||
|
||||
// Client-only mirror of the per-board `boardCardLoadingModes` docs published by
|
||||
// the 'boardCardsLoadingMode' publication: { _id: boardId, lazy: bool }. In 'auto'
|
||||
// mode this is how the client learns whether a given board loads lazily (by size),
|
||||
// so client rendering agrees with the server board publication per board. #6480.
|
||||
export const BoardCardLoadingModes = new Mongo.Collection('boardCardLoadingModes');
|
||||
|
||||
// Effective card-loading mode. Prefers the admin Setting doc (reactive, so an
|
||||
// Admin Panel change is picked up), falls back to the public env value, then to
|
||||
// 'all'. See server/cards-loading.js and Admin Panel / Features.
|
||||
export function cardsLoadingMode() {
|
||||
const setting = ReactiveCache.getCurrentSetting();
|
||||
const fromDoc = setting && setting.cardsLoading;
|
||||
// Card loading is not an admin toggle: the mode comes from the CARDS_LOADING env
|
||||
// var (default 'auto'), mirrored to Meteor.settings.public by the server. 'auto'
|
||||
// then decides per board by size (see isLazyCards / the boardCardLoadingModes flag).
|
||||
const fromEnv =
|
||||
Meteor.settings && Meteor.settings.public && Meteor.settings.public.cardsLoading;
|
||||
return resolveCardsLoadingMode(fromDoc || fromEnv);
|
||||
return resolveCardsLoadingMode(fromEnv);
|
||||
}
|
||||
|
||||
export function isLazyCards() {
|
||||
return cardsLoadingMode() === 'lazy';
|
||||
// Whether the given board (default: the current board) loads lazily. In 'auto'
|
||||
// mode this is decided PER BOARD by size on the server and delivered via the
|
||||
// `boardCardLoadingModes` flag; explicit 'all'/'lazy' apply globally. When the flag
|
||||
// has not arrived yet in 'auto' mode we default to EAGER, so small boards keep the
|
||||
// unchanged full-featured path and only a confirmed-big board switches to windows.
|
||||
export function isLazyCards(boardId) {
|
||||
const bid = boardId || (Utils && Utils.getCurrentBoardId && Utils.getCurrentBoardId());
|
||||
if (bid) {
|
||||
const doc = BoardCardLoadingModes.findOne(bid);
|
||||
if (doc) return !!doc.lazy;
|
||||
}
|
||||
const mode = cardsLoadingMode();
|
||||
if (mode === 'lazy') return true;
|
||||
if (mode === 'all') return false;
|
||||
return false; // 'auto', flag not yet known -> eager until the board is confirmed big
|
||||
}
|
||||
|
||||
// Stable id for one (list, swimlane) window's count doc (shared, unit-tested).
|
||||
@@ -39,7 +59,7 @@ export function windowCountId(listId, swimlaneId) {
|
||||
// list.cards() runs against minimongo, the server count is exactly the accurate
|
||||
// version of whatever list.cards(swimlaneId).length would report.
|
||||
export function lazyListCardCount(list, swimlaneId) {
|
||||
if (!isLazyCards() || !list || !list.boardId) return null;
|
||||
if (!list || !list.boardId || !isLazyCards(list.boardId)) return null;
|
||||
const selector = listCardsSelector(
|
||||
list._id,
|
||||
swimlaneId,
|
||||
|
||||
+10
-1
@@ -136,7 +136,16 @@ window.Popup = new (class {
|
||||
|
||||
return function(evt, tpl) {
|
||||
const context = (this.currentData && this.currentData()) || this;
|
||||
context.__afterConfirmAction = action;
|
||||
// #6479: store the pending confirm action on the Popup instance, NOT on the
|
||||
// Blaze data context. The old code stashed it as a field on the context and
|
||||
// the confirm button read it back as `this.__afterConfirmAction`; but the
|
||||
// context is a ReactiveCache/Minimongo document (e.g. a board member sub-doc),
|
||||
// and once Blaze re-renders the confirmation popup with a fresh/immutable copy
|
||||
// of that context the field is gone, so clicking "Remove member" (and every
|
||||
// other confirm dialog) silently did nothing. The instance field survives the
|
||||
// re-render; the action is still invoked with the confirmation popup's own data
|
||||
// context as `this`, so `this.userId` etc. keep working.
|
||||
self._afterConfirmAction = action;
|
||||
self.open(name).call(context, evt, tpl);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,7 +26,9 @@ import '/client/components/import/import.css';
|
||||
import '/client/components/lists/list.css';
|
||||
import '/client/components/main/accessibility.css';
|
||||
import '/client/components/main/brokenCards.css';
|
||||
import '/client/components/main/customTheme.css';
|
||||
import '/client/components/main/dueCards.css';
|
||||
import '/client/components/main/uiFont.css';
|
||||
import '/client/components/main/editor.css';
|
||||
import '/client/components/main/fonts.css';
|
||||
import '/client/components/main/globalSearch.css';
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { Session } from 'meteor/session';
|
||||
import { Tracker } from 'meteor/tracker';
|
||||
import { TAPi18n } from '/imports/i18n';
|
||||
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { decideSandstormAutoOpen } from '/models/lib/sandstormAutoOpen';
|
||||
import Settings from '/models/settings';
|
||||
import { EscapeActions } from '/client/lib/escapeActions';
|
||||
import { Filter } from '/client/lib/filter';
|
||||
@@ -70,10 +72,91 @@ function renderBoardList(ctx, menu) {
|
||||
});
|
||||
}
|
||||
|
||||
// #2220: on the FIRST landing on '/' this session (i.e. right after login), send
|
||||
// the user to their chosen default "home" board. Only once per session, so a
|
||||
// later click on "All Boards" stays on the list, and the choice is honoured again
|
||||
// on the next login / full reload. Returns true when it redirected.
|
||||
function maybeRedirectToDefaultBoard() {
|
||||
if (Session.get('defaultBoardRedirectDone')) return false;
|
||||
Session.set('defaultBoardRedirectDone', true);
|
||||
|
||||
if (!Meteor.userId()) return false;
|
||||
const user = ReactiveCache.getCurrentUser();
|
||||
const boardId = user && user.getDefaultBoardId && user.getDefaultBoardId();
|
||||
if (!boardId) return false;
|
||||
|
||||
// Redirect by id right away — at login the board's own subscription may not have
|
||||
// loaded yet, so use its real slug when it happens to be cached and a harmless
|
||||
// placeholder otherwise (the board route resolves by :id). If the board was
|
||||
// deleted / access was removed, the board route degrades gracefully and the
|
||||
// "All Boards" link still works (this only redirects once per session).
|
||||
const board = ReactiveCache.getBoard(boardId);
|
||||
FlowRouter.go('board', { id: boardId, slug: (board && board.slug) || 'board' });
|
||||
return true;
|
||||
}
|
||||
|
||||
// #2220 on Sandstorm: a grain historically opened straight into its single board.
|
||||
// Restore that convenience — reactively, because a grain's login (Meteor.userId())
|
||||
// and its boards subscription both arrive asynchronously after '/' first renders.
|
||||
// Runs at most once per page load (grain session): a saved Home board wins; else
|
||||
// exactly one board just opens (nothing saved — choosing a Home board is the separate
|
||||
// explicit All Boards toggle); else (zero or many boards, nothing saved) we stay on
|
||||
// the All Boards list. The decision itself is the pure, unit-tested
|
||||
// decideSandstormAutoOpen(). Sandstorm-only (see the isSandstorm gate in the route).
|
||||
let sandstormAutoOpenStarted = false;
|
||||
function startSandstormAutoOpen() {
|
||||
if (sandstormAutoOpenStarted) return;
|
||||
sandstormAutoOpenStarted = true;
|
||||
|
||||
const boardsHandle = Meteor.subscribe('boards');
|
||||
Tracker.autorun(computation => {
|
||||
const userReady = !!Meteor.userId();
|
||||
const user = userReady ? ReactiveCache.getCurrentUser() : null;
|
||||
const savedDefaultId = user && user.getDefaultBoardId && user.getDefaultBoardId();
|
||||
const boardsReady = boardsHandle.ready();
|
||||
|
||||
let boardCount = 0;
|
||||
let onlyBoardId;
|
||||
if (userReady && boardsReady) {
|
||||
const boards = ReactiveCache.getBoards({
|
||||
archived: false,
|
||||
type: 'board',
|
||||
'members.userId': Meteor.userId(),
|
||||
});
|
||||
boardCount = boards.length;
|
||||
if (boardCount === 1) onlyBoardId = boards[0]._id;
|
||||
}
|
||||
|
||||
const decision = decideSandstormAutoOpen({
|
||||
userReady,
|
||||
savedDefaultId,
|
||||
boardsReady,
|
||||
boardCount,
|
||||
onlyBoardId,
|
||||
});
|
||||
|
||||
if (decision.action === 'wait') return; // inputs not ready — keep watching
|
||||
if (decision.action === 'redirect') {
|
||||
// Just open the board — nothing is persisted here.
|
||||
const board = ReactiveCache.getBoard(decision.boardId);
|
||||
FlowRouter.go('board', { id: decision.boardId, slug: (board && board.slug) || 'board' });
|
||||
}
|
||||
computation.stop(); // decided (redirected or staying) — done for this page load
|
||||
});
|
||||
}
|
||||
|
||||
FlowRouter.route('/', {
|
||||
name: 'home',
|
||||
triggersEnter: [ensureSignedInUnlessSandstorm],
|
||||
action() {
|
||||
// On Sandstorm, render All Boards immediately and let the reactive auto-open
|
||||
// (above) redirect once the grain login + boards have loaded, if appropriate.
|
||||
if (isSandstorm) {
|
||||
startSandstormAutoOpen();
|
||||
renderBoardList(this, 'starred');
|
||||
return;
|
||||
}
|
||||
if (maybeRedirectToDefaultBoard()) return;
|
||||
renderBoardList(this, 'starred');
|
||||
},
|
||||
});
|
||||
|
||||
+17
-7
@@ -59,10 +59,15 @@ services:
|
||||
chmod +x "$$BIN"
|
||||
fi
|
||||
mkdir -p /data/files/db
|
||||
# #6480/#6481: --repl-set-name makes FerretDB v1 auto-create the capped
|
||||
# local.oplog.rs and advertise the replica-set hello handshake, so WeKan's
|
||||
# Meteor can TAIL the OpLog instead of poll-and-diff (fixes high CPU on
|
||||
# busy boards). Pair with MONGO_OPLOG_URL in the wekan service below.
|
||||
exec "$$BIN" \
|
||||
--handler=sqlite \
|
||||
--sqlite-url=file:/data/files/db/ \
|
||||
--listen-addr=0.0.0.0:27017 \
|
||||
--repl-set-name=rs0 \
|
||||
--telemetry=disable \
|
||||
--log-level=error
|
||||
volumes:
|
||||
@@ -125,9 +130,13 @@ services:
|
||||
# https://github.com/meteor/meteor/blob/release-3.5/v3-docs/docs/performance/change-streams-observer-driver.md#choosing-the-reactivity-driver-order
|
||||
# Keep oplog/polling fallback available for deployments that cannot use Change Streams:
|
||||
# https://github.com/wekan/wekan/issues/6307#issuecomment-4299349231
|
||||
# FerretDB has no MongoDB change streams / replica-set oplog, so use polling:
|
||||
- DEFAULT_METEOR_REACTIVITY_ORDER=polling
|
||||
- METEOR_REACTIVITY_ORDER=polling
|
||||
# wekan/wekan#6480/#6481: FerretDB v1 now ships an OpLog (the ferretdb service
|
||||
# above runs with --repl-set-name=rs0), so prefer OpLog tailing and fall back
|
||||
# to polling — this is the main fix for FerretDB's high CPU on busy boards.
|
||||
# FerretDB has no MongoDB change streams. To force polling only, set both to
|
||||
# `polling` and comment out MONGO_OPLOG_URL below.
|
||||
- DEFAULT_METEOR_REACTIVITY_ORDER=oplog,polling
|
||||
- METEOR_REACTIVITY_ORDER=oplog,polling
|
||||
# uws does not work at s390x. sockjs works.
|
||||
#- DDP_TRANSPORT=uws
|
||||
- DDP_TRANSPORT=sockjs
|
||||
@@ -194,10 +203,11 @@ services:
|
||||
# - Latency from 50ms to 2000ms
|
||||
# Must configure MongoDB replica set first
|
||||
# See: https://blog.meteor.com/tuning-meteor-mongo-livedata-for-scalability-13fe9deb8908
|
||||
# FerretDB emulates OpLog via --replication-set-name=rs0.
|
||||
# HIGHLY RECOMMENDED — enables real-time DDP updates (sub-100ms latency).
|
||||
#- MONGO_OPLOG_URL=mongodb://wekan-ferretdb:27017/local?replicaSet=rs0
|
||||
# MONGO_OPLOG_URL intentionally unset: FerretDB v1 has no replica-set oplog (WeKan uses polling).
|
||||
# FerretDB v1 emulates the OpLog via --repl-set-name=rs0 (set on the ferretdb
|
||||
# service above). This enables real-time DDP updates and stops the poll-and-diff
|
||||
# CPU burn (wekan/wekan#6480/#6481). To force polling only, comment this out and
|
||||
# set METEOR_REACTIVITY_ORDER=polling above.
|
||||
- MONGO_OPLOG_URL=mongodb://ferretdb:27017/local?replicaSet=rs0
|
||||
#---------------------------------------------------------------
|
||||
# ==== OPTIONAL: KADIRA PERFORMANCE MONITORING FOR METEOR ====
|
||||
# https://github.com/edemaine/kadira-compose
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
[Sandstorm](../Platforms/FOSS/Sandstorm/Sandstorm.md) - [Sandstorm Backup](../Platforms/FOSS/Sandstorm/Export-from-Wekan-Sandstorm-grain-.zip-file.md)
|
||||
# Backup Sandstorm
|
||||
|
||||
# Related
|
||||
1. Please first backup your Sandstorm https://docs.sandstorm.io/en/latest/administering/backups/
|
||||
2. Please first download all of your grains to .zip files from top menu row down arrow button, just in case.
|
||||
|
||||
[Transferring attachments from MongoDB to filesystem, and text from MongoDB to SQLite](https://github.com/wekan/minio-metadata)
|
||||
# Upgrade Sandstorm WeKan
|
||||
|
||||
[Upgrade Sandstorm WeKan ](../Platforms/FOSS/Sandstorm/Sandstorm.md)
|
||||
|
||||
# Backup Docker
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ Related, for creating apps to other appstores: [Browser compatibility matrix](Br
|
||||
|
||||
2. PWABuilder has about 100+ downloadable icons etc webmanifest requirements like is at [public](../../public)
|
||||
|
||||
3. Clone WeKan repo, add favicons etc from step 2 to wekan/public/ , and build WeKan bundle from source like at [Emoji](../Features/Emoji.md) . Note: Currently WeKan does not have feature for custom favicons, it would require a lot of work for 100+ favicons etc customizations.
|
||||
3. Clone WeKan repo, add favicons etc from step 2 to wekan/public/ , and build WeKan bundle from source like at [Emoji](../Features/Editor/Emoji.md) . Note: Currently WeKan does not have feature for custom favicons, it would require a lot of work for 100+ favicons etc customizations.
|
||||
|
||||
4. Run bundle at server like [Offline](../Platforms/Propietary/Windows/Offline.md) or [Raspberry Pi](../Platforms/FOSS/RaspberryPi/Raspberry-Pi.md)
|
||||
|
||||
|
||||
@@ -20,4 +20,4 @@ https://user-images.githubusercontent.com/70445835/184324324-b4bd466e-6873-4eef-
|
||||
|
||||
* [Calendar issues](https://github.com/wekan/wekan/issues?q=is%3Aissue+is%3Aopen+calendar+label%3AFeature%3ACalendar-view)
|
||||
* [Time Tracking](Time-Tracking.md)
|
||||
* [Gantt Chart](../Features/Gantt.md)
|
||||
* [Gantt Chart](../Features/Reports/Gantt.md)
|
||||
@@ -0,0 +1,152 @@
|
||||
# Dev: Snap `core` base vs multi-platform builds
|
||||
|
||||
Developer notes on why the `ppc64el` and `s390x` snap legs of `release-all.yml`
|
||||
fail, what the constraint actually is (verified from the action's source, not the
|
||||
error string), and the only supported way to build these architectures for a
|
||||
`core24` snap.
|
||||
|
||||
## TL;DR
|
||||
|
||||
- WeKan's `snapcraft.yaml` uses **`base: core24`**.
|
||||
- The `snap-qemu` job builds `ppc64el` / `s390x` with
|
||||
**`diddlesnaps/snapcraft-multiarch-action@v1`**, whose newest supported base is
|
||||
**`core22`**. It rejects `core24` before any build starts.
|
||||
- That action is unmaintained, and there is **no maintained QEMU multi-arch snap
|
||||
action that supports `core24`**.
|
||||
- The only `core24`-capable path for architectures that have **no native GitHub
|
||||
runner** (`ppc64el`, `s390x`, `riscv64`) is **Launchpad `snapcraft remote-build`**
|
||||
— already used for `riscv64` in the `snap-launchpad` job.
|
||||
|
||||
## The failure
|
||||
|
||||
Logs (`release-all.yml` → `snap-qemu` matrix legs `ppc64el` and `s390x`):
|
||||
|
||||
```
|
||||
snapcraft.yaml is at version '10.01'. # version gate passed
|
||||
Building Snapcraft project in "."...
|
||||
##[error]Your build requires a base that this tool does not support (core24).
|
||||
'base' or 'build-base' in your 'snapcraft.yaml' must be one of 'core', 'core18' or 'core20'.
|
||||
```
|
||||
|
||||
The version check passes and QEMU sets up fine; the build dies the instant
|
||||
snapcraft reads `base: core24`. The other two log lines are harmless noise:
|
||||
|
||||
- `Failed to save: Unable to reserve cache with key docker.io--tonistiigi--binfmt-...`
|
||||
— a post-step cache warning from the QEMU binfmt image, not the cause.
|
||||
- `Node.js 20 is deprecated ...` — the old action running on a newer Node runtime.
|
||||
|
||||
## Why it fails — verified from the action's source
|
||||
|
||||
This is **not** just the error message; it is a hardcoded allow-list. The action
|
||||
is pinned at SHA `cfd7a246fad6bea65bb92f69a1c8d07898c231e5`. Its compiled
|
||||
`dist/index.js` (from `src/lib/build.js` + `src/lib/channel-matrix.js`) hardcodes a
|
||||
maximum base of `core22` in **three** independent places:
|
||||
|
||||
1. **Allow-list that throws** (`build.js`):
|
||||
|
||||
```js
|
||||
const base = await detectBase(this.projectRoot);
|
||||
if (!['core', 'core18', 'core20', 'core22'].includes(base)) {
|
||||
throw new Error(`Your build requires a base that this tool does not support (${base}). ...`);
|
||||
}
|
||||
```
|
||||
|
||||
`core24` is not in the list → immediate throw. (The error *message* is itself
|
||||
stale — it says "must be one of core/core18/core20" while the code actually also
|
||||
allows `core22`.)
|
||||
|
||||
2. **Container image tag** (`build.js`):
|
||||
|
||||
```js
|
||||
let containerImage = `diddledani/snapcraft:${base}`; // needs docker.io/diddledani/snapcraft:core24
|
||||
```
|
||||
|
||||
It runs snapcraft inside `docker.io/diddledani/snapcraft:<base>` under QEMU. The
|
||||
maintainer never published a `:core24` tag (tags stop at `core22`).
|
||||
|
||||
3. **Channel matrix** (`channel-matrix.js`):
|
||||
|
||||
```js
|
||||
switch (base) {
|
||||
case 'core22':
|
||||
case 'core20': return channel;
|
||||
case 'core18': ...
|
||||
case 'core': ...
|
||||
}
|
||||
throw new Error(`Snapcraft Channel '${channel}' is unsupported ... '${base}' Base Snap.`);
|
||||
```
|
||||
|
||||
`getChannel('core24', 'stable')` falls through every case and throws too.
|
||||
|
||||
**Maximum base supported by `diddlesnaps/snapcraft-multiarch-action@v1`: `core22`.**
|
||||
The action was last updated for `core22` and abandoned; `core24` (and `core26`)
|
||||
are not supported and will not be by `@v1`.
|
||||
|
||||
## Source repositories
|
||||
|
||||
The failing action and its build image:
|
||||
|
||||
| What | Repo |
|
||||
|---|---|
|
||||
| `snapcraft-multiarch-action` (the failing job) | https://github.com/diddlesnaps/snapcraft-multiarch-action — pinned [`cfd7a246`](https://github.com/diddlesnaps/snapcraft-multiarch-action/tree/cfd7a246fad6bea65bb92f69a1c8d07898c231e5); evidence in [`dist/index.js`](https://github.com/diddlesnaps/snapcraft-multiarch-action/blob/cfd7a246fad6bea65bb92f69a1c8d07898c231e5/dist/index.js) |
|
||||
| `diddledani/snapcraft` Docker image (what it runs snapcraft inside) | https://github.com/diddledani/snapcraft — image: https://hub.docker.com/r/diddledani/snapcraft (no `core24` tag) |
|
||||
|
||||
snapcraft and the other actions used by the snap jobs:
|
||||
|
||||
| What | Repo |
|
||||
|---|---|
|
||||
| snapcraft (the tool) | https://github.com/canonical/snapcraft |
|
||||
| `snapcore/action-build` (used by `snap-native`) | https://github.com/snapcore/action-build |
|
||||
| `snapcore/action-publish` (used by `snap-native` / `snap-qemu`) | https://github.com/snapcore/action-publish |
|
||||
| `docker/setup-qemu-action` (QEMU setup step) | https://github.com/docker/setup-qemu-action |
|
||||
| `tonistiigi/binfmt` (the QEMU binfmt image in the cache warning) | https://github.com/tonistiigi/binfmt |
|
||||
|
||||
The base snaps (`core*`):
|
||||
|
||||
| Base | Repo |
|
||||
|---|---|
|
||||
| `core` | https://github.com/canonical/core |
|
||||
| `core18` | https://github.com/canonical/core18 |
|
||||
| `core20` | https://github.com/canonical/core20 |
|
||||
| `core22` | https://github.com/snapcore/core22 |
|
||||
| `core24` (WeKan's base) | https://github.com/canonical/core-base (mirror: https://github.com/snapcore/core-base) — the modern base snaps were consolidated into one `core-base` repo, so there is no standalone `canonical/core24`. |
|
||||
|
||||
## How the snap jobs are split in `release-all.yml`
|
||||
|
||||
| Job | Arches | Mechanism | `core24`? |
|
||||
|---|---|---|---|
|
||||
| `snap-native` | `amd64`, `arm64` | `snapcore/action-build` on a **native** runner | Yes |
|
||||
| `snap-qemu` | `ppc64el`, `s390x` | `diddlesnaps/snapcraft-multiarch-action` under QEMU | **No — caps at core22** |
|
||||
| `snap-launchpad` | `riscv64` | `snapcraft remote-build` on Launchpad | Yes |
|
||||
|
||||
`amd64` / `arm64` have native runners, so `action-build` works and is fastest.
|
||||
`ppc64el` / `s390x` / `riscv64` have **no native GitHub runner**, and snapcraft
|
||||
cannot cross-build a snap — so they need either QEMU emulation (which can't do
|
||||
`core24`) or Launchpad.
|
||||
|
||||
## The fix
|
||||
|
||||
Because no QEMU multi-arch action supports `core24`, `ppc64el` and `s390x` cannot
|
||||
stay in `snap-qemu`. Move them to Launchpad `remote-build` alongside `riscv64`:
|
||||
|
||||
- Add `ppc64el` and `s390x` to the `snap-launchpad` matrix.
|
||||
- Delete the dead `snap-qemu` job.
|
||||
|
||||
The current `snap-launchpad` job is more robust than the older Launchpad legs that
|
||||
were originally replaced by `snap-qemu`: it **requires the `.snap` file to exist,
|
||||
retries the remote build 3×, and only uploads when the file is present**, which
|
||||
guards the old "is not a valid file" (`snapcraft upload`, exit 64) failure.
|
||||
|
||||
### Historical note
|
||||
|
||||
`ppc64el` / `s390x` were once moved *off* Launchpad *onto* QEMU because the old
|
||||
Launchpad legs ended in Launchpad state "Stopped" with no snap, then failed at
|
||||
`snapcraft upload`. That escape route is now a dead end for `core24`, and the
|
||||
hardened `snap-launchpad` job addresses the upload failure that prompted the move.
|
||||
|
||||
## If you ever go back to a QEMU-style multi-arch build
|
||||
|
||||
You would need a maintained action/image that ships a `core24`-capable snapcraft
|
||||
(snapcraft 8.x) and a matching `core24` build container for the target arch. As of
|
||||
this writing none exists as a drop-in; Canonical's supported answer for non-native
|
||||
architectures on `core24` is Launchpad `remote-build`.
|
||||
@@ -0,0 +1,302 @@
|
||||
# Dev: WeKan for Nextcloud — building, publishing to the App Store, and integrations
|
||||
|
||||
What must be added to the WeKan GitHub repos so that WeKan's GitHub Actions can build a
|
||||
**Nextcloud-deployable** WeKan (using WeKan's existing Docker image), how to **publish it
|
||||
to the Nextcloud App Store**, and what **integrations** are possible (local AI, Deck,
|
||||
SSO, email, …).
|
||||
|
||||
WeKan already publishes public Docker images from release-all.yml's `docker` job —
|
||||
`wekanteam/wekan`, `quay.io/wekan/wekan`, `ghcr.io/wekan/wekan` — and already ships OIDC
|
||||
support (`packages/wekan-oidc/`), both of which the Nextcloud paths below build on.
|
||||
|
||||
## Remaining steps
|
||||
|
||||
> **Status:** the `nextcloud` job **is already wired into `release-all.yml`** — guarded, it
|
||||
> skips with a `::notice::` until the items below are done, so it never breaks a release.
|
||||
|
||||
1. **Create the ExApp repo `wekan/nextcloud`**: `appinfo/info.xml` with the
|
||||
`<external-app>` block, the Node **AppAPI-handshake wrapper** (`/heartbeat`, `/enabled`,
|
||||
`/init`, `AUTHORIZATION-APP-API` → WeKan user mapping), and a `Dockerfile` (WeKan +
|
||||
embedded FerretDB SQLite). See §A1–A3.
|
||||
2. **Get the app signing certificate**: generate a key + CSR, PR the CSR to
|
||||
`nextcloud/app-certificate-requests`, receive `wekan.crt`; then **register** the app at
|
||||
`apps.nextcloud.com/developer/apps/new`. See §B2–B3.
|
||||
3. **Add secrets** `NEXTCLOUD_APP_PRIVATE_KEY`, `NEXTCLOUD_APP_PUBLIC_CRT`,
|
||||
`NEXTCLOUD_APPSTORE_TOKEN`; **extend `WEKAN_REPO_TOKEN`** to `wekan/nextcloud`.
|
||||
4. **Require HaRP** deploy daemon + Nextcloud 30/31+ (WebSocket/DDP) in the app docs; the
|
||||
old proxy/Docker-Socket-Proxy can't carry WeKan's realtime.
|
||||
5. Test-deploy the ExApp on a Nextcloud + HaRP instance: OIDC login, DDP realtime,
|
||||
attachments, and an AI `taskprocessing` call.
|
||||
|
||||
**How to add a secret:** GitHub → the `wekan/wekan` repo → **Settings → Secrets and
|
||||
variables → Actions → New repository secret**; or `gh secret set NAME --repo wekan/wekan`
|
||||
(paste the value) / `gh secret set NAME --repo wekan/wekan < file`. For the private key:
|
||||
`gh secret set NEXTCLOUD_APP_PRIVATE_KEY --repo wekan/wekan < ~/.nextcloud/certificates/wekan.key`.
|
||||
|
||||
---
|
||||
|
||||
## 0. Two ways to ship on Nextcloud — and which one fits WeKan
|
||||
|
||||
| Track | What it is | Runtime | Fits WeKan? |
|
||||
|---|---|---|---|
|
||||
| **Classic App Store app** | PHP app running **inside** the Nextcloud process | Nextcloud PHP | ❌ WeKan is Meteor/Node — can't run in PHP |
|
||||
| **External App (ExApp) via AppAPI** | App backend runs as an **external Docker container**, managed by Nextcloud's **AppAPI** and a **Deploy Daemon** | any language / Docker | ✅ **This is WeKan's path** — it already has a Docker image |
|
||||
|
||||
**Key reality check:** AppAPI is **not a generic reverse proxy**. WeKan's current image
|
||||
**will not deploy as-is** — the container must implement the **AppAPI handshake**. So
|
||||
"use the WeKan Docker image at Nextcloud" means: build a thin **ExApp wrapper** around it,
|
||||
add an `appinfo/info.xml` with an `<external-app>` block, and publish that to the App
|
||||
Store (the image lives in a registry the app references).
|
||||
|
||||
---
|
||||
|
||||
## A. Building WeKan for Nextcloud (what to add to the repos)
|
||||
|
||||
### A1. A new repo: `wekan/nextcloud` (the ExApp)
|
||||
An ExApp is a Nextcloud app folder + a container:
|
||||
|
||||
```
|
||||
wekan-exapp/
|
||||
├── appinfo/
|
||||
│ └── info.xml # metadata + <external-app> (docker image + routes)
|
||||
├── ex_app/
|
||||
│ ├── lib/ # the AppAPI-handshake wrapper (Node) — see A2
|
||||
│ └── src/ # optional Vue frontend (adds a WeKan TopMenu entry)
|
||||
└── Dockerfile # builds the ExApp image (wrapper + WeKan + DB)
|
||||
```
|
||||
|
||||
`appinfo/info.xml` — the distinguishing `<external-app>` block (scopes were removed in
|
||||
AppAPI 3.2.0; access is per-route via `access_level`):
|
||||
|
||||
```xml
|
||||
<external-app>
|
||||
<docker-install>
|
||||
<registry>ghcr.io</registry>
|
||||
<image>wekan/wekan-exapp</image>
|
||||
<image-tag>v10.00</image-tag>
|
||||
</docker-install>
|
||||
<routes>
|
||||
<route>
|
||||
<url>.*</url>
|
||||
<verb>GET,POST,PUT,DELETE</verb>
|
||||
<access_level>USER</access_level> <!-- PUBLIC | USER | ADMIN -->
|
||||
<headers_to_exclude>[]</headers_to_exclude>
|
||||
<bruteforce_protection>[401]</bruteforce_protection>
|
||||
</route>
|
||||
</routes>
|
||||
</external-app>
|
||||
```
|
||||
|
||||
### A2. The AppAPI-handshake wrapper (the real work — there is no Node ExApp SDK)
|
||||
On deploy, AppAPI injects env vars (`APP_SECRET`, `APP_ID`, `APP_PORT`, `APP_HOST`,
|
||||
`NEXTCLOUD_URL`, `APP_PERSISTENT_STORAGE`, …) and drives a lifecycle the container **must**
|
||||
implement. Add a small Node/Express service (a sidecar, or routes fronting WeKan) that:
|
||||
|
||||
1. Serves **`GET /heartbeat`** → `{"status":"ok"}` (unauthenticated; polled up to ~90 s).
|
||||
2. Handles **`PUT /enabled?enabled=1|0`** → registers/removes the WeKan menu entry,
|
||||
returns `{"error":""}`.
|
||||
3. Optionally **`POST /init`** → returns `{}` immediately, then reports progress `1..100`
|
||||
via `PUT /ocs/v1.php/apps/app_api/ex-app/status`.
|
||||
4. Binds WeKan on `APP_PORT`/`APP_HOST`, stores data under `APP_PERSISTENT_STORAGE`.
|
||||
5. Trusts Nextcloud-proxied requests via the shared-secret headers
|
||||
(`AUTHORIZATION-APP-API` = `base64(userid:secret)`, `EX-APP-ID`, `EX-APP-VERSION`,
|
||||
`AA-VERSION`) and **maps the Nextcloud `userid` to a WeKan user** (JIT-provision).
|
||||
6. To call back into Nextcloud, sends the same `AUTHORIZATION-APP-API` header.
|
||||
|
||||
Mirror the contract from the Python skeleton (`nextcloud/app-skeleton-python`) and the
|
||||
Authentication/Deployment docs (§B). `nc_py_api` supplies this for Python; **Node has no
|
||||
official SDK**, so it's a small custom service.
|
||||
|
||||
### A3. Two hard constraints for WeKan specifically
|
||||
- **WebSockets/DDP:** WeKan's realtime uses DDP over WebSockets. The **old** AppAPI proxy
|
||||
did **not** support WebSockets — you must use the **HaRP** deploy daemon (Nextcloud
|
||||
30/31+), which routes directly to the ExApp and supports WebSockets. (The classic
|
||||
**Docker Socket Proxy** daemon is deprecated, slated for removal in Nextcloud 35.)
|
||||
- **Database:** AppAPI deploys a **single image**. WeKan's DB (FerretDB v1 SQLite, or
|
||||
MongoDB) must be **bundled inside the ExApp image** (embedded `WEKAN_DB=ferretdb`, as
|
||||
the snap does) or provided as an external service the wrapper points at — it can't be a
|
||||
second compose service the way UCS does it. Embedding **FerretDB v1 SQLite** (from
|
||||
[wekan/FerretDB](https://github.com/wekan/FerretDB)) with data under
|
||||
`APP_PERSISTENT_STORAGE` is the clean fit.
|
||||
|
||||
### A4. `release-all.yml` — a new `nextcloud` job
|
||||
After the `docker` job publishes the WeKan image, add a job that:
|
||||
1. Checks out `wekan/nextcloud` (uses `WEKAN_REPO_TOKEN`).
|
||||
2. Builds & pushes the **ExApp image** (`ghcr.io/wekan/wekan-exapp:v<version>`) — wrapper
|
||||
+ WeKan bundle + embedded FerretDB.
|
||||
3. Regenerates `appinfo/info.xml` with the new `<image-tag>` and `<version>`.
|
||||
4. **Signs and uploads** the app release to the App Store (§B4).
|
||||
|
||||
### A5. Secrets/keys to add (`wekan/wekan`)
|
||||
| Secret | New/existing | What it is |
|
||||
|---|---|---|
|
||||
| `NEXTCLOUD_APP_PRIVATE_KEY` | **new** | The app signing **private key** (`APP_ID.key`) |
|
||||
| `NEXTCLOUD_APP_PUBLIC_CRT` | **new** | The Nextcloud-issued **certificate** (`APP_ID.crt`) |
|
||||
| `NEXTCLOUD_APPSTORE_TOKEN` | **new** | App Store developer API token (upload releases) |
|
||||
| `WEKAN_REPO_TOKEN` | **extend** | Add `wekan/nextcloud` to its repo scope |
|
||||
| `GHCR_AUTH`/`DOCKERHUB_AUTH` | existing | Already push WeKan images; reuse for the ExApp image |
|
||||
|
||||
---
|
||||
|
||||
## B. Publishing an app to the Nextcloud App Store (files, build, publish, docs)
|
||||
|
||||
Same store (apps.nextcloud.com) for both tracks; an ExApp just adds the `<external-app>`
|
||||
block and references a container image instead of shipping PHP.
|
||||
|
||||
### B1. Files to create
|
||||
- **`appinfo/info.xml`** — mandatory: `<id>` (= folder name), `<name lang="en">`,
|
||||
`<description lang="en">` (Markdown ok), `<version>` (semver), `<licence>` (SPDX,
|
||||
British spelling), `<author>`, `<bugs>` (tracker URL),
|
||||
`<dependencies><nextcloud min-version="30" max-version="35"/></dependencies>`.
|
||||
Optional: `<summary>`, `<category>` (e.g. `organization`, `tools`, `integration`,
|
||||
`workflow`, `ai`), `<screenshot>` (HTTPS), `<website>`, `<repository>`, `<donation>`.
|
||||
For an ExApp, add the `<external-app>` block (§A1).
|
||||
- **ExApp only:** `ex_app/` (backend + optional Vue frontend) and a **`Dockerfile`**.
|
||||
|
||||
### B2. Get a signing certificate (one-time)
|
||||
```sh
|
||||
mkdir -p ~/.nextcloud/certificates && cd ~/.nextcloud/certificates
|
||||
openssl req -nodes -newkey rsa:4096 -keyout APP_ID.key -out APP_ID.csr -subj "/CN=APP_ID"
|
||||
```
|
||||
Submit `APP_ID.csr` as a PR to **`nextcloud/app-certificate-requests`**; when merged you
|
||||
receive `APP_ID.crt`.
|
||||
|
||||
### B3. Register the app
|
||||
At `https://apps.nextcloud.com/developer/apps/new`, providing the public cert and a
|
||||
signature over the app id:
|
||||
```sh
|
||||
echo -n "APP_ID" | openssl dgst -sha512 -sign ~/.nextcloud/certificates/APP_ID.key | openssl base64
|
||||
```
|
||||
|
||||
### B4. Build, sign, and upload a release
|
||||
1. Package the app folder as `.tar.gz` (must contain `appinfo/info.xml`). **`krankerl`**
|
||||
automates packaging/signing.
|
||||
2. Sign the tarball:
|
||||
```sh
|
||||
openssl dgst -sha512 -sign ~/.nextcloud/certificates/APP_ID.key app.tar.gz | openssl base64
|
||||
```
|
||||
3. Upload — the store **fetches a download URL** (not a multipart upload):
|
||||
```
|
||||
POST https://apps.nextcloud.com/api/v1/apps/releases
|
||||
{ "download": "https://…/app.tar.gz", "signature": "<base64>", "nightly": false }
|
||||
```
|
||||
A pre-release id in the version (e.g. `-alpha.1`) → **beta** channel; `nightly:true` →
|
||||
**nightly**.
|
||||
4. **CI:** the documented GitHub Actions path uses **`R0Wi/nextcloud-appstore-push-action`**
|
||||
with secrets `APP_PRIVATE_KEY`, `APP_PUBLIC_CRT`, `APPSTORE_TOKEN` (community
|
||||
alternative: `skjnldsv/publish-nextcloud-app`).
|
||||
|
||||
### B5. Documentation (verified)
|
||||
| Topic | URL |
|
||||
|---|---|
|
||||
| AppAPI & External Apps (admin) | https://docs.nextcloud.com/server/latest/admin_manual/exapps_management/AppAPIAndExternalApps.html |
|
||||
| Managing Deploy Daemons | https://docs.nextcloud.com/server/latest/admin_manual/exapps_management/ManagingDeployDaemons.html |
|
||||
| Deploy Configurations (HaRP/DSP) | https://docs.nextcloud.com/server/latest/admin_manual/exapps_management/DeployConfigurations.html |
|
||||
| ExApp Overview (dev) | https://docs.nextcloud.com/server/latest/developer_manual/exapp_development/development_overview/ExAppOverview.html |
|
||||
| Deployment (`/heartbeat`, `/init`, env vars) | https://docs.nextcloud.com/server/latest/developer_manual/exapp_development/tech_details/Deployment.html |
|
||||
| Authentication (`AUTHORIZATION-APP-API`) | https://docs.nextcloud.com/server/latest/developer_manual/exapp_development/tech_details/Authentication.html |
|
||||
| Routes (`info.xml <routes>`) | https://docs.nextcloud.com/server/latest/developer_manual/exapp_development/tech_details/api/routes.html |
|
||||
| AppAPI source | https://github.com/nextcloud/app_api |
|
||||
| HaRP daemon | https://github.com/nextcloud/HaRP |
|
||||
| Python skeleton ExApp | https://github.com/nextcloud/app-skeleton-python |
|
||||
| nc_py_api (Python framework) | https://github.com/cloud-py-api/nc_py_api |
|
||||
| App Store Developer Guide | https://nextcloudappstore.readthedocs.io/en/latest/developer.html |
|
||||
| Code signing | https://docs.nextcloud.com/server/latest/developer_manual/app_publishing_maintenance/code_signing.html |
|
||||
| Publishing rules | https://docs.nextcloud.com/server/latest/developer_manual/app_publishing_maintenance/publishing.html |
|
||||
| Release automation (GitHub Actions) | https://docs.nextcloud.com/server/latest/developer_manual/app_publishing_maintenance/release_automation.html |
|
||||
| krankerl CLI | https://github.com/ChristophWurst/krankerl |
|
||||
| app-certificate-requests | https://github.com/nextcloud/app-certificate-requests |
|
||||
| Release upload API | https://apps.nextcloud.com/api/v1/apps/releases |
|
||||
|
||||
---
|
||||
|
||||
## C. Integrations possible on the Nextcloud platform
|
||||
|
||||
Two integration modes, and the distinction matters:
|
||||
|
||||
- **WeKan as an outbound OCS API client** (works today, WeKan stays external, no ExApp
|
||||
needed): AI, Deck, Provisioning, WebDAV/CalDAV.
|
||||
- **In-Nextcloud UI surfaces** (need a companion app / ExApp running inside Nextcloud):
|
||||
Dashboard, Unified Search, Notifications, Talk bots, Flow.
|
||||
|
||||
### C1. Using Nextcloud local AI in WeKan — **achievable today, no ExApp required**
|
||||
Since Nextcloud 30, the **Task Processing API** is the single unified AI entry point
|
||||
(it supersedes the old Text Processing / Text-To-Image / Speech-To-Text APIs). It is
|
||||
exposed over **OCS**, so WeKan's server can call it as an authenticated client:
|
||||
|
||||
```
|
||||
POST /ocs/v2.php/taskprocessing/schedule
|
||||
Headers: OCS-APIRequest: true ; Basic auth (user + app password)
|
||||
Body: {"type":"core:text2text:summary","input":{...},"appId":"wekan"}
|
||||
```
|
||||
then poll for the result. The model runs **on the Nextcloud side** via a backend:
|
||||
- **`llm2`** — fully local LLMs via llama.cpp (any GGUF model); rates **Green** on
|
||||
Nextcloud's Ethical AI rating.
|
||||
- **`integration_openai`** — any OpenAI-compatible engine (**LocalAI**, Ollama, vLLM…).
|
||||
- **Context Chat** — RAG over the user's Nextcloud files.
|
||||
|
||||
Useful task types for cards: `core:text2text` (draft a description), `…:summary`
|
||||
(summarize comments), `…:headline` (suggest a title), `…:translate`.
|
||||
|
||||
> WeKan does **not** need to be an ExApp to *use* the AI — it only needs an app-password
|
||||
> and OCS. (Registering as an ExApp AI *provider* is a different, much larger thing and
|
||||
> is not required here.)
|
||||
|
||||
### C2. Integration matrix
|
||||
|
||||
| Feature | Nextcloud mechanism | How WeKan uses it | Doc |
|
||||
|---|---|---|---|
|
||||
| **Local AI** | Task Processing API over OCS (`/ocs/v2.php/taskprocessing/schedule`), backend llm2/LocalAI | WeKan server summarizes/generates/translates card text | https://docs.nextcloud.com/server/stable/developer_manual/client_apis/OCS/ocs-taskprocessing-api.html |
|
||||
| **Kanban interop (Deck)** | Deck REST/OCS API `/ocs/v2.php/apps/deck/api/v1.0/` | Import/sync Deck boards↔WeKan (Deck stacks→WeKan lists, cards→cards) | https://deck.readthedocs.io/en/latest/API/ |
|
||||
| **SSO / login** | `user_oidc` (NC as OIDC client) or the `oidc` provider app (NC as IdP); LDAP; SAML | **WeKan already has OIDC** (`packages/wekan-oidc/`) — point it at Nextcloud's `oidc` provider for zero-code SSO | https://github.com/nextcloud/user_oidc |
|
||||
| **User/group provisioning** | OCS Provisioning API `/ocs/v1.php/cloud/users` | Mirror Nextcloud users/groups into WeKan (JIT alongside OIDC) | https://docs.nextcloud.com/server/stable/admin_manual/configuration_user/user_provisioning_api.html |
|
||||
| **Embed WeKan in NC UI** | **External Sites** app (iframe menu entry) | Add a "WeKan" top-menu item loading WeKan in an iframe (relax WeKan's `X-Frame-Options`/CSP for the NC origin) | https://docs.nextcloud.com/server/stable/admin_manual/configuration_server/external_sites.html |
|
||||
| **Files** | WebDAV `/remote.php/dav/files/USER/` | Attach/pull Nextcloud files onto cards | https://docs.nextcloud.com/server/stable/developer_manual/client_apis/WebDAV/index.html |
|
||||
| **Calendar / due dates** | CalDAV `/remote.php/dav/` | Publish card due dates as a CalDAV calendar | https://docs.nextcloud.com/server/stable/developer_manual/client_apis/WebDAV/index.html |
|
||||
| **Email** | Nextcloud **Mail** app (IMAP/SMTP client) + server SMTP | WeKan sends its own SMTP mail; NC Mail is a separate mailbox, not a relay WeKan drives | https://apps.nextcloud.com/apps/mail |
|
||||
| **Dashboard / Search / Notifications / Talk bots / Flow** | Dashboard widget, Unified Search, Notifications, Talk bot, Flow APIs | Require an in-Nextcloud **companion app/ExApp** — not reachable from external WeKan by REST alone | https://docs.nextcloud.com/server/stable/developer_manual/ |
|
||||
|
||||
**Honest caveats:** SSO (OIDC) is the strongest, zero-code win. AI, Deck, Provisioning,
|
||||
WebDAV/CalDAV work from external WeKan via REST. A Deck↔WeKan bridge is feasible but must
|
||||
be built (none is maintained). Dashboard/Search/Notifications/Talk/Flow assume code
|
||||
running *inside* Nextcloud.
|
||||
|
||||
---
|
||||
|
||||
## D. Comparison: kanban / project software for Nextcloud
|
||||
|
||||
| App | Type | Kanban features | API | AI | License | URL |
|
||||
|---|---|---|---|---|---|---|
|
||||
| **Deck** | Built-in native (PHP) | Full: boards, stacks, cards, labels, due dates, assignees, checklists, attachments, comments, sharing | Deck REST/OCS API; CalDAV for dates | Yes (in-platform Assistant/Task Processing) | AGPL-3.0 | https://apps.nextcloud.com/apps/deck |
|
||||
| **Nextcloud Tables** | Built-in native | Kanban-*ish* views over a no-code database; not true drag-drop stacks | Tables REST API v1/v2 | Via platform Assistant | AGPL-3.0 | https://apps.nextcloud.com/apps/tables |
|
||||
| **WeKan** | External (Meteor/Node) / ExApp | Full Trello-like: swimlanes, WIP limits, custom fields, rules, subtasks, templates — richer boards than Deck | Own REST API; consumes NC OCS APIs as a client | Can call NC Task Processing API as an OCS client (§C1) | MIT | https://github.com/wekan/wekan |
|
||||
| Kanboard / Planka / Focalboard | External | Full kanban, own stacks | Own APIs | varies | varies (OSS) | integrate via SSO/iframe only |
|
||||
|
||||
**Bottom line:** **Deck is the only native, first-party kanban** for Nextcloud. Tables
|
||||
offers board-like views but is a database app. Everything genuinely kanban (WeKan,
|
||||
Kanboard, Planka, Focalboard) is a **separate app** that *integrates with* Nextcloud
|
||||
(SSO/OIDC, iframe embed, OCS calls) rather than running as a native NC app.
|
||||
|
||||
---
|
||||
|
||||
## E. Checklist
|
||||
|
||||
- [ ] New repo `wekan/nextcloud` (ExApp): `appinfo/info.xml` + `<external-app>`, Node
|
||||
AppAPI-handshake wrapper, Dockerfile (WeKan + embedded FerretDB SQLite)
|
||||
- [ ] Obtain the app **signing certificate** (PR to `nextcloud/app-certificate-requests`)
|
||||
and **register** the app at apps.nextcloud.com
|
||||
- [ ] Add secrets `NEXTCLOUD_APP_PRIVATE_KEY`, `NEXTCLOUD_APP_PUBLIC_CRT`,
|
||||
`NEXTCLOUD_APPSTORE_TOKEN`; extend `WEKAN_REPO_TOKEN` to `wekan/nextcloud`
|
||||
- [ ] `release-all.yml`: add the `nextcloud` job (build ExApp image → regenerate
|
||||
info.xml → sign → `POST /api/v1/apps/releases`)
|
||||
- [ ] Require **HaRP** deploy daemon (WebSocket/DDP) and Nextcloud 30/31+ in the docs
|
||||
- [ ] (Integrations) Document/point WeKan OIDC at Nextcloud `oidc` provider (SSO);
|
||||
optionally add Task Processing AI calls and a Deck sync bridge
|
||||
- [ ] Test: deploy the ExApp on a Nextcloud + HaRP instance; verify login (OIDC),
|
||||
realtime (DDP over WebSocket), attachments, and an AI summarize call
|
||||
|
||||
## See also
|
||||
|
||||
- [UCS.md](UCS.md) — the same "Docker WeKan on a platform + migration" pattern for Univention.
|
||||
- [Snap-Ondra-Gantt.md](Snap-Ondra-Gantt.md) — the "push to a downstream repo + publish from release-all.yml" pattern.
|
||||
- [OS/Linux.md](OS/Linux.md) — Docker/Snap/Flatpak in the broader Linux update picture.
|
||||
@@ -0,0 +1,24 @@
|
||||
# Dev: IBM AIX install & update platforms (manual vs automatic)
|
||||
|
||||
AIX OS updates ship as **filesets** installed with `installp` (usually via the
|
||||
`smit`/`smitty` menus). **NIM (Network Installation Management)** pushes installs/
|
||||
updates from a master to clients. Open-source software comes as RPMs from the **AIX
|
||||
Toolbox**, managed with **`dnf`** (modern replacement for `yum`). The Internet-update
|
||||
tool is **SUMA**, which downloads fixes on a schedule from IBM.
|
||||
|
||||
> **SUMA (Service Update Management Assistant)** is AIX's automatic Internet
|
||||
> *downloader* — cron-driven — but *applying* the fixes stays a separate admin/NIM step.
|
||||
|
||||
| Mechanism | Manual | Automatic | Auto/Internet mechanism | Maintainer | URL | License |
|
||||
|---|:---:|:---:|---|---|---|---|
|
||||
| **`installp` + `smit`/`smitty`** (filesets) | ✓ | — | None | IBM | https://www.ibm.com/docs/en/aix | Proprietary |
|
||||
| **NIM** (Network Installation Management) | ✓ | partial | Push installs/updates from NIM master (LAN, not Internet auto-refresh) | IBM | https://www.ibm.com/docs/en/aix/7.2.0?topic=management-managing-nim | Proprietary |
|
||||
| **`dnf`/`yum`** (AIX Toolbox RPMs) | ✓ | — | Pulls from Toolbox repos on demand; no auto-refresh daemon | IBM (pkgs) / OSS (dnf) | https://www.ibm.com/support/pages/aix-toolbox-open-source-software-overview | GPL/OSS |
|
||||
| **SUMA** (Service Update Management Assistant) | ✓ | **✓ (download)** | Scheduled task downloads TL/SP/security fixes over the Internet from `esupport.ibm.com`; cron-driven, e-mail notify | IBM | https://www.ibm.com/docs/en/aix/7.3.0?topic=suma-service-update-management-assistant | Proprietary |
|
||||
|
||||
## WeKan / Electron applicability
|
||||
**No** for the desktop — no Electron/Chromium on AIX. Node.js has an official **AIX**
|
||||
port, so the WeKan **server** side is feasible; the Electron client is not.
|
||||
|
||||
## See also
|
||||
[Solaris.md](Solaris.md) · [HP-UX.md](HP-UX.md) · [OpenVMS.md](OpenVMS.md) · [zOS.md](zOS.md) · [Linux.md](Linux.md)
|
||||
@@ -0,0 +1,54 @@
|
||||
# Dev: Amiga-family install & update platforms (manual vs automatic, like Snap)
|
||||
|
||||
How software is installed and updated across the **Amiga** family — **AmigaOS 3.x**
|
||||
(classic 68k), **AmigaOS 4.x** (PowerPC, Hyperion), **MorphOS** (PowerPC), and
|
||||
**AROS** (open-source AmigaOS-compatible reimplementation) — split into **manual**
|
||||
and **automatic** (background/self-updating, like Linux Snap auto-refresh).
|
||||
|
||||
> The Amiga family inherits a distribution culture from the classic 68k days:
|
||||
> software ships as compressed archives (`.lha`, historically `.lzx`, sometimes
|
||||
> `.zip`) downloaded from a central web archive, then unpacked and either dragged
|
||||
> into place or installed via a bundled **Installer** script. This is fundamentally
|
||||
> **manual** — the user is the update mechanism. Genuine auto-update tooling is rare
|
||||
> and recent: **AmiUpdate** and **AMIStore's Updater** (AmigaOS 4) can check online
|
||||
> and pull newer versions, and **Grunch** (MorphOS/AmigaOS) is the only tool in the
|
||||
> family that behaves like a true auto-refreshing package manager. **AROS** and
|
||||
> **classic AmigaOS 3.x** have no mainstream package manager and no auto-update.
|
||||
|
||||
---
|
||||
|
||||
## Comparison table
|
||||
|
||||
| Platform / OS | Manual | Automatic | Auto mechanism | Maintainer | URL | License |
|
||||
|---|:---:|:---:|---|---|---|---|
|
||||
| **Aminet** (cross-platform archive) | ✓ | — | None — browse/search, download `.lha`/`.zip`, unpack + Installer by hand | Aminet community | https://aminet.net/ | Per-package (mixed) |
|
||||
| **OS4Depot** (AmigaOS 4) | ✓ | — | None — manual `.lha` download + install | OS4Depot volunteers | https://www.os4depot.net/ | Per-package (mixed) |
|
||||
| **MorphOS-Storage** (MorphOS) | ✓ | — | None — manual `.lha` download + install | MorphOS community | https://www.morphos-storage.net/ | Per-package (mixed) |
|
||||
| **AROS Archives** (AROS) | ✓ | — | None — manual download + install; AROS has **no mainstream package manager** | AROS community | https://archives.arosworld.org/ | Per-package (mixed) |
|
||||
| **AMIStore** (AmigaOS 4 app store) | ✓ | Partial | Store client + separate **Updater**: compares local vs remote versions, can auto-check on start and on a schedule (~every 4h), then user confirms download/install | A-EON Technology | http://www.amistore.net/ | Proprietary client |
|
||||
| **AmiUpdate** (AmigaOS 4 online updater) | ✓ | Partial | Checks amiupdate.net for newer versions of OS components + registered apps, lists them, user selects; can auto-check but is user-triggered, not a silent daemon | Simon Archer (bundled with AmigaOS 4.1 from Update 6) | https://wiki.amigaos.net/wiki/AmigaOS_Updates | Bundled (proprietary) |
|
||||
| **Grunch** (MorphOS + AmigaOS) | ✓ | **✓** | True package manager: search/download/install/**auto-update**/uninstall with dependency handling; update prompts can be disabled so installed software updates automatically (closest analogue to Snap auto-refresh) | "geit" (Guido Mersmann) | https://www.geit.de/eng_grunch.html | Freeware |
|
||||
| **MorphOS system updates** | ✓ | — | None automatic — full/point OS releases distributed as downloadable images/installers; user installs manually | MorphOS Development Team | https://www.morphos-storage.net/ | Proprietary OS |
|
||||
| **Classic AmigaOS 3.x drag-install** (68k) | ✓ | — | None — drag drawer, or run the Amiga **Installer** script; `.lha`/`.lzx` archives. No package manager, no auto-update | software by individual authors | https://aminet.net/ | Per-package (mixed) |
|
||||
|
||||
*AmiUpdate* and *AMIStore's Updater* are "Partial" because they can *check*
|
||||
automatically/on schedule but still require the user to confirm the install — not
|
||||
silent auto-appliers like Snap's `snapd`. **Grunch** is the single family tool that
|
||||
can genuinely auto-update installed apps unattended. There is **no** legitimate
|
||||
mainstream package manager for AROS or classic AmigaOS 3.x.
|
||||
|
||||
---
|
||||
|
||||
## WeKan / Electron applicability
|
||||
|
||||
**A WeKan Electron desktop app cannot run on any Amiga-family OS.** Electron bundles
|
||||
Node.js + Chromium, and there is no port of Node or Chromium to 68k (AmigaOS 3.x), to
|
||||
PowerPC AmigaOS 4 / MorphOS, or to AROS — these platforms lack the modern V8/Blink
|
||||
toolchain, threading, and graphics stack Chromium requires. The only realistic way an
|
||||
Amiga user reaches WeKan is via a **web browser pointed at a hosted WeKan server**
|
||||
(and even then, heavy modern JavaScript strains these browsers) — never as a native
|
||||
install, so none of the update mechanisms above apply to WeKan itself.
|
||||
|
||||
## See also
|
||||
|
||||
- Linux: [Linux.md](Linux.md) · Haiku: [Haiku.md](Haiku.md) · RISC OS: [RISC-OS.md](RISC-OS.md) · macOS: [Mac.md](Mac.md)
|
||||
@@ -0,0 +1,23 @@
|
||||
# Dev: Android install & update platforms (manual vs automatic, like Snap)
|
||||
|
||||
Android is the most pluralistic mobile OS: the default path is **Google Play**
|
||||
background auto-update, but privileged alternative stores (**F-Droid**, **Aurora**) can
|
||||
now auto-update too, and enterprise fleets use **Managed Google Play** push-installs.
|
||||
**Google Play System Updates** (Project Mainline) patch OS modules without a full OTA.
|
||||
|
||||
| Store/Mechanism | Manual | Automatic | Auto/Internet mechanism | Maintainer | URL | License |
|
||||
|---|:---:|:---:|---|---|---|---|
|
||||
| **Google Play Store** | ✓ | ✓ (default) | Background updates over Wi-Fi/any-network when idle+charging; global + per-app toggles | Google | https://support.google.com/googleplay/answer/113412 | Proprietary |
|
||||
| **F-Droid + Privileged Extension** | ✓ | ✓ | Client polls repo index; Privileged Extension (or GrapheneOS/Calyx integration) installs updates silently (since 2025) | F-Droid | https://f-droid.org/packages/org.fdroid.fdroid.privileged/ | GPL/AGPL |
|
||||
| **Aurora Store** | ✓ | ✓ | Anonymous/Google-account Play proxy; unattended updates via Android 12+ update-owner API | Aurora OSS | https://gitlab.com/AuroraOSS/AuroraStore | GPL-3.0 |
|
||||
| **Sideloaded APK** | ✓ | — | User downloads `.apk`; no update channel | any source | https://developer.android.com/ | varies |
|
||||
| **Play System Updates** (Mainline) | — | ✓ | APK/APEX modules delivered over Play infrastructure, no full OTA | Google/AOSP | https://source.android.com/docs/core/ota/modular-system | Apache-2.0 |
|
||||
| **Managed Google Play / MDM** | — | ✓ | `autoInstallPolicy` / forced-install pushes and silently updates apps via EMM (Intune, Jamf…) | Google + EMM | https://support.google.com/work/android/answer/6137769 | Proprietary |
|
||||
|
||||
## WeKan applicability
|
||||
WeKan is a **web/Meteor app** — native Electron desktop auto-update does not apply.
|
||||
Ship WeKan as a **PWA** (installable via Chrome) or wrap it (**Bubblewrap/TWA**) for
|
||||
Play/F-Droid/Aurora; the wrapper then rides normal store auto-update.
|
||||
|
||||
## See also
|
||||
[iOS.md](iOS.md) · [ChromeOS.md](ChromeOS.md) · [Linux.md](Linux.md)
|
||||
Loaded 100 of 523 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user