Compare commits

..
1 Commits
Author SHA1 Message Date
Lauri Ojansivu afd744e3ff v7.94 2025-08-18 19:03:00 +03:00
1553 changed files with 170870 additions and 389927 deletions

No files matched your search

+1 -1
View File
@@ -251,7 +251,7 @@ gosu wekan:wekan /home/wekan/.meteor/meteor -- help
# Build app (Development)
cd /home/wekan/app
gosu wekan:wekan /home/wekan/.meteor/meteor add standard-minifier-js
gosu wekan:wekan /home/wekan/.meteor/meteor npm install --no-audit
gosu wekan:wekan /home/wekan/.meteor/meteor npm install
# Put back the original tar
mv $(which tar)~ $(which tar)
+18 -38
View File
@@ -20,10 +20,7 @@
"strict": 0,
"no-undef": 0,
"accessor-pairs": 2,
"comma-dangle": [
2,
"always-multiline"
],
"comma-dangle": [2, "always-multiline"],
"consistent-return": 2,
"dot-notation": 2,
"eqeqeq": 2,
@@ -31,28 +28,15 @@
"no-cond-assign": 2,
"no-constant-condition": 2,
"no-eval": 2,
"no-inner-declarations": [
0
],
"no-inner-declarations": [0],
"no-unneeded-ternary": 2,
"radix": 2,
"semi": [
2,
"always"
],
"camelcase": [
2,
{
"properties": "never"
}
],
"semi": [2, "always"],
"camelcase": [2, { "properties": "never" }],
"comma-spacing": 2,
"comma-style": 2,
"eol-last": 2,
"linebreak-style": [
2,
"unix"
],
"linebreak-style": [2, "unix"],
"meteor/audit-argument-checks": 0,
"new-parens": 2,
"no-lonely-if": 2,
@@ -61,13 +45,7 @@
"no-spaced-func": 2,
"no-trailing-spaces": 2,
"operator-linebreak": 2,
"quotes": [
2,
"single",
{
"avoidEscape": true
}
],
"quotes": [2, "single", { "avoidEscape": true }],
"semi-spacing": 2,
"space-unary-ops": 2,
"arrow-spacing": 2,
@@ -79,7 +57,6 @@
"prefer-spread": 2,
"prefer-template": 2,
"no-unused-vars": "warn",
"@typescript-eslint/no-floating-promises": "error",
"prettier/prettier": [
"error",
{
@@ -95,18 +72,11 @@
"settings": {
"import/resolver": {
"meteor": {
"extensions": [
".js",
".jsx"
]
"extensions": [".js", ".jsx"]
}
}
},
"plugins": [
"prettier",
"meteor",
"@typescript-eslint"
],
"plugins": ["prettier", "meteor"],
"globals": {
"Meteor": false,
"Session": false,
@@ -123,22 +93,29 @@
"Template": false,
"$": false,
"_": false,
"autosize": false,
"Avatar": true,
"Avatars": true,
"BlazeComponent": false,
"BlazeLayout": false,
"CollectionHooks": false,
"DocHead": false,
"ESSearchResults": false,
"FastRender": false,
"FlowRouter": false,
"FS": false,
"getSlug": false,
"Migrations": false,
"moment": false,
"Mousetrap": false,
"Picker": false,
"Presence": true,
"presences": true,
"Ps": true,
"ReactiveTabs": false,
"Restivus": false,
"SimpleSchema": false,
"SubsManager": false,
"T9n": false,
"TAPi18n": false,
"AccountsTemplates": true,
@@ -156,11 +133,13 @@
"CSSEvents": true,
"EscapeActions": true,
"Filter": true,
"Mixins": true,
"Modal": true,
"MultiSelection": true,
"Popup": true,
"Sidebar": true,
"Utils": true,
"InlinedForm": true,
"UnsavedEdits": true,
"Notifications": true,
"allowIsBoardAdmin": true,
@@ -173,6 +152,7 @@
"Checklists": true,
"Settings": true,
"InvitationCodes": true,
"JsonRoutes": true,
"Authentication": true,
"Integrations": true,
"HTTP": true,
+1 -2
View File
@@ -1,4 +1,3 @@
* text=auto eol=lf
*.{cmd,[cC][mM][dD]} text eol=crlf
*.{bat,[bB][aA][tT]} text eol=crlf
.gitea/CODEOWNERS linguist-detectable=false
*.{bat,[bB][aA][tT]} text eol=crlf
-3
View File
@@ -1,3 +0,0 @@
# Pull requests request review from the eligible staff team(s).
# One approval from either an Admin or Developer satisfies branch protection.
* @trueog/Admins @trueog/Developers
-1
View File
@@ -1,4 +1,3 @@
# These are supported funding model platforms
github: wekan
custom: ['https://wekan.fi/commercial-support/']
+56
View File
@@ -0,0 +1,56 @@
## Issue
UPGRADE OR MIGRATE: https://github.com/wekan/wekan/blob/main/docs/Upgrade/Upgrade.md
Pull requests welcome to fix any broken links at docs directory, and organizing docs/Features and their screenshots to subdirectories of each feature.
Please report these issues elsewhere:
- SECURITY ISSUES, PGP EMAIL: https://github.com/wekan/wekan/blob/main/SECURITY.md
- UCS: https://github.com/wekan/univention/issues
If WeKan Snap is slow, try this: https://github.com/wekan/wekan/wiki/Cron
Please search existing Open and Closed issues, most questions have already been answered.
If you can not login for any reason: https://github.com/wekan/wekan/wiki/Forgot-Password
Email settings, only SMTP MAIL_URL and MAIL_FROM are in use:
https://github.com/wekan/wekan/wiki/Troubleshooting-Mail
### Server Setup Information
Please anonymize info, and do not any of your Wekan board URLs, passwords,
API tokens etc to this public issue.
* Did you test in newest Wekan?:
* Did you configure root-url correctly so Wekan cards open correctly (see https://github.com/wekan/wekan/wiki/Settings)?
* Operating System:
* Deployment Method (Snap/Docker/Sandstorm/bundle/source):
* Http frontend if any (Caddy, Nginx, Apache, see config examples from Wekan GitHub wiki first):
* Node.js Version:
* MongoDB Version:
* What webbrowser version are you using (Wekan should work on all modern browsers that support Javascript)?
### Problem description
Add a recorded animated gif (e.g. with https://github.com/phw/peek) about
how it works currently, and screenshot mockups how it should work.
#### Reproduction Steps
#### Logs
Check Right Click / Inspect / Console in you browser - generally Chromium
based browsers show more detailed info than Firefox based browsers.
Please anonymize logs.
Snap: sudo snap logs wekan.wekan
Docker: sudo docker logs wekan-app
If logs are very long, attach them in .zip file
-42
View File
@@ -1,42 +0,0 @@
name: 🐛 Bug Report
description: Report a bug to help improve WeKan
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Thanks for reporting a bug! Please ensure you are using the [latest version of WeKan](https://wekan.fi/install) and [Upgrade](https://wekan.fi/upgrade) before submitting.
- type: textarea
id: description
attributes:
label: Bug Description
description: A clear and concise description of what the bug is. Mention versions of WeKan, Node.js, database name and version, frontend webserver version like Caddy etc.
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Steps to Reproduce
description: How can we recreate this issue?
placeholder: |
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
validations:
required: true
- type: textarea
id: logs
attributes:
label: Relevant Logs
description: |
- Please paste any relevant anonymized server logs or browser console errors here.
- Snap: sudo snap logs wekan.wekan
- Docker: sudo docker logs wekan-app
- If logs are very long, attach them in .zip file
render: shell
- type: textarea
id: context
attributes:
label: Additional Context
description: Add any other context, anonymized screenshots or GIF video about the bug, and screenshot mockups about how it should work.
@@ -1,31 +0,0 @@
name: ✨ Feature Request
description: Suggest a new feature for WeKan
labels: ["Feature:Request"]
body:
- type: textarea
id: feature-description
attributes:
label: Problem Statement
description: Is your feature request related to a problem? Please describe.
placeholder: I'm always frustrated when [...]
validations:
required: true
- type: textarea
id: solution
attributes:
label: Proposed Solution
description: A clear and concise description of what you want to happen.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives Considered
description: Any alternative solutions or features you've considered.
- type: textarea
id: context
attributes:
label: Additional Context
description: Add any other context, like anonymized screenshot mockups about how it should work.
@@ -1,14 +0,0 @@
name: 🛡️ Security Issue
description: Report a security vulnerability
labels: ["security", "critical"]
body:
- type: markdown
attributes:
value: |
## ⚠️ IMPORTANT: Please do not report security vulnerabilities via public issues.
To protect the WeKan community, we ask that you report security bugs privately. This allows us to fix the issue before it can be exploited by malicious actors.
### How to report:
Please read our **[Security Policy (SECURITY.md)](https://github.com/wekan/wekan/blob/main/SECURITY.md)** for the official reporting process and contact information.
-23
View File
@@ -1,23 +0,0 @@
name: 🗳️ Univention (UCS) Issue
description: Problems specifically related to the WeKan app on Univention Corporate Server
labels: ["UCS"]
body:
- type: markdown
attributes:
value: |
## 🛑 Is this a UCS-specific issue?
If your issue is related to the **Univention Corporate Server (UCS) integration**, packaging, or installation via the Univention App Center, it should be reported in the dedicated Univention repository.
### ➡️ [Report UCS Issues Here](https://github.com/wekan/univention/issues)
---
**Why?**
Reporting there ensures that the maintainers specifically focused on the UCS environment see your request.
If you are certain this is a **core WeKan bug** that affects all platforms (Docker, Snap, etc.), please go back and use the standard [Bug Report](https://github.com/wekan/wekan/issues/new?template=bug-report.yml) template.
- type: textarea
id: ucs-details
attributes:
label: Brief Description (Optional)
description: If you still wish to post here, please provide a brief summary of why this is a core Wekan issue and not a UCS-specific integration bug.
+2 -2
View File
@@ -9,6 +9,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: 'Checkout Repository'
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: 'Dependency Review'
uses: actions/dependency-review-action@v5
uses: actions/dependency-review-action@v4
+4 -4
View File
@@ -32,13 +32,13 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
# Login against a Docker registry except on PR
# https://github.com/docker/login-action
- name: Log into registry ${{ env.REGISTRY }}
if: github.event_name != 'pull_request'
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -48,14 +48,14 @@ jobs:
# https://github.com/docker/metadata-action
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
# Build and push Docker image with Buildx (don't push on PR)
# https://github.com/docker/build-push-action
- name: Build and push Docker image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
+1 -1
View File
@@ -15,6 +15,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v5
- name: Build the Docker image
run: docker build . --file Dockerfile --tag wekan:$(date +%s)
-251
View File
@@ -1,251 +0,0 @@
name: Playwright E2E Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: playwright-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
unit-tests:
name: Meteor unit tests
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- name: Setup Node 24
uses: actions/setup-node@v6
with:
node-version: '24'
cache: 'npm'
- name: Cache Meteor installation
uses: actions/cache@v5
with:
path: ~/.meteor
key: meteor-${{ hashFiles('.meteor/release') }}-${{ hashFiles('.meteor/packages') }}
restore-keys: meteor-
- name: Cache Meteor build (.meteor/local)
uses: actions/cache@v5
with:
path: .meteor/local
key: meteor-local-unit-${{ hashFiles('.meteor/versions') }}
restore-keys: meteor-local-unit-
- name: Install Meteor
run: |
if [ ! -x ~/.meteor/meteor ]; then
curl https://install.meteor.com/ | sh
fi
echo "$HOME/.meteor" >> "$GITHUB_PATH"
- name: Install npm dependencies
run: npm ci
- name: Run Meteor unit tests
# First Meteor build is slow; allow time before the test driver starts.
timeout-minutes: 45
run: meteor test --once --driver-package meteortesting:mocha
env:
TEST_WATCH: '0'
e2e-tests:
name: Playwright E2E (${{ matrix.project }})
runs-on: ubuntu-24.04
needs: unit-tests
strategy:
fail-fast: false
matrix:
project: [chromium, firefox]
# webkit excluded from CI (requires macOS runner) — run locally with npm run test:e2e:all
services:
mongodb:
image: mongo:7
ports: ['27017:27017']
options: >-
--health-cmd "mongosh --eval 'db.adminCommand(\"ping\")'"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v6
- name: Setup Node 24
uses: actions/setup-node@v6
with:
node-version: '24'
cache: 'npm'
- name: Cache Meteor installation
uses: actions/cache@v5
with:
path: ~/.meteor
key: meteor-${{ hashFiles('.meteor/release') }}-${{ hashFiles('.meteor/packages') }}
restore-keys: meteor-
- name: Cache Meteor build (.meteor/local)
uses: actions/cache@v5
with:
path: .meteor/local
key: meteor-local-e2e-${{ hashFiles('.meteor/versions') }}
restore-keys: meteor-local-e2e-
- name: Install Meteor
run: |
if [ ! -x ~/.meteor/meteor ]; then
curl https://install.meteor.com/ | sh
fi
echo "$HOME/.meteor" >> "$GITHUB_PATH"
- name: Install app npm dependencies
run: npm ci
- name: Install Playwright test dependencies
run: npm ci
working-directory: tests/playwright
- name: Install Playwright browsers
run: npx playwright install --with-deps ${{ matrix.project }}
working-directory: tests/playwright
- name: Start WeKan in background
run: |
MONGO_URL=mongodb://localhost:27017/wekan_test \
ROOT_URL=http://localhost:3000 \
PORT=3000 \
meteor run --settings settings.json > wekan-run.log 2>&1 &
# The first Meteor build is slow, so wait up to 20 minutes for the
# server to start responding before giving up.
for i in $(seq 1 120); do
if curl -sf http://localhost:3000 > /dev/null; then
echo "WeKan is up after $((i * 10))s"
break
fi
echo "Waiting for WeKan... attempt $i/120"
sleep 10
done
if ! curl -sf http://localhost:3000 > /dev/null; then
echo "WeKan failed to start. Last 200 log lines:"
tail -n 200 wekan-run.log
exit 1
fi
env:
DISABLE_CAPTCHA: 'true'
- name: Run Playwright tests (${{ matrix.project }})
run: |
npx playwright test \
--project=${{ matrix.project }} \
--reporter=github
working-directory: tests/playwright
env:
WEKAN_BASE_URL: http://localhost:3000
WEKAN_MONGO_URL: mongodb://localhost:27017/wekan_test
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@v7
with:
name: playwright-report-${{ matrix.project }}
path: tests/playwright/playwright-report/
retention-days: 14
- name: Upload test results (screenshots / videos)
if: failure()
uses: actions/upload-artifact@v7
with:
name: test-results-${{ matrix.project }}
path: tests/playwright/test-results/
retention-days: 7
e2e-regression:
name: Puppeteer regression suite
runs-on: ubuntu-24.04
needs: unit-tests
services:
mongodb:
image: mongo:7
ports: ['27017:27017']
options: >-
--health-cmd "mongosh --eval 'db.adminCommand(\"ping\")'"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v6
- name: Setup Node 24
uses: actions/setup-node@v6
with:
node-version: '24'
cache: 'npm'
- name: Cache Meteor installation
uses: actions/cache@v5
with:
path: ~/.meteor
key: meteor-${{ hashFiles('.meteor/release') }}-${{ hashFiles('.meteor/packages') }}
restore-keys: meteor-
- name: Cache Meteor build (.meteor/local)
uses: actions/cache@v5
with:
path: .meteor/local
key: meteor-local-regression-${{ hashFiles('.meteor/versions') }}
restore-keys: meteor-local-regression-
- name: Install Meteor
run: |
if [ ! -x ~/.meteor/meteor ]; then
curl https://install.meteor.com/ | sh
fi
echo "$HOME/.meteor" >> "$GITHUB_PATH"
- name: Install npm dependencies
run: npm ci
- name: Install Chromium
run: sudo apt-get install -y chromium-browser
- name: Start WeKan in background
run: |
MONGO_URL=mongodb://localhost:27017/wekan_regression \
ROOT_URL=http://localhost:3000 \
PORT=3000 \
meteor run > wekan-run.log 2>&1 &
# The first Meteor build is slow, so wait up to 20 minutes.
for i in $(seq 1 120); do
if curl -sf http://localhost:3000 > /dev/null; then
echo "WeKan is up after $((i * 10))s"
break
fi
echo "Waiting for WeKan... attempt $i/120"
sleep 10
done
if ! curl -sf http://localhost:3000 > /dev/null; then
echo "WeKan failed to start. Last 200 log lines:"
tail -n 200 wekan-run.log
exit 1
fi
env:
DISABLE_CAPTCHA: 'true'
- name: Run list-regressions e2e
run: node tests/e2e/list-regressions.js
env:
WEKAN_BASE_URL: http://localhost:3000
WEKAN_MONGO_URL: mongodb://localhost:27017/wekan_regression
CHROMIUM_PATH: /usr/bin/chromium-browser
HEADLESS: 'true'
-633
View File
@@ -1,633 +0,0 @@
name: Release All Platforms
# Triggered manually via GitHub CLI or the Actions UI.
# Usage:
# gh workflow run release-all.yml -f old_version=9.35 -f new_version=9.36
#
# Or use the convenience script (bumps versions, commits, pushes, then triggers):
# ./releases/release-all.sh 9.35 9.36
#
# Builds only the platforms MongoDB ships server binaries for (amd64, arm64,
# win64, mac); MongoDB 7.x is the default database. s390x/ppc64le are not built
# because MongoDB has no server binaries for them.
#
# This workflow does EVERYTHING for a release remotely (nothing is built or
# pushed on the maintainer's machine), running jobs in parallel where possible:
# - bump: runs releases/version.sh + releases/rebuild-docs.sh on the runner
# (bumps Node/MongoDB/WeKan versions, rebuilds API docs), then
# commits and pushes the bump to wekan/wekan main
# - prepare: tags the bumped main as v<new_version> and extracts release notes
# - website: updates the wekan/wekan.fi repo (install page + API docs) and pushes
# - charts: updates and publishes the wekan/charts Helm chart and pushes
# - build-*: builds the amd64 bundle (full Meteor 3 build), then rebuilds native
# modules for arm64, win64, mac-arm64 from that amd64 base
# - release: creates the GitHub Release with the newest CHANGELOG.md notes and
# uploads every wekan-<version>-<platform>.zip bundle
# - docker: builds and pushes the multi-arch image to Docker Hub, Quay.io, GHCR
# - snap: builds the snap on Launchpad (amd64 + arm64), pushes it to the Snap
# Store and attaches the .snap files to the GitHub Release
#
# Required GitHub Secrets:
# GHCR_AUTH - base64("username:token") for ghcr.io
# DOCKERHUB_AUTH - base64("username:token") for Docker Hub
# QUAY_AUTH - base64("username:token") for Quay.io
# SNAP_AUTH - Snap Store credentials (output of `snapcraft export-login`)
# LP_CREDENTIALS - base64 of the Launchpad credentials file used by
# `snapcraft remote-build` (see releases/snap-build.sh notes)
# WEKAN_REPO_TOKEN - Personal Access Token with write access to wekan/wekan.fi
# 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.
on:
workflow_dispatch:
inputs:
old_version:
description: 'Previous version number (e.g. 9.35, without v prefix)'
required: true
type: string
new_version:
description: 'New version number (e.g. 9.36, without v prefix)'
required: true
type: string
permissions:
contents: write
packages: write
# Node.js major version that Meteor 3.5-beta bundles. Used for native module
# rebuilds on every platform — it must match the amd64 build's Node ABI.
env:
NODE_VERSION: '24'
jobs:
# ─── 0. Bump versions + rebuild API docs, commit to main ────────────────────
# Runs releases/version.sh (probes upstream for the newest Node.js/MongoDB,
# bumps every version reference) and releases/rebuild-docs.sh (regenerates
# public/api/wekan.yml + wekan.html), then commits and pushes to main so the
# tag, builds, website and charts jobs all see the bumped sources.
bump:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v6
with:
ref: main
fetch-depth: 0
- uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install version-bump and docs build dependencies
run: |
sudo apt-get update
sudo apt-get install -y python3 python3-pip curl wget
- name: Bump versions and rebuild API docs
env:
# Builds fetch dependencies from upstream themselves, so skip the slow
# pre-download of Node/MongoDB tarballs that the local flow does.
RELEASE_SKIP_DEP_DOWNLOAD: '1'
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
./releases/version.sh "${{ inputs.old_version }}" "${{ inputs.new_version }}"
./releases/rebuild-docs.sh "${{ inputs.new_version }}"
- name: Commit and push version bump
run: |
git add --all
if git diff --cached --quiet; then
echo "No version changes to commit."
else
git commit -m "Bump versions for v${{ inputs.new_version }}"
git push origin main
fi
# ─── 1. Tag the release and extract release notes ───────────────────────────
prepare:
needs: bump
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
changelog: ${{ steps.changelog.outputs.content }}
steps:
- uses: actions/checkout@v6
with:
ref: main
fetch-depth: 0
- name: Normalise version from input
id: version
run: |
VERSION="${{ inputs.new_version }}"
VERSION="${VERSION#v}"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
- name: Create and push tag v${{ steps.version.outputs.version }}
run: |
VERSION="${{ steps.version.outputs.version }}"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
if git rev-parse "v$VERSION" >/dev/null 2>&1; then
echo "Tag v$VERSION already exists, leaving it as is."
else
git tag -a "v$VERSION" -m "v$VERSION"
git push origin "v$VERSION"
fi
- name: Extract release notes from CHANGELOG.md
id: changelog
run: |
VERSION="${{ steps.version.outputs.version }}"
# Prefer a version-specific "# v<version> ..." section, otherwise fall
# back to the topmost "# Upcoming/Upcomig WeKan ® release" section.
NOTES=$(python3 - <<PYEOF
import re, sys
version = "$VERSION"
with open("CHANGELOG.md", encoding="utf-8") as f:
content = f.read()
patterns = [
rf"(# v{re.escape(version)} .*?)(?=\n# v[0-9]|\Z)",
r"(# Upcom\w* WeKan.*?)(?=\n# v[0-9]|\Z)",
]
for pat in patterns:
m = re.search(pat, content, re.DOTALL)
if m:
print(m.group(1).strip())
sys.exit(0)
print(f"WeKan v{version}")
PYEOF
)
{
echo "content<<GHEOF"
echo "$NOTES"
echo "GHEOF"
} >> "$GITHUB_OUTPUT"
# ─── 1b. Update the wekan.fi website repo (parallel) ────────────────────────
# Bumps the version/Meteor/Node/MongoDB strings on the install page and copies
# the freshly rebuilt API docs into wekan.fi/api/v<new>, then commits + pushes
# to wekan/wekan.fi. Runs as soon as `bump` has pushed the bumped sources.
website:
needs: bump
runs-on: ubuntu-latest
steps:
- name: Checkout wekan repo (bumped main, with rebuilt API docs)
uses: actions/checkout@v6
with:
ref: main
path: wekan
- name: Checkout wekan.fi website repo
uses: actions/checkout@v6
with:
repository: wekan/wekan.fi
token: ${{ secrets.WEKAN_REPO_TOKEN }}
path: wekan.fi
- name: Update and push website
env:
WEBDIR: ${{ github.workspace }}/wekan.fi
WEKANREPODIR: ${{ github.workspace }}/wekan
RELEASE_PUSH_WEBSITE: '1'
run: |
cd wekan.fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
cd ..
./wekan/releases/release-website.sh "${{ inputs.old_version }}" "${{ inputs.new_version }}"
# ─── 1c. Update and publish the Helm chart repo (parallel) ──────────────────
# Bumps the WeKan version in wekan/charts, packages the chart and publishes it
# to the gh-pages Helm index, then pushes both branches. Runs in parallel with
# the platform builds; packaging the chart does not need the built artifacts.
charts:
needs: bump
runs-on: ubuntu-latest
steps:
- name: Checkout wekan repo (for releases/release-charts.sh + version refs)
uses: actions/checkout@v6
with:
ref: main
path: wekan
- name: Checkout wekan/charts repo (main + gh-pages)
uses: actions/checkout@v6
with:
repository: wekan/charts
token: ${{ secrets.WEKAN_REPO_TOKEN }}
fetch-depth: 0
path: charts
- name: Install Helm
uses: azure/setup-helm@v4
- name: Fetch gh-pages branch (release-charts.sh switches to it)
run: |
cd charts
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git fetch origin '+refs/heads/*:refs/remotes/origin/*'
git branch --track gh-pages origin/gh-pages 2>/dev/null || true
- name: Release and publish chart
env:
CHARTS_DIR: ${{ github.workspace }}/charts
run: |
./wekan/releases/release-charts.sh "${{ inputs.new_version }}"
# ─── 2. Build amd64 bundle (full Meteor 3 build) ────────────────────────────
# Meteor 3.5-beta installer supports only Linux x86_64/aarch64, macOS, Windows.
# The full Meteor build always runs here on x86_64; every other platform then
# only rebuilds the native npm modules from this bundle.
build-amd64:
needs: prepare
runs-on: ubuntu-24.04
timeout-minutes: 180
env:
VERSION: ${{ needs.prepare.outputs.version }}
steps:
- uses: actions/checkout@v6
with:
ref: refs/tags/v${{ needs.prepare.outputs.version }}
- name: Cache Meteor installation
uses: actions/cache@v5
with:
path: ~/.meteor
key: meteor-${{ hashFiles('.meteor/release') }}-${{ hashFiles('.meteor/packages') }}
restore-keys: meteor-
- name: Cache npm packages
uses: actions/cache@v5
with:
path: ~/.npm
key: npm-${{ hashFiles('package-lock.json') }}
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y build-essential g++ make python3 curl wget zip unzip
- uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install Meteor
run: |
if [ ! -f ~/.meteor/meteor ]; then
curl https://install.meteor.com/ | sh
fi
echo "$HOME/.meteor" >> $GITHUB_PATH
- name: Build WeKan bundle
run: |
rm -rf .build node_modules .meteor/local
(meteor update --npm 2>/dev/null || true) && meteor npm install
meteor build .build --directory
- name: Install server npm modules
run: |
cd .build/bundle/programs/server
npm install
- name: Create amd64 zip
run: |
cd .build
zip -r wekan-${VERSION}-amd64.zip bundle
- uses: actions/upload-artifact@v7
with:
name: bundle-amd64
path: .build/wekan-${{ needs.prepare.outputs.version }}-amd64.zip
retention-days: 1
# ─── 3. Build arm64 bundle (rebuild native modules natively) ─────────────────
build-arm64:
needs: [prepare, build-amd64]
runs-on: ubuntu-24.04-arm
timeout-minutes: 60
env:
VERSION: ${{ needs.prepare.outputs.version }}
steps:
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y build-essential g++ make python3 curl unzip zip
- name: Download amd64 bundle
uses: actions/download-artifact@v8
with:
name: bundle-amd64
- name: Rebuild native modules for arm64 in container
run: |
unzip wekan-${VERSION}-amd64.zip
# Fix: bcrypt 5.0.1 (Meteor accounts-password) ships node-addon-api@3
# which fails to compile on Node.js 24. Replace with bcrypt 6.0.0.
BCRYPT_DIR="$(pwd)/bundle/programs/server/npm/node_modules/meteor/accounts-password/node_modules/bcrypt"
if [ -d "$BCRYPT_DIR" ]; then
rm -rf "$BCRYPT_DIR"
TMP=$(mktemp -d)
pushd "$TMP" > /dev/null
npm pack bcrypt@6.0.0
mkdir bcrypt && tar xzf bcrypt-*.tgz --strip-components=1 -C bcrypt
cd bcrypt && npm install --ignore-scripts && cd ..
cp -a bcrypt "$BCRYPT_DIR"
popd > /dev/null
rm -rf "$TMP"
fi
docker run --rm --platform linux/arm64 \
-v "$(pwd)/bundle:/bundle" \
-w /bundle/programs/server \
node:${NODE_VERSION}-slim \
sh -c "apt-get update -qq && apt-get install -y -q build-essential python3 && npm install"
- name: Create arm64 zip
run: |
zip -r wekan-${VERSION}-arm64.zip bundle
- uses: actions/upload-artifact@v7
with:
name: bundle-arm64
path: wekan-${{ needs.prepare.outputs.version }}-arm64.zip
retention-days: 1
# ─── 5. Build win64 bundle (rebuild native modules on Windows) ───────────────
build-win64:
needs: [prepare, build-amd64]
runs-on: windows-latest
timeout-minutes: 60
env:
VERSION: ${{ needs.prepare.outputs.version }}
steps:
# Checked out into src/ so start-wekan.bat and snapcraft.yaml (which pins
# the MongoDB tools/mongosh versions) are available; the downloaded bundle
# is extracted alongside it in the workspace root.
- uses: actions/checkout@v6
with:
ref: refs/tags/v${{ needs.prepare.outputs.version }}
path: src
- uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install 7-zip
run: choco install -y 7zip
- name: Download amd64 bundle
uses: actions/download-artifact@v8
with:
name: bundle-amd64
- name: Fix bcrypt for Node.js 24
shell: bash
run: |
7z x wekan-${VERSION}-amd64.zip
# Fix: bcrypt 5.0.1 (Meteor accounts-password) ships node-addon-api@3
# which fails to compile on Node.js 24. Replace with bcrypt 6.0.0.
BCRYPT_DIR="$(pwd)/bundle/programs/server/npm/node_modules/meteor/accounts-password/node_modules/bcrypt"
if [ -d "$BCRYPT_DIR" ]; then
rm -rf "$BCRYPT_DIR"
TMP=$(mktemp -d)
pushd "$TMP" > /dev/null
npm pack bcrypt@6.0.0
mkdir bcrypt && tar xzf bcrypt-*.tgz --strip-components=1 -C bcrypt
cd bcrypt && npm install --ignore-scripts && cd ..
cp -r bcrypt "$BCRYPT_DIR"
popd > /dev/null
rm -rf "$TMP"
fi
- name: Rebuild native modules for Windows
shell: bash
run: |
cd bundle/programs/server
rm -rf node_modules
npm install
- name: Add MongoDB tools, mongosh and start-wekan.bat to the bundle
shell: bash
run: |
set -euo pipefail
# Use the same MongoDB tools/mongosh versions as the snap, which
# releases/version.sh keeps at the newest release for each bundle.
TOOLS_VER=$(grep -oE 'mongodb-database-tools-ubuntu2204-\$\{TOOLS_ARCH\}-[0-9]+\.[0-9]+\.[0-9]+' src/snapcraft.yaml \
| grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)
MONGOSH_VER=$(grep -oE 'mongosh-[0-9]+\.[0-9]+\.[0-9]+-linux' src/snapcraft.yaml \
| grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1)
echo "MongoDB Database Tools: $TOOLS_VER mongosh: $MONGOSH_VER"
# MongoDB Database Tools (Windows): bsondump, mongodump, mongoexport,
# mongofiles, mongoimport, mongorestore, mongostat, mongotop.
TOOLS_DIR="mongodb-database-tools-windows-x86_64-${TOOLS_VER}"
curl -fL -o tools.zip \
"https://fastdl.mongodb.org/tools/db/${TOOLS_DIR}.zip"
7z x -y tools.zip > /dev/null
cp "${TOOLS_DIR}/bin/"*.exe bundle/
# mongosh (Windows): mongosh.exe + mongosh_crypt_v1.dll.
MONGOSH_DIR="mongosh-${MONGOSH_VER}-win32-x64"
curl -fL -o mongosh.zip \
"https://downloads.mongodb.com/compass/${MONGOSH_DIR}.zip"
7z x -y mongosh.zip > /dev/null
cp "${MONGOSH_DIR}/bin/mongosh.exe" bundle/
cp "${MONGOSH_DIR}/bin/mongosh_crypt_v1.dll" bundle/
# Windows launcher from the repo root.
cp src/start-wekan.bat bundle/
echo "=== bundle contents ==="
ls -1 bundle/
- name: Create win64 zip
shell: cmd
run: 7z a wekan-%VERSION%-win64.zip bundle
- uses: actions/upload-artifact@v7
with:
name: bundle-win64
path: wekan-${{ needs.prepare.outputs.version }}-win64.zip
retention-days: 1
# ─── 6. Build mac-arm64 bundle (rebuild native modules on Apple Silicon) ─────
build-mac-arm64:
needs: [prepare, build-amd64]
runs-on: macos-14 # macos-14 runners are Apple Silicon (arm64)
timeout-minutes: 60
env:
VERSION: ${{ needs.prepare.outputs.version }}
steps:
- uses: actions/setup-node@v6
with:
node-version: ${{ env.NODE_VERSION }}
- name: Download amd64 bundle
uses: actions/download-artifact@v8
with:
name: bundle-amd64
- name: Rebuild native modules for macOS arm64
run: |
unzip -q wekan-${VERSION}-amd64.zip
# Fix: bcrypt 5.0.1 (Meteor accounts-password) ships node-addon-api@3
# which fails to compile on Node.js 24. Replace with bcrypt 6.0.0.
BCRYPT_DIR="$(pwd)/bundle/programs/server/npm/node_modules/meteor/accounts-password/node_modules/bcrypt"
if [ -d "$BCRYPT_DIR" ]; then
rm -rf "$BCRYPT_DIR"
TMP=$(mktemp -d)
pushd "$TMP" > /dev/null
npm pack bcrypt@6.0.0
mkdir bcrypt && tar xzf bcrypt-*.tgz --strip-components=1 -C bcrypt
cd bcrypt && npm install --ignore-scripts && cd ..
cp -a bcrypt "$BCRYPT_DIR"
popd > /dev/null
rm -rf "$TMP"
fi
cd bundle/programs/server
rm -rf node_modules
npm install
cd ../../..
- name: Create mac-arm64 zip
run: zip -r wekan-${VERSION}-mac-arm64.zip bundle
- uses: actions/upload-artifact@v7
with:
name: bundle-mac-arm64
path: wekan-${{ needs.prepare.outputs.version }}-mac-arm64.zip
retention-days: 1
# ─── 7. Create GitHub Release and upload all bundles ────────────────────────
release:
needs:
- prepare
- build-amd64
- build-arm64
- build-win64
- build-mac-arm64
runs-on: ubuntu-latest
env:
VERSION: ${{ needs.prepare.outputs.version }}
steps:
- name: Download all bundles
uses: actions/download-artifact@v8
with:
pattern: bundle-*
merge-multiple: true
- name: Create GitHub Release with all bundles
uses: softprops/action-gh-release@v3
with:
tag_name: v${{ needs.prepare.outputs.version }}
name: v${{ needs.prepare.outputs.version }}
body: ${{ needs.prepare.outputs.changelog }}
files: |
wekan-${{ needs.prepare.outputs.version }}-amd64.zip
wekan-${{ needs.prepare.outputs.version }}-arm64.zip
wekan-${{ needs.prepare.outputs.version }}-win64.zip
wekan-${{ needs.prepare.outputs.version }}-mac-arm64.zip
# ─── 8. Build and push the multi-arch Docker image to all registries ────────
# Mirrors releases/docker-build.sh step 3 (multi-arch buildx --push).
# Needs `release` because the Dockerfile downloads wekan-<version>-<arch>.zip
# from the GitHub Release; building before those assets exist 404s and (without
# set -e in the Dockerfile) shipped a broken image missing /build/main.js.
docker:
needs: [prepare, release]
runs-on: ubuntu-latest
env:
VERSION: ${{ needs.prepare.outputs.version }}
steps:
- uses: actions/checkout@v6
with:
ref: refs/tags/v${{ needs.prepare.outputs.version }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub, Quay.io and GHCR
env:
DOCKERHUB_AUTH: ${{ secrets.DOCKERHUB_AUTH }}
QUAY_AUTH: ${{ secrets.QUAY_AUTH }}
GHCR_AUTH: ${{ secrets.GHCR_AUTH }}
run: |
login() {
# $1 = registry host, $2 = base64("user:token")
local host="$1" creds user token
creds="$(printf '%s' "$2" | base64 -d)"
user="${creds%%:*}"
token="${creds#*:}"
printf '%s' "$token" | docker login "$host" -u "$user" --password-stdin
}
login docker.io "$DOCKERHUB_AUTH"
login quay.io "$QUAY_AUTH"
login ghcr.io "$GHCR_AUTH"
- name: Build and push multi-arch image
run: |
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t wekanteam/wekan:v${VERSION} \
-t wekanteam/wekan:latest \
-t quay.io/wekan/wekan:v${VERSION} \
-t quay.io/wekan/wekan:latest \
-t ghcr.io/wekan/wekan:v${VERSION} \
-t ghcr.io/wekan/wekan:latest \
--push \
.
# ─── 9. Build the snap on Launchpad, push to the store, attach to release ────
# The snap's `wekan` part downloads wekan-<version>-<arch>.zip from the GitHub
# Release, so this job runs only after `release` has published those assets.
# `snapcraft remote-build` builds every platform listed in snapcraft.yaml
# (amd64 + arm64) on Launchpad.
snap:
needs: [prepare, release]
runs-on: ubuntu-latest
env:
VERSION: ${{ needs.prepare.outputs.version }}
steps:
- uses: actions/checkout@v6
with:
ref: refs/tags/v${{ needs.prepare.outputs.version }}
# remote-build (Launchpad) rejects shallow clones, so fetch full history
fetch-depth: 0
- name: Install snapcraft
run: sudo snap install snapcraft --classic
- name: Provide Launchpad credentials for remote-build
env:
LP_CREDENTIALS: ${{ secrets.LP_CREDENTIALS }}
run: |
mkdir -p ~/.local/share/snapcraft
printf '%s' "$LP_CREDENTIALS" | base64 -d > ~/.local/share/snapcraft/launchpad-credentials
chmod 600 ~/.local/share/snapcraft/launchpad-credentials
- name: Build snaps on Launchpad (amd64 + arm64)
run: |
snapcraft remote-build --launchpad-accept-public-upload
- name: Push snaps to the Snap Store (edge + beta channels)
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_AUTH }}
run: |
for f in wekan_${VERSION}_*.snap; do
echo "Uploading $f to the Snap Store (edge,beta)..."
snapcraft upload --release=edge,beta "$f"
done
- name: Attach snaps to the GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release upload "v${VERSION}" wekan_${VERSION}_*.snap --clobber
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v5
with:
fetch-depth: 0
+163
View File
@@ -0,0 +1,163 @@
name: Test suite
on:
push:
branches:
- main
pull_request:
permissions:
contents: read # to fetch code (actions/checkout)
jobs:
# the following are optional jobs and need to be configured according
# to this project's settings:
#
# lintcode:
# name: Javascript lint
# runs-on: ubuntu-latest
# steps:
# - name: checkout
# uses: actions/checkout@v5
#
# - name: setup node
# uses: actions/setup-node@v1
# with:
# node-version: '12.x'
#
# - name: cache dependencies
# uses: actions/cache@v1
# with:
# path: ~/.npm
# key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
# restore-keys: |
# ${{ runner.os }}-node-
#
# - run: npm install
# - run: npm run lint:code
#
# lintstyle:
# name: SCSS lint
# runs-on: ubuntu-latest
# needs: [lintcode]
# steps:
# - name: checkout
# uses: actions/checkout@v5
#
# - name: setup node
# uses: actions/setup-node@v1
# with:
# node-version: '12.x'
#
# - name: cache dependencies
# uses: actions/cache@v1
# with:
# path: ~/.npm
# key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
# restore-keys: |
# ${{ runner.os }}-node-
# - run: npm install
# - run: npm run lint:style
#
# lintdocs:
# name: documentation lint
# runs-on: ubuntu-latest
# needs: [lintcode,lintstyle]
# steps:
# - name: checkout
# uses: actions/checkout@v5
#
# - name: setup node
# uses: actions/setup-node@v1
# with:
# node-version: '12.x'
#
# - name: cache dependencies
# uses: actions/cache@v1
# with:
# path: ~/.npm
# key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
# restore-keys: |
# ${{ runner.os }}-node-
#
# - run: npm install
# - run: npm run lint:markdown
tests:
name: Meteor ${{ matrix.meteor }} tests
runs-on: ubuntu-latest
steps:
# CHECKOUTS
- name: Checkout
uses: actions/checkout@v5
# CACHING
- name: Install Meteor
id: cache-meteor-install
uses: actions/cache@v4
with:
path: ~/.meteor
key: v1-meteor-${{ hashFiles('.meteor/versions') }}
restore-keys: |
v1-meteor-
- name: Cache NPM dependencies
id: cache-meteor-npm
uses: actions/cache@v4
with:
path: ~/.npm
key: v1-npm-${{ hashFiles('package-lock.json') }}
restore-keys: |
v1-npm-
- name: Cache Meteor build
id: cache-meteor-build
uses: actions/cache@v4
with:
path: |
.meteor/local/resolver-result-cache.json
.meteor/local/plugin-cache
.meteor/local/isopacks
.meteor/local/bundler-cache/scanner
key: v1-meteor_build_cache-${{ github.ref }}-${{ github.sha }}
restore-keys: |
v1-meteor_build_cache-
- name: Setup meteor
uses: meteorengineer/setup-meteor@v2
with:
meteor-release: '2.2'
- name: Install NPM Dependencies
run: meteor npm ci
- name: Run Tests
run: sh ./test-wekan.sh -cv
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
name: coverage-folder
path: .coverage/
coverage:
name: Coverage report
runs-on: ubuntu-latest
needs: [tests]
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Download coverage
uses: actions/download-artifact@v5
with:
name: coverage-folder
path: .coverage/
- name: Coverage Report
uses: VeryGoodOpenSource/very_good_coverage@v3.0.0
with:
path: ".coverage/lcov.info"
min_coverage: 1 # TODO add tests and increase to 95!
-25
View File
@@ -1,5 +1,4 @@
*~
/*.log
*.sw*
.meteor-spk
*.sublime-workspace
@@ -36,31 +35,7 @@ ehthumbs.db
.devcontainer/volumes*/
.coverage
# Rspack build artifacts
_build/
public/build-assets/
public/build-chunks/
private/build-assets/
private/build-chunks/
public/client-rspack.js.map
# Helm chart
# Chart dependencies
/helm/wekan/**/*.tgz
/helm/wekan/charts
# Meteor Modern-Tools build context directories
_build
*/build-assets
*/build-chunks
.rsdoctor
# Tests
playwright-report/
test-results/
# Claude AI
.claude/
# Tools like mongosh etc so that they work inside Flatpak sandbox
.tools/
+67 -36
View File
@@ -3,29 +3,32 @@
# 'meteor add' and 'meteor remove' will edit this file for you,
# but you can also edit it by hand.
meteor-base@1.5.2
meteor-base@1.5.1
# Build system
ecmascript@0.18.0
standard-minifier-js@3.2.0
rspack@1.1.0
ecmascript@0.16.8
standard-minifier-js@2.8.1
mquandalle:jade
coffeescript@2.4.1!
blaze@3.0.2
# Polyfills
es5-shim@4.8.0
# Collections
aldeed:collection2
aldeed:schema-index@4.0.0-beta.5
reywood:publish-composite
mongo@2.4.0-rc350.1
cottz:publish-relations
dburles:collection-helpers
idmontie:migrations
easy:search
mongo@1.16.8
mquandalle:collection-mutations
# Account system
accounts-password@3.3.0-rc350.1
accounts-express@1.0.0-rc350.1
communitypackages:core
communitypackages:unstyled
communitypackages:flow-routing
# Routing
ostrio:flow-router-extra
accounts-password@2.4.0
useraccounts:core
useraccounts:flow-routing
useraccounts:unstyled
simple:rest-accounts-password
wekan-ldap
wekan-accounts-cas
wekan-accounts-sandstorm
@@ -34,32 +37,60 @@ wekan-oidc
wekan-accounts-oidc
# Utilities
check@1.5.0
random@1.2.2
reactive-dict@1.3.2
session@1.2.3-rc350.1
tracker@1.3.4
audit-argument-checks@1.0.8
check@1.3.2
jquery@3.0.0!
random@1.2.1
reactive-dict@1.3.1
session@1.2.1
tracker@1.3.3
underscore@1.0.13
arillo:flow-router-helpers
audit-argument-checks@1.0.7
kadira:dochead
mquandalle:autofocus
ongoworks:speakingurl
raix:handlebar-helpers
http@2.0.0! # force new http package
# Datepicker
wekan-bootstrap-datepicker
# UI components
ostrio:i18n
reactive-var@1.0.13
shell-server@0.7.0
email@3.2.0-rc350.1
dynamic-import@0.7.4
reactive-var@1.0.12
mousetrap:mousetrap
mquandalle:jquery-textcomplete
mquandalle:mousetrap-bindglobal
templates:tabs
meteor-autosize
shell-server@0.5.0
email@2.2.5
dynamic-import@0.7.3
msavin:usercache
# Keep stylus in 1.1.0, because building v2 takes extra 52 minutes.
meteorhacks:subs-manager
meteorhacks:aggregate@1.3.0
wekan-markdown
quave:synced-cron
konecty:mongo-counter
percolate:synced-cron
ostrio:cookies
ostrio:files
# lmieulet:meteor-coverage
meteortesting:mocha@3.0.0
ostrio:files@2.3.0
pascoual:pdfkit
lmieulet:meteor-coverage
meteortesting:mocha@2.0.3
aldeed:simple-schema
matb33:collection-hooks
service-configuration@1.3.5
minifier-css@2.0.2-rc350.1
ejson@1.2.0-rc350.1
logging@1.3.6
simple:json-routes
kadira:flow-router
spacebars
service-configuration@1.3.2
communitypackages:picker
minifier-css@1.6.4
blaze
kadira:blaze-layout
peerlibrary:blaze-components
ejson@1.1.3
logging@1.3.3
wekan-fullcalendar
momentjs:moment@2.29.3
wekan-fontawesome
jam:offline
jam:method
jam:pub-sub
+1 -1
View File
@@ -1 +1 @@
METEOR@3.5-rc.1
METEOR@2.14
+161 -116
View File
@@ -1,121 +1,166 @@
accounts-base@3.3.0-rc350.1
accounts-express@1.0.0-rc350.1
accounts-oauth@1.4.7-rc350.1
accounts-password@3.3.0-rc350.1
aldeed:collection2@4.2.0
aldeed:schema-index@4.0.0
aldeed:simple-schema@1.13.1
allow-deny@2.1.0
audit-argument-checks@1.0.8
autoupdate@2.0.1
babel-compiler@7.14.0
babel-runtime@1.5.2
base64@1.0.14-rc350.1
binary-heap@1.0.13-rc350.1
blaze@3.0.3
blaze-tools@2.0.1
boilerplate-generator@2.1.0
caching-compiler@2.0.1
caching-html-compiler@2.0.1
callback-hook@1.8.0-rc350.1
check@1.5.0
communitypackages:core@2.0.3
communitypackages:flow-routing@2.1.0
communitypackages:unstyled@2.0.1
core-runtime@1.0.0
ddp@1.4.2
ddp-client@3.3.0-rc350.1
ddp-common@1.4.5-rc350.1
ddp-rate-limiter@1.3.0-rc350.1
ddp-server@3.3.0-rc350.1
diff-sequence@1.1.3
dynamic-import@0.7.4
ecmascript@0.18.0
ecmascript-runtime@0.8.3
ecmascript-runtime-client@0.13.0
ecmascript-runtime-server@0.11.1
ejson@1.2.0-rc350.1
email@3.2.0-rc350.1
es5-shim@4.8.1
facts-base@1.0.2
fetch@0.2.0-rc350.1
geojson-utils@1.0.12
hot-code-push@1.0.5
html-tools@2.0.1
htmljs@2.0.2
id-map@1.2.0
inter-process-messaging@0.1.2
jam:method@1.9.1
jam:offline@0.4.1
jam:pub-sub@0.5.1
jquery@3.0.2
lai:collection-extensions@1.0.0
localstorage@1.2.1
logging@1.3.6
matb33:collection-hooks@2.1.0
meteor@2.3.1-rc350.1
meteor-base@1.5.2
meteortesting:browser-tests@1.8.0
meteortesting:mocha@3.3.0
meteortesting:mocha-core@8.2.0
minifier-css@2.0.2-rc350.1
minifier-js@3.2.0
minimongo@2.2.0-rc350.1
modern-browsers@0.2.3
modules@0.20.3
modules-runtime@0.13.2
mongo@2.4.0-rc350.1
mongo-decimal@0.2.0
mongo-dev-server@1.1.1
mongo-id@1.0.9
npm-mongo@6.16.1
oauth@3.0.4-rc350.1
oauth2@1.3.3
observe-sequence@2.0.1
ordered-dict@1.2.0
ostrio:cookies@2.9.2
accounts-base@2.2.10
accounts-oauth@1.4.3
accounts-password@2.4.0
aldeed:collection2@2.10.0
aldeed:collection2-core@1.2.0
aldeed:schema-deny@1.1.0
aldeed:schema-index@1.1.1
aldeed:simple-schema@1.5.4
allow-deny@1.1.1
arillo:flow-router-helpers@0.5.2
audit-argument-checks@1.0.7
autoupdate@1.8.0
babel-compiler@7.10.5
babel-runtime@1.5.1
base64@1.0.12
binary-heap@1.0.11
blaze@2.7.1
blaze-tools@1.1.3
boilerplate-generator@1.7.2
caching-compiler@1.2.2
caching-html-compiler@1.2.1
callback-hook@1.5.1
check@1.3.2
coffeescript@2.7.0
coffeescript-compiler@2.4.1
communitypackages:picker@1.1.1
cottz:publish-relations@2.0.8
dburles:collection-helpers@1.1.0
ddp@1.4.1
ddp-client@2.6.1
ddp-common@1.4.0
ddp-rate-limiter@1.2.1
ddp-server@2.7.0
deps@1.0.12
diff-sequence@1.1.2
dynamic-import@0.7.3
easy:search@2.2.1
easysearch:components@2.2.2
easysearch:core@2.2.2
ecmascript@0.16.8
ecmascript-runtime@0.8.1
ecmascript-runtime-client@0.12.1
ecmascript-runtime-server@0.11.0
ejson@1.1.3
email@2.2.5
es5-shim@4.8.0
fetch@0.1.4
geojson-utils@1.0.11
hot-code-push@1.0.4
html-tools@1.1.3
htmljs@1.1.1
http@2.0.0
id-map@1.1.1
idmontie:migrations@1.0.3
inter-process-messaging@0.1.1
jquery@3.0.0
kadira:blaze-layout@2.3.0
kadira:dochead@1.5.0
kadira:flow-router@2.12.1
konecty:mongo-counter@0.0.5_3
lmieulet:meteor-coverage@1.1.4
localstorage@1.2.0
logging@1.3.3
matb33:collection-hooks@1.3.0
mdg:validation-error@0.5.1
meteor@1.11.5
meteor-autosize@5.0.1
meteor-base@1.5.1
meteorhacks:aggregate@1.3.0
meteorhacks:collection-utils@1.2.0
meteorhacks:picker@1.0.3
meteorhacks:subs-manager@1.6.4
meteortesting:browser-tests@1.4.2
meteortesting:mocha@2.1.0
meteortesting:mocha-core@8.0.1
minifier-css@1.6.4
minifier-js@2.7.5
minifiers@1.1.8-faster-rebuild.0
minimongo@1.9.3
modern-browsers@0.1.10
modules@0.20.0
modules-runtime@0.13.1
momentjs:moment@2.29.3
mongo@1.16.8
mongo-decimal@0.1.3
mongo-dev-server@1.1.0
mongo-id@1.0.8
mongo-livedata@1.0.12
mousetrap:mousetrap@1.4.6_1
mquandalle:autofocus@1.0.0
mquandalle:collection-mutations@0.1.0
mquandalle:jade@0.4.9
mquandalle:jade-compiler@0.4.5
mquandalle:jquery-textcomplete@0.8.0_1
mquandalle:mousetrap-bindglobal@0.0.1
msavin:usercache@1.8.0
npm-mongo@4.17.2
oauth@2.2.1
oauth2@1.3.2
observe-sequence@1.0.21
ongoworks:speakingurl@1.1.0
ordered-dict@1.1.0
ostrio:cookies@2.7.2
ostrio:cstorage@4.0.1
ostrio:files@3.0.1
ostrio:flow-router-extra@3.15.3
ostrio:files@2.3.3
ostrio:i18n@3.2.1
promise@1.0.0
quave:synced-cron@2.4.0
raix:eventemitter@1.0.0
random@1.2.2
rate-limit@1.2.0-rc350.1
react-fast-refresh@0.3.0
reactive-dict@1.3.2
reactive-var@1.0.13
reload@1.3.2
retry@1.1.1
reywood:publish-composite@1.9.0
routepolicy@1.1.2
rspack@1.1.0
service-configuration@1.3.5
session@1.2.3-rc350.1
sha@1.0.10
shell-server@0.7.0
socket-stream-client@0.7.0-rc350.1
spacebars@2.0.1
spacebars-compiler@2.0.1
standard-minifier-js@3.2.0
templating@1.4.5
templating-compiler@2.0.1
templating-runtime@2.0.2
templating-tools@2.0.1
tools-core@1.1.0
tracker@1.3.4
typescript@5.10.0
url@1.3.6-rc350.1
webapp@2.2.0-rc350.1
webapp-hashing@1.1.2
wekan-accounts-cas@0.2.0
wekan-accounts-lockout@1.1.0
pascoual:pdfkit@1.0.7
peerlibrary:assert@0.3.0
peerlibrary:base-component@0.17.1
peerlibrary:blaze-components@0.23.0
peerlibrary:computed-field@0.10.0
peerlibrary:data-lookup@0.3.0
peerlibrary:reactive-field@0.6.0
percolate:synced-cron@1.5.2
promise@0.12.2
raix:eventemitter@0.1.3
raix:handlebar-helpers@0.2.5
random@1.2.1
rate-limit@1.1.1
react-fast-refresh@0.2.8
reactive-dict@1.3.1
reactive-var@1.0.12
reload@1.3.1
retry@1.1.0
routepolicy@1.1.1
service-configuration@1.3.3
session@1.2.1
sha@1.0.9
shell-server@0.5.0
simple:authenticate-user-by-token@1.2.1
simple:json-routes@2.3.1
simple:rest-accounts-password@1.2.2
simple:rest-bearer-token-parser@1.1.1
simple:rest-json-error-handler@1.1.1
socket-stream-client@0.5.2
spacebars@1.4.1
spacebars-compiler@1.3.1
standard-minifier-js@2.8.1
templates:tabs@2.3.0
templating@1.4.1
templating-compiler@1.4.1
templating-runtime@1.5.0
templating-tools@1.2.2
tracker@1.3.3
typescript@4.9.5
ui@1.0.13
underscore@1.0.13
url@1.3.2
useraccounts:core@1.16.2
useraccounts:flow-routing@1.15.0
useraccounts:unstyled@1.14.2
webapp@1.13.6
webapp-hashing@1.1.1
wekan-accounts-cas@0.1.0
wekan-accounts-lockout@1.0.0
wekan-accounts-oidc@1.0.10
wekan-accounts-sandstorm@0.9.0
wekan-accounts-sandstorm@0.8.0
wekan-bootstrap-datepicker@1.10.0
wekan-fontawesome@6.4.2
wekan-fullcalendar@5.11.5
wekan-ldap@0.1.0
wekan-fullcalendar@3.10.5
wekan-ldap@0.0.2
wekan-markdown@1.0.9
wekan-oidc@1.1.0
zodern:types@1.0.13
wekan-oidc@1.0.12
yasaricli:slugify@0.0.7
zimme:active-route@2.3.2
zodern:types@1.0.10
-2
View File
@@ -1,2 +0,0 @@
npm-packages/
tests/
+1 -1
View File
@@ -1,6 +1,6 @@
[main]
host = https://www.transifex.com
lang_map = te_IN: te-IN, es_AR: es-AR, es_419: es-LA, es_TX: es-TX, he_IL: he-IL, zh_CN: zh-CN, ar_EG: ar-EG, cs_CZ: cs-CZ, fa_IR: fa-IR, ms_MY: ms-MY, nl_NL: nl-NL, de_CH: de-CH, en_IT: en-IT, uz_UZ: uz-UZ, fr_CH: fr-CH, hi_IN: hi-IN, et_EE: et-EE, es_PE: es-PE, es_MX: es-MX, gl_ES: gl-ES, mn_MN: mn, zh_TW: zh-TW, ast_ES: ast-ES, es_CL: es-CL, ja_JP: ja, lv_LV: lv, ro_RO: ro-RO, az_AZ: az-AZ, cy_GB: cy-GB, gu_IN: gu-IN, pl_PL: pl-PL, vep: ve-PP, en_BR: en-BR, en@ysv: en-YS, hu_HU: hu, ko_KR: ko-KR, pt_BR: pt-BR, zh_HK: zh-HK, zu_ZA: zu-ZA, en_MY: en-MY, ja-Hira: ja-HI, fi_FI: fi, vec: ve-CC, vi_VN: vi-VN, fr_FR: fr-FR, id_ID: id, zh_Hans: zh-Hans, en_DE: en-DE, en_GB: en-GB, el_GR: el-GR, uk_UA: uk-UA, az@latin: az-LA, de_AT: de-AT, uz@Latn: uz-LA, vls: vl-SS, ar_DZ: ar-DZ, bg_BG: bg, es_PY: es-PY, fy_NL: fy-NL, uz@Arab: uz-AR, ru_UA: ru-UA, war: wa-RR, zh_CN.GB2312: zh-GB
lang_map = te_IN: te-IN, es_AR: es-AR, es_419: es-LA, es_TX: es-TX, he_IL: he-IL, zh_CN: zh-CN, ar_EG: ar-EG, cs_CZ: cs-CZ, fa_IR: fa-IR, ms_MY: ms-MY, nl_NL: nl-NL, de_CH: de-CH, en_IT: en-IT, uz_UZ: uz-UZ, fr_CH: fr-CH, hi_IN: hi-IN, et_EE: et-EE, es_PE: es-PE, es_MX: es-MX, gl_ES: gl-ES, mn_MN: mn, sl_SI: sl, zh_TW: zh-TW, ast_ES: ast-ES, es_CL: es-CL, ja_JP: ja, lv_LV: lv, ro_RO: ro-RO, az_AZ: az-AZ, cy_GB: cy-GB, gu_IN: gu-IN, pl_PL: pl-PL, vep: ve-PP, en_BR: en-BR, en@ysv: en-YS, hu_HU: hu, ko_KR: ko-KR, pt_BR: pt-BR, zh_HK: zh-HK, zu_ZA: zu-ZA, en_MY: en-MY, ja-Hira: ja-HI, fi_FI: fi, vec: ve-CC, vi_VN: vi-VN, fr_FR: fr-FR, id_ID: id, zh_Hans: zh-Hans, en_DE: en-DE, en_GB: en-GB, el_GR: el-GR, uk_UA: uk-UA, az@latin: az-LA, de_AT: de-AT, uz@Latn: uz-LA, vls: vl-SS, ar_DZ: ar-DZ, bg_BG: bg, es_PY: es-PY, fy_NL: fy-NL, uz@Arab: uz-AR, ru_UA: ru-UA, war: wa-RR, zh_CN.GB2312: zh-GB
[o:wekan:p:wekan:r:application]
file_filter = imports/i18n/data/<lang>.i18n.json
+11 -15
View File
@@ -1,16 +1,12 @@
{
"terminal.integrated.env.linux": {
"TERM": "xterm-256color"
},
"terminal.integrated.env.osx": {
"TERM": "xterm-256color"
},
"terminal.integrated.env.windows": {
"TERM": "xterm-256color"
},
"terminal.integrated.shell.linux": "/bin/bash",
"terminal.integrated.shellArgs.linux": [
"-l"
],
"files.simpleDialog.enable": true
}
"editor.tabSize": 2,
"editor.rulers": [80],
"files.trimTrailingWhitespace": true,
"files.insertFinalNewline": true,
"files.exclude": {
"**/.git": true,
"**/.DS_Store": true,
"node_modules": true,
".meteor/local": true
}
}
+20 -3886
View File
File diff suppressed because it is too large. Load diff
+2 -7
View File
@@ -3,20 +3,15 @@
For all code at WeKan GitHub Organization https://github.com/wekan
- All code in pull requests need to have permission already to add it to WeKan with MIT license, and will become MIT license.
- All code xet7 add is MIT license.
- For any dependencies, permissive licenses like https://copyfree.org are preferred
- For anything currently that is non-permissive (like GPL, AGPL, SSPL), those will be replaced with permissive-licensed alternatives
- Any intelligence allowed: Human, AI or any other. No discrimination, please have world peace. Mentioning participants optional.
- All code is checked and validated by xet7 and many different security AI. If code is real working fix or feature, it usually will be merged.
- All known vulnerabilities have been fixed at newest WeKan, please upgrade https://wekan.fi/upgrade/
- There is no known backdoors. If WeKan is installed by someone else, first registered user is Admin and has Admin Panel access.
- Maintainer of WeKan has no access to someone elses installed WeKan.
- For someone elses installed WeKan, see [Forgot Password](https://github.com/wekan/wekan/blob/main/docs/Login/Forgot-Password.md)
# Reporting about violations or something else
## Private reports
- Email support@wekan.fi
- Email support@wekan.team
- Security issues: [SECURITY.md](SECURITY.md)
- License violations
- Anything private, sensitive or negative
+19 -26
View File
@@ -6,25 +6,24 @@ Not paid:
- Security reports, see [SECURITY.md](SECURITY.md)
- Pull requests
- xet7 checking pull requests
- This is because full-featured WeKan FLOSS kanban with MIT license is free to use
also for Commercial Use without any payment, with full source code at
https://github.com/wekan/wekan for all platforms at https://wekan.fi/install/ .
There is nothing proprietary. WeKan is NOT Big Tech.
WeKan is maintained by https://github.com/xet7 at Finland https://wekan.fi .
- Public FLOSS Developer Community Support:
- Public Community Support
- https://github.com/wekan/wekan/issues
Paid by customers of WeKan to WeKan:
Paid by customers of WeKan Team:
- Optional Private Paid Commercial Support at https://wekan.fi/commercial-support/
- Optional Paid Support/Features/Fixes/PrivateChat . All Paid Features and Fixes are added directly to FLOSS WeKan https://github.com/wekan/wekan
- Optional Paid Official SaaS Hosting https://wekan.fi/saas/ , if you need full-featured hosting, that is more than free app https://wekan.fi/app/
- Commercial Support at https://wekan.fi/commercial-support/
- Support
- Private Chat
- Features
- Fixes
- Hosting
## Contributing Security related
For responsible security disclosure, please follow this process: [SECURITY.md](SECURITY.md)
For responsible security disclosure, please follow this process:
https://github.com/wekan/wekan/blob/main/SECURITY.md
CVE Hall of Fame is at https://wekan.fi/hall-of-fame/
CVE Hall of Fame is at https://wekan.github.io/hall-of-fame/
## Contributing to Documentation Wiki
@@ -34,17 +33,7 @@ and click send PR.
## Contributing code
Use rebuild-wekan.sh to run all tests to verify you don't break any features, before sending PR.
Only send one feature or fix at one PR. Do not add many unrelated changes.
Only send those changed code lines, that are related to your one feature or fix.
Do not format unrelated code. Do not use code formatting or linting like eslist or prettier.
Send your code ASAP, because there are daily releases of WeKan with major features and fixes added.
[Building WeKan and sending PR](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Build-and-Create-Pull-Request.md).
[Building WeKan and sending PR](https://github.com/wekan/wekan/wiki/Emoji).
WeKan code contributors Hall of Fame is at ChangeLog, where
GitHub usernames are mentioned with changes added:
@@ -55,9 +44,13 @@ Changes can be like typo fixes, bugfixes, features, or anything else
like for example at open GitHub issues https://github.com/wekan/wekan/issues .
Closed issues are already fixed or implemented.
Also see other docs:
Also see other docs at wiki, for example:
https://github.com/wekan/wekan/tree/main/docs/DeveloperDocs
https://github.com/wekan/wekan/wiki/Developer-Documentation
Do not use code formatting or linting like eslist or prettier.
Only send minimal changed code lines, that are related to feature or fix.
WeKan code has MIT license.
@@ -71,7 +64,7 @@ so that WeKan code will still work OK.
## Contributing translations
Non-English translations are contributed only at
https://app.transifex.com/wekan/wekan
https://transifex.com/wekan/wekan
When adding new features, in your PR to
https://github.com/wekan/wekan/pulls
+119 -66
View File
@@ -1,26 +1,31 @@
FROM ubuntu:26.04
FROM ubuntu:24.04
LABEL maintainer="wekan"
LABEL org.opencontainers.image.ref.name="ubuntu"
LABEL org.opencontainers.image.version="26.04"
LABEL org.opencontainers.image.version="24.04"
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.42
# 2022-04-25:
# - gyp does not yet work with Ubuntu 22.04 ubuntu:rolling,
# so changing to 21.10. https://github.com/wekan/wekan/issues/4488
# 2021-09-18:
# - Above Ubuntu base image copied from Docker Hub ubuntu:hirsute-20210825
# to Quay to avoid Docker Hub rate limits.
ARG DEBIAN_FRONTEND=noninteractive
ENV BUILD_DEPS="apt-utils gnupg wget bzip2 g++ curl libarchive-tools build-essential git ca-certificates python3 unzip"
ENV BUILD_DEPS="apt-utils gnupg gosu wget bzip2 g++ curl libarchive-tools build-essential git ca-certificates python3 unzip"
ENV \
DEBUG=false \
NODE_VERSION=v24.16.0 \
METEOR_RELEASE=METEOR@3.5-beta.12 \
NODE_VERSION=v14.21.4 \
METEOR_RELEASE=METEOR@2.14 \
USE_EDGE=false \
NPM_VERSION=11.11.0 \
METEOR_EDGE=1.5-beta.17 \
NPM_VERSION=6.14.17 \
FIBERS_VERSION=4.0.1 \
ARCHITECTURE=linux-x64 \
SRC_PATH=./ \
WITH_API=true \
MONGO_OPLOG_URL="" \
RESULTS_PER_PAGE="" \
DEFAULT_BOARD_ID="" \
ACCOUNTS_LOCKOUT_KNOWN_USERS_FAILURES_BEFORE=3 \
@@ -61,7 +66,6 @@ ENV \
OAUTH2_LOGIN_STYLE=redirect \
OAUTH2_CLIENT_ID="" \
OAUTH2_SECRET="" \
OAUTH2_SECRET_FILE="" \
OAUTH2_SERVER_URL="" \
OAUTH2_AUTH_ENDPOINT="" \
OAUTH2_USERINFO_ENDPOINT="" \
@@ -87,7 +91,6 @@ ENV \
LDAP_AUTHENTIFICATION=false \
LDAP_AUTHENTIFICATION_USERDN="" \
LDAP_AUTHENTIFICATION_PASSWORD="" \
LDAP_AUTHENTIFICATION_PASSWORD_FILE="" \
LDAP_LOG_ENABLED=false \
LDAP_BACKGROUND_SYNC=false \
LDAP_BACKGROUND_SYNC_INTERVAL="" \
@@ -153,86 +156,136 @@ ENV \
ORACLE_OIM_ENABLED=false \
WAIT_SPINNER="" \
WRITABLE_PATH=/data \
S3="" \
MAIL_SERVICE_PASSWORD_FILE="" \
MONGO_PASSWORD_FILE="" \
S3_SECRET_FILE=""
S3=""
# NODE_OPTIONS="--max_old_space_size=4096"
#---------------------------------------------
# == at docker-compose.yml: AUTOLOGIN WITH OIDC/OAUTH2 ====
# https://github.com/wekan/wekan/wiki/autologin
#- OIDC_REDIRECTION_ENABLED=true
#---------------------------------------------------------------------
# Copy the app to the image
#COPY ${SRC_PATH} /home/wekan/app
# Install OS
RUN <<EOR
set -o xtrace
# Fail hard on any error so a missing release zip / failed download can never
# produce a "successful" image with an empty /build (Cannot find /build/main.js).
set -eo pipefail
# Create Wekan user. --create-home is required because --system users do not
# get a home directory by default; without it /home/wekan never exists and the
# later `chown ... /home/wekan/` aborts the build (now that set -e is active).
useradd --user-group --system --create-home --home-dir /home/wekan wekan
# OS Updates
# Add non-root user wekan
useradd --user-group --system --home-dir /home/wekan wekan
# OS dependencies
apt-get update --assume-yes
apt-get upgrade --assume-yes
apt-get install --assume-yes --no-install-recommends ${BUILD_DEPS}
# Multi-arch mapping: Docker TARGETARCH -> Node.js arch name + WeKan bundle name
# arm/v7 uses TARGETARCH=arm; armhf has no MongoDB Community -> uses FerretDB
case "${TARGETARCH}" in
"amd64") NODE_ARCH="x64" WEKAN_ARCH="amd64" ;;
"arm64") NODE_ARCH="arm64" WEKAN_ARCH="arm64" ;;
"arm") NODE_ARCH="armv7l" WEKAN_ARCH="armhf" ;;
"ppc64le") NODE_ARCH="ppc64le" WEKAN_ARCH="ppc64le" ;;
"s390x") NODE_ARCH="s390x" WEKAN_ARCH="s390x" ;;
*) echo "Unsupported architecture: ${TARGETARCH}"; exit 1 ;;
esac
# Node.js Installation (official nodejs.org builds for Node 22)
cd /tmp
wget "https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-linux-${NODE_ARCH}.tar.gz"
wget "https://nodejs.org/dist/${NODE_VERSION}/SHASUMS256.txt.asc"
grep "node-${NODE_VERSION}-linux-${NODE_ARCH}.tar.gz" SHASUMS256.txt.asc | shasum -a 256 -c -
tar xzf "node-${NODE_VERSION}-linux-${NODE_ARCH}.tar.gz" -C /usr/local --strip-components=1 --no-same-owner
rm -f "node-${NODE_VERSION}-linux-${NODE_ARCH}.tar.gz" SHASUMS256.txt.asc
ln -s "/usr/local/bin/node" "/usr/local/bin/nodejs"
# NPM configuration
npm install -g npm@${NPM_VERSION}
chown --recursive wekan:wekan /home/wekan/
# Temporary Tar swap for Meteor bundle
# Meteor installer doesn't work with the default tar binary, so using bsdtar while installing.
# https://github.com/coreos/bugs/issues/1095#issuecomment-350574389
cp $(which tar) $(which tar)~
ln -sf $(which bsdtar) $(which tar)
# WeKan Bundle Installation
# Install NodeJS
cd /tmp
# Download nodejs
#wget "https://github.com/wekan/node-v14-esm/releases/download/${NODE_VERSION}/node-${NODE_VERSION}-${ARCHITECTURE}.tar.gz"
wget "https://github.com/wekan/node-v14-esm/releases/download/v14.21.4/node-v14.21.4-linux-x64.tar.gz"
#wget "https://github.com/wekan/node-v14-esm/releases/download/${NODE_VERSION}/SHASUMS256.txt"
wget "https://github.com/wekan/node-v14-esm/releases/download/v14.21.4/SHASUMS256.txt"
# Verify nodejs authenticity
#grep "node-${NODE_VERSION}-${ARCHITECTURE}.tar.gz" "SHASUMS256.txt" | shasum -a 256 -c -
grep "node-v14.21.4-linux-x64.tar.gz" "SHASUMS256.txt" | shasum -a 256 -c -
rm -f "SHASUMS256.txt"
# Install Node
#tar xzf "node-$NODE_VERSION-$ARCHITECTURE.tar.gz" -C /usr/local --strip-components=1 --no-same-owner
tar xzf "node-v14.21.4-linux-x64.tar.gz" -C /usr/local --strip-components=1 --no-same-owner
#rm "node-$NODE_VERSION-$ARCHITECTURE.tar.gz" "SHASUMS256.txt"
rm "node-v14.21.4-linux-x64.tar.gz" "SHASUMS256.txt"
ln -s "/usr/local/bin/node" "/usr/local/bin/nodejs"
#mkdir -p "/opt/nodejs/lib/node_modules/fibers/.node-gyp" "/root/.node-gyp/${NODE_VERSION} /home/wekan/.config"
#mkdir -p "/opt/nodejs/lib/node_modules/fibers/.node-gyp" "/root/.node-gyp/v14.21.4 /home/wekan/.config"
# Install node dependencies
#npm install -g npm@${NPM_VERSION} --production
npm install -g npm@$6.14.17 --production
chown --recursive wekan:wekan /home/wekan/.config
# Install Meteor
cd /home/wekan
chown --recursive wekan:wekan /home/wekan
echo "Starting meteor ${METEOR_RELEASE} installation... \n"
#gosu wekan:wekan curl https://install.meteor.com/ | /bin/sh
# Specify Meteor version 2.14 to be compatible: https://github.com/wekan/wekan/pull/5816/files
#gosu wekan:wekan npm -g install meteor@2.14 --unsafe-perm
#mv /root/.meteor /home/wekan/
#chown --recursive wekan:wekan /home/wekan/.meteor
#sed -i 's/api\.versionsFrom/\/\/api.versionsFrom/' /home/wekan/app/packages/meteor-useraccounts-core/package.js
#cd /home/wekan/.meteor
#gosu wekan:wekan /home/wekan/.meteor/meteor -- help
# Build app (Production)
#cd /home/wekan/app
mkdir -p /home/wekan/app
cd /home/wekan/app
wget "https://github.com/wekan/wekan/releases/download/v${VERSION}/wekan-${VERSION}-${WEKAN_ARCH}.zip"
unzip "wekan-${VERSION}-${WEKAN_ARCH}.zip"
rm "wekan-${VERSION}-${WEKAN_ARCH}.zip"
npm install --prefix ./bundle/programs/server
#mkdir -p /home/wekan/.npm
#chown --recursive wekan:wekan /home/wekan/.npm
#chmod u+w *.json
#gosu wekan:wekan meteor npm install --production
#gosu wekan:wekan /home/wekan/.meteor/meteor build --directory /home/wekan/app_build
#cd /home/wekan/app_build/bundle/programs/server/
#chmod u+w *.json
#gosu wekan:wekan meteor npm install --production
#cd node_modules/fibers
#node build.js
#cd ../..
# Remove legacy webbroser bundle, so that Wekan works also at Android Firefox, iOS Safari, etc.
#rm -rf /home/wekan/app_build/bundle/programs/web.browser.legacy
#mv /home/wekan/app_build/bundle /build
wget "https://github.com/wekan/wekan/releases/download/v7.94/wekan-7.94-amd64.zip"
unzip wekan-7.94-amd64.zip
rm wekan-7.94-amd64.zip
mv /home/wekan/app/bundle /build
# Restore original tar
# Put back the original tar
mv $(which tar)~ $(which tar)
# Cleanup
# Remove unused Go-based pebble binary shipped by base image to reduce CVE surface.
apt-get remove --purge --assume-yes pebble || true
rm -f /usr/bin/pebble
apt-get remove --purge --assume-yes ${BUILD_DEPS}
#npm uninstall -g api2html
apt-get autoremove --assume-yes
apt-get clean --assume-yes
rm -Rf /tmp/*
rm -Rf /var/lib/apt/lists/*
rm -Rf /var/cache/apt
rm -Rf /var/lib/apt/lists
rm -Rf /home/wekan/app_build
rm -Rf /home/wekan/app
rm -Rf /home/wekan/.meteor
mkdir -p /data
chown wekan:wekan --recursive /data
mkdir /data
chown wekan --recursive /data
EOR
USER wekan
ENV PORT=8080
EXPOSE $PORT
STOPSIGNAL SIGKILL
WORKDIR /build
CMD ["bash", "-c", "ulimit -s 65500; exec node main.js"]
STOPSIGNAL SIGKILL
WORKDIR /home/wekan/app
#---------------------------------------------------------------------
# https://github.com/wekan/wekan/issues/3585#issuecomment-1021522132
# Add more Node heap:
# NODE_OPTIONS="--max_old_space_size=4096"
# Add more stack:
# bash -c "ulimit -s 65500; exec node --stack-size=65500 main.js"
#---------------------------------------------------------------------
#
# CMD ["node", "/build/main.js"]
# CMD ["bash", "-c", "ulimit -s 65500; exec node --stack-size=65500 /build/main.js"]
# CMD ["bash", "-c", "ulimit -s 65500; exec node --stack-size=65500 --max-old-space-size=8192 /build/main.js"]
CMD ["bash", "-c", "ulimit -s 65500; exec node /build/main.js"]
+93
View File
@@ -0,0 +1,93 @@
FROM arm64v8/ubuntu:23.04 AS builder
#FROM amd64/alpine:latest AS builder
# Set the environment variables for builder
ENV QEMU_VERSION=v7.2.0-1 \
QEMU_ARCHITECTURE=aarch64 \
NODE_ARCHITECTURE=linux-arm64 \
NODE_VERSION=v14.21.4 \
WEKAN_VERSION=latest \
WEKAN_ARCHITECTURE=arm64
# Install dependencies
#RUN apk update && apk add ca-certificates outils-sha1 && \
RUN echo 'debconf debconf/frontend select Noninteractive' | debconf-set-selections
RUN apt update && apt install ca-certificates wget unzip -y && \
\
# Download qemu static for our architecture
wget https://github.com/multiarch/qemu-user-static/releases/download/${QEMU_VERSION}/qemu-${QEMU_ARCHITECTURE}-static.tar.gz -O - | tar -xz && \
\
# Download wekan and shasum
wget https://releases.wekan.team/raspi3/wekan-${WEKAN_VERSION}-${WEKAN_ARCHITECTURE}.zip && \
wget https://releases.wekan.team/raspi3/SHA256SUMS.txt && \
# Verify wekan
grep wekan-${WEKAN_VERSION}-${WEKAN_ARCHITECTURE}.zip SHA256SUMS.txt | sha256sum -c - && \
\
# Unzip wekan
unzip wekan-${WEKAN_VERSION}-${WEKAN_ARCHITECTURE}.zip && \
\
# Download node and shasums
wget https://github.com/wekan/node-v14-esm/releases/download/${NODE_VERSION}/node-${NODE_VERSION}-${NODE_ARCHITECTURE}.tar.gz && \
wget https://github.com/wekan/node-v14-esm/releases/download/${NODE_VERSION}/SHASUMS256.txt && \
#wget https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-${NODE_ARCHITECTURE}.tar.gz && \
#wget https://nodejs.org/dist/${NODE_VERSION}/SHASUMS256.txt.asc && \
#wget https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-${NODE_ARCHITECTURE}.tar.gz && \
#wget https://nodejs.org/dist/${NODE_VERSION}/SHASUMS256.txt.asc && \
\
# Verify nodejs authenticity
grep node-${NODE_VERSION}-${NODE_ARCHITECTURE}.tar.gz SHASUMS256.txt | sha256sum -c - && \
\
# Extract node and remove tar.gz
tar xvzf node-${NODE_VERSION}-${NODE_ARCHITECTURE}.tar.gz
# Build wekan dockerfile
FROM --platform=linux/arm64 arm64v8/ubuntu:23.04
LABEL maintainer="wekan"
# Set the environment variables (defaults where required)
ENV QEMU_ARCHITECTURE=aarch64 \
NODE_ARCHITECTURE=linux-arm64 \
NODE_VERSION=v14.21.4 \
NODE_ENV=production \
NPM_VERSION=latest \
WITH_API=true \
PORT=8080 \
ROOT_URL=http://localhost \
MONGO_URL=mongodb://127.0.0.1:27017/wekan
# Copy qemu-static to image
COPY --from=builder qemu-${QEMU_ARCHITECTURE}-static /usr/bin
# Copy the app to the image
COPY --from=builder bundle /home/wekan/bundle
# Copy
COPY --from=builder node-${NODE_VERSION}-${NODE_ARCHITECTURE} /opt/nodejs
RUN \
set -o xtrace && \
# Add non-root user wekan
useradd --user-group --system --home-dir /home/wekan wekan && \
\
# Install Node
ln -s /opt/nodejs/bin/node /usr/bin/node && \
ln -s /opt/nodejs/bin/npm /usr/bin/npm && \
mkdir -p /opt/nodejs/lib/node_modules/fibers/.node-gyp /root/.node-gyp/8.16.1 /home/wekan/.config && \
chown wekan --recursive /home/wekan/.config
# \
# # Install Node dependencies
# #npm install -g npm@${NPM_VERSION} && \
# \
# # Install Health Check dependencies
# #apk add curl
#
#HEALTHCHECK --start-period=30s --interval=30s --timeout=10s --retries=3 \
# CMD curl --fail "http://localhost:$PORT" || exit 1
EXPOSE $PORT
USER wekan
# CMD ["bash", "-c", "ulimit -s 65500; exec node --stack-size=65500 --max-old-space-size=8192 /home/wekan/bundle/main.js"]
CMD ["bash", "-c", "ulimit -s 65500; exec node /home/wekan/bundle/main.js"]
+94
View File
@@ -0,0 +1,94 @@
FROM arm64v8/ubuntu:23.04 AS builder
#FROM --platform=linux/amd64 amd64/ubuntu:23.04 AS builder
#FROM --platform=linux/amd64 ghcr.io/wekan/wekan:main AS builder
#FROM arm64v8/ubuntu:23.04 AS builder
#FROM amd64/alpine:latest AS builder
# Set the environment variables for builder
ENV QEMU_VERSION=v7.2.0-1 \
QEMU_ARCHITECTURE=s390x \
NODE_ARCHITECTURE=linux-s390x \
NODE_VERSION=v14.21.4 \
WEKAN_VERSION=latest \
WEKAN_ARCHITECTURE=s390x
# Install dependencies
#RUN apk update && apk add ca-certificates outils-sha1 && \
RUN echo 'debconf debconf/frontend select Noninteractive' | debconf-set-selections
RUN apt update && apt install ca-certificates wget unzip -y && \
\
# Download qemu static for our architecture
wget https://github.com/multiarch/qemu-user-static/releases/download/${QEMU_VERSION}/qemu-${QEMU_ARCHITECTURE}-static.tar.gz -O - | tar -xz && \
\
# Download wekan and shasum
wget https://releases.wekan.team/${WEKAN_ARCHITECTURE}/wekan-${WEKAN_VERSION}-${WEKAN_ARCHITECTURE}.zip && \
wget https://releases.wekan.team/${WEKAN_ARCHITECTURE}/SHA256SUMS.txt && \
# Verify wekan
grep wekan-${WEKAN_VERSION}-${WEKAN_ARCHITECTURE}.zip SHA256SUMS.txt | sha256sum -c - && \
\
# Unzip wekan
unzip wekan-${WEKAN_VERSION}-${WEKAN_ARCHITECTURE}.zip && \
\
# Download node and shasums
wget https://github.com/wekan/node-v14-esm/releases/download/${NODE_VERSION}/node-${NODE_VERSION}-${NODE_ARCHITECTURE}.tar.gz && \
wget https://github.com/wekan/node-v14-esm/releases/download/${NODE_VERSION}/SHASUMS256.txt && \
#wget https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-${NODE_ARCHITECTURE}.tar.gz && \
#wget https://nodejs.org/dist/${NODE_VERSION}/SHASUMS256.txt.asc && \
#wget https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-${NODE_ARCHITECTURE}.tar.gz && \
#wget https://nodejs.org/dist/${NODE_VERSION}/SHASUMS256.txt.asc && \
\
# Verify nodejs authenticity
grep node-${NODE_VERSION}-${NODE_ARCHITECTURE}.tar.gz SHASUMS256.txt | sha256sum -c - && \
\
# Extract node and remove tar.gz
tar xvzf node-${NODE_VERSION}-${NODE_ARCHITECTURE}.tar.gz
# Build wekan dockerfile
FROM --platform=linux/s390x s390x/ubuntu:23.04
LABEL maintainer="wekan"
# Set the environment variables (defaults where required)
ENV QEMU_ARCHITECTURE=s390x \
NODE_ARCHITECTURE=linux-s390x \
NODE_VERSION=v14.21.4 \
NODE_ENV=production \
NPM_VERSION=latest \
WITH_API=true \
PORT=8080 \
ROOT_URL=http://localhost \
MONGO_URL=mongodb://127.0.0.1:27017/wekan
# Copy qemu-static to image
COPY --from=builder qemu-${QEMU_ARCHITECTURE}-static /usr/bin
# Copy the app to the image
COPY --from=builder bundle /home/wekan/bundle
# Copy
COPY --from=builder node-${NODE_VERSION}-${NODE_ARCHITECTURE} /opt/nodejs
RUN \
set -o xtrace && \
# Add non-root user wekan
useradd --user-group --system --home-dir /home/wekan wekan && \
\
# Install Node
ln -s /opt/nodejs/bin/node /usr/bin/node && \
ln -s /opt/nodejs/bin/npm /usr/bin/npm && \
mkdir -p /opt/nodejs/lib/node_modules/fibers/.node-gyp /root/.node-gyp/8.16.1 /home/wekan/.config && \
chown wekan --recursive /home/wekan/.config
# \
# # Install Node dependencies
# #npm install -g npm@${NPM_VERSION} && \
# \
# # Install Health Check dependencies
# #apk add curl
#
#HEALTHCHECK --start-period=30s --interval=30s --timeout=10s --retries=3 \
# CMD curl --fail "http://localhost:$PORT" || exit 1
EXPOSE $PORT
USER wekan
CMD ["bash", "-c", "ulimit -s 65500; exec node /home/wekan/bundle/main.js"]
+13 -71
View File
@@ -1,3 +1,5 @@
[Gitpod Ready-to-Code](https://gitpod.io/#https://github.com/wekan/wekan)
# WeKan ® - Open Source kanban
## Downloads
@@ -47,13 +49,12 @@ See bottom of https://github.com/wekan/wekan/issues/3874
## FAQ
**NOTE**:
- Please read the [FAQ](https://github.com/wekan/wekan/blob/main/docs/FAQ/FAQ.md) first
- Please don't feed the [trolls](https://github.com/wekan/wekan/blob/main/docs/FAQ/FAQ.md#why-am-i-called-a-troll) and [spammers](https://github.com/wekan/wekan/blob/main/docs/FAQ/FAQ.md#why-am-i-called-a-spammer) that are mentioned in the FAQ :)
## About WeKan ®
WeKan ® is a completely [Open Source][open_source] and [Free software][free_software]
WeKan ® is an completely [Open Source][open_source] and [Free software][free_software]
collaborative kanban board application with MIT license.
Whether you’re maintaining a personal todo list, planning your holidays with some friends,
@@ -69,23 +70,26 @@ that by providing one-click installation on various platforms.
- WeKan ® largest user has 30k users using WeKan ® in their company.
- WeKan ® has been [translated](https://app.transifex.com/wekan/) to about 105 languages.
- [Features][features]: WeKan ® has real-time user interface.
- [Platforms](https://wekan.fi/install/): WeKan ® supports many platforms.
- [Platforms][platforms]: WeKan ® supports many platforms.
WeKan ® is critical part of new platforms Wekan is currently being integrated to.
## Requirements
- 64bit: Linux [Snap](https://github.com/wekan/wekan/blob/main/docs/Platforms/FOSS/Snap/Install.md) or [Sandstorm](https://sandstorm.io) /
[Mac](https://github.com/wekan/wekan/wiki/Mac) / [Windows](https://github.com/wekan/wekan/blob/main/docs/Platforms/Propietary/Windows/Offline.md).
[More Platforms](https://github.com/wekan/wekan/tree/main/docs/Platforms), bundle for RasPi3 ARM and other CPUs where Node.js and MongoDB exists.
- 1 GB RAM minimum free for WeKan ®. Production server should have minimum total 4 GB RAM.
For thousands of users, for example with [Docker](https://github.com/wekan/wekan/blob/main/docker-compose.yml): 3 frontend servers,
each having 2 CPU and 2 wekan-app containers. One backend wekan-db server with many CPUs.
- Enough disk space and alerts about low disk space. If you run out of disk space, MongoDB database gets corrupted.
- Enough disk space and alerts about low disk space. If you run out disk space, MongoDB database gets corrupted.
- SECURITY: Updating to newest WeKan ® version very often. Please check you do not have automatic updates of Sandstorm or Snap turned off.
Old versions have security issues because of old versions Node.js etc. Only newest WeKan ® is supported.
WeKan ® on Sandstorm is not usually affected by any Standalone WeKan ® (Snap/Docker/Source) security issues.
- [Reporting all new bugs immediately](https://github.com/wekan/wekan/issues).
New features and fixes are added to WeKan ® [many times a day](https://github.com/wekan/wekan/blob/main/CHANGELOG.md).
- [Backups](https://github.com/wekan/wekan/blob/main/docs/Backup/Backup.md) of WeKan ® database once a day minimum.
- [Backups](https://github.com/wekan/wekan/blob/main/docs/Backup/Backup.md) of WeKan ® database once a day miminum.
Bugs, updates, users deleting list or card, harddrive full, harddrive crash etc can eat your data. There is no undo yet.
Some bugs can cause WeKan ® board to not load at all, requiring manual fixing of database content.
Some bug can cause WeKan ® board to not load at all, requiring manual fixing of database content.
## Roadmap and Demo
@@ -93,7 +97,7 @@ that by providing one-click installation on various platforms.
[Developer Documentation][dev_docs]
- There are many companies and individuals contributing code to WeKan ®, to add features and bugfixes
- There is many companies and individuals contributing code to WeKan ®, to add features and bugfixes
[many times a day](https://github.com/wekan/wekan/blob/main/CHANGELOG.md).
- [Please add Add new Feature Requests and Bug Reports immediately](https://github.com/wekan/wekan/issues).
- [Commercial Support](https://wekan.fi/commercial-support/).
@@ -103,75 +107,12 @@ By working directly with WeKan ® you get the benefit of active maintenance and
## Getting Started with Development
The default branch uses Meteor 3.5-beta.7 with Node.js 24.x.
See [CHANGELOG.md](https://github.com/wekan/wekan/blob/main/CHANGELOG.md) for the latest runtime updates.
The default branch uses [Meteor 2 with Node.js 14](https://wekan.fi/install/).
To contribute, [create a fork](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Build-and-Create-Pull-Request.md#2-create-fork-of-httpsgithubcomwekanwekan-at-github-web-page) and run `./rebuild-wekan.sh` (or `./rebuild-wekan.bat` on Windows) as detailed [here](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Build-and-Create-Pull-Request.md#3-select-option-1-to-install-dependencies-and-then-enter). Once you're ready, please test your code and [submit a pull request (PR)](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Build-and-Create-Pull-Request.md#7-test).
Please refer to the [developer documentation](https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Developer-Documentation.md) for more information.
## First-Time Setup for Development
### Prerequisites
Before building WeKan from source, ensure you have:
- **Git** - for cloning the repository
- **Node.js 24.x** - WeKan requires Node.js 24.x
- **Meteor** - the JavaScript framework WeKan is built with
### Installing Node.js 24.x
```bash
# Using nvm (recommended)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
source ~/.zshrc # or ~/.bashrc
nvm install 24
nvm use 24
```
### Installing Meteor
```bash
curl https://install.meteor.com/ | sh
```
### Building WeKan
The `rebuild-wekan.sh` script guides you through a three-stage process:
1. **Install dependencies (Option 1)** - Downloads all required Meteor packages and npm modules
2. **Build WeKan (Option 2)** - Compiles the application
3. **Run WeKan (Option 3)** - Starts the development server at http://localhost:4000
```bash
# Clone your fork
git clone git@github.com:YOUR_USERNAME/wekan.git
cd wekan
# Make the script executable
chmod +x rebuild-wekan.sh
# Step 1: Install dependencies
./rebuild-wekan.sh
# Press 1 and Enter
# Step 2: Build WeKan (after dependencies complete)
./rebuild-wekan.sh
# Press 2 and Enter
# Step 3: Run WeKan in development mode
./rebuild-wekan.sh
# Press 3 and Enter
```
### WSL Users
WSL users can use Snap Candidate. See [install docs](https://wekan.fi/install/).
The development server will start at http://localhost:4000. Any changes you make to the source code will automatically trigger a rebuild and refresh your browser.
## Screenshot
[More screenshots at Features page](https://github.com/wekan/wekan/tree/main/docs/Features)
@@ -183,6 +124,7 @@ The development server will start at http://localhost:4000. Any changes you make
WeKan ® is released under the very permissive [MIT license](LICENSE), and made
with [Meteor](https://www.meteor.com).
[platforms]: https://github.com/wekan/wekan/tree/main/docs/Platforms
[dev_docs]: https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Developer-Documentation.md
[screenshot_wekan]: https://wekan.fi/wekan-dark-mode.png
[features]: https://github.com/wekan/wekan/wiki/Features
+86 -150
View File
@@ -1,34 +1,13 @@
## About Money: No bounty
About money, see [CONTRIBUTING.md](CONTRIBUTING.md)
There is no bounty, like described at [CONTRIBUTING.md](CONTRIBUTING.md),
because WeKan is NOT Big Tech. WeKan is FLOSS.
Security is very important to us. If you discover any issue regarding security, please disclose
the information responsibly by sending an email to security@wekan.fi and not by
creating a GitHub issue. We will respond swiftly to fix verifiable security issues.
## Steps for Coordinated Vulnerability Disclosure
We thank you with a place at our hall of fame page, that is
at https://wekan.github.io/hall-of-fame
### 1. Security Researcher:
- **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).
### 2. WeKan Security Team:
- **Remediation**: Please wait for a new WeKan release that addresses the issue.
Fixes are announced at the top of the [ChangeLog](https://github.com/wekan/wekan/blob/main/CHANGELOG.md).
- **Recognition**: We will acknowledge your contribution by adding you to our
[Hall of Fame](https://wekan.fi/hall-of-fame/).
- **CVE Policy**: WeKan Security requests CVE at GitHub when releasing Security Advisory.
### 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?
## How should reports be formatted?
```
Name: %name
@@ -46,40 +25,35 @@ CWSS (optional): %cwss
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/
added to the Wekan Hall of Fame.
## Which domains are in scope?
No public domains, because all those are donated to WeKan Open Source project,
No public domains, because all those are donated to Wekan Open Source project,
and we don't have any permissions to do security scans on those donated servers.
Please don't perform research that could impact other users. Second, please keep
the reports short and succinct. If we fail to understand the logic of your bug, we will tell you.
Please don't perform research that could impact other users. Secondly, please keep
the reports short and succinct. If we fail to understand the logics of your bug, we will tell you.
You can [Install WeKan](https://wekan.fi/install/) on your own computer
You can [Install Wekan](https://github.com/wekan/wekan/releases) to your own computer
and scan it's vulnerabilities there.
## About WeKan Versions
## About Wekan versions
There are 2 versions of WeKan: Standalone WeKan, and Sandstorm WeKan.
There are only 2 versions of Wekan: Standalone Wekan, and Sandstorm Wekan.
## Meteor Security
### Standalone Wekan Security
https://guide.meteor.com/security
### Standalone WeKan Security
Standalone WeKan includes all non-Sandstorm platforms. Some Standalone WeKan platforms
Standalone Wekan includes all non-Sandstorm platforms. Some Standalone Wekan platforms
like Snap and Docker have their own specific sandboxing etc features.
Standalone WeKan by default does not load any files from Internet, like fonts, CSS, etc.
This also means all Standalone WeKan functionality works in offline local networks.
WeKan is used in most countries of the world https://snapcraft.io/wekan
and by companies that have 30k users.
Standalone Wekan by default does not load any files from Internet, like fonts, CSS, etc.
This also means all Standalone Wekan functionality works in offline local networks.
WeKan is used at most countries of the world https://snapcraft.io/wekan
and by by companies that have 30k users.
- WeKan private board attachments are not accessible without logging in.
- There is feature to set board public, so that board is visible without logging
in readonly mode, with realtime updates.
- Wekan private board attachments are not accessible without logging in.
- There is feature to set board public, so that board is visible without logging in in readonly mode, with realtime updates.
- Admin Panel has feature to disable all public boards, so all boards are private.
## SSL/TLS
@@ -88,28 +62,32 @@ and by companies that have 30k users.
- If you are thinking about TLS MITM, look at https://github.com/caddyserver/caddy/issues/2530
- Let's Encrypt TLS requires publicly accessible webserver, that Let's Encrypt TLS validation servers check.
- If firewall limits to only allowed IP addresses, you may need non-Let's Encrypt TLS cert.
- For On Premise:
- https://caddyserver.com/docs/automatic-https#local-https
- https://github.com/wekan/wekan/wiki/Caddy-Webserver-Config
- https://github.com/wekan/wekan/wiki/Azure
- https://github.com/wekan/wekan/wiki/Traefik-and-self-signed-SSL-certs
## XSS
- DOMPurify https://www.npmjs.com/package/dompurify
- WeKan uses DOMPurify npm package to filter for XSS at fields like cards, as you can see from
- Dompurify https://www.npmjs.com/package/dompurify
- WeKan uses dompurify npm package to filter for XSS at fields like cards, as you can see from
[package.json](https://github.com/wekan/wekan/blob/main/package.json). Other used versions can be seen from
[Meteor versions file](https://github.com/wekan/wekan/blob/main/.meteor/versions).
- Forms can include markdown links, html, image tags etc like you see at https://wekan.fi .
- Forms can include markdown links, html, image tags etc like you see at https://wekan.github.io .
- It's possible to add attachments to cards, and markdown/html links to files.
- DOMPurify cleans up viewed code, so JavaScript in input fields does not execute
- https://wekan.fi/hall-of-fame/fieldbleed/
- Dompurify cleans up viewed code, so Javascript in input fields does not execute
- https://wekan.github.io/hall-of-fame/fieldbleed/
- Reaction in comment is now checked, that it does not have extra added code
- https://wekan.fi/hall-of-fame/reactionbleed/
- https://wekan.github.io/hall-of-fame/reactionbleed/
- https://github.com/wekan/wekan/blob/main/packages/markdown/src/template-integration.js#L76
## QA About PubSub
## QA about PubSub
Q:
Hello,
I have just seen the Meteor DevTools Evolved extension and was wondering if anyone
had asked themselves the question of security.
I have just seen the Meteor DevTools Evolved extension and was wondering if anyone had asked themselves the question of security.
Insofar as all data is shown in the minimongo tab in plain text.
How can data be hidden from this extension?
@@ -117,36 +95,32 @@ A:
## PubSub
- It is not a security issue to show text or images that the user has permission to see.
It is a security issue, if browserside is some text or image that user should not see.
- Meteor has browserside minimongo database, made with JavaScript, updated with Publish/Subscribe, PubSub.
- Publish/Subscribe means, that realtime web framework reads database changes stream,
and then immediately updates webpage, like dashboards, chat, kanban.
That is the point of any realtime web framework in any programming language.
- Yes, you should check with Meteor DevTools Evolved Chromium/Firefox extension that
at minimongo is only text that user has permission to see.
- Check as a logged-in user and as a logged-out user.
- It is not security issue to show some text or image, that user has permission to see. It is a security issue, if browserside is some text or image that user should not see.
- Meteor has browserside minimongo database, made with Javascript, updated with Publish/Subscribe, PubSub.
- Publish/Subscribe means, that realtime web framework reads database changes stream, and then immediately updates webpage,
like like dashboards, chat, kanban. That is the point in any realtime web framework in any programming language.
- Yes, you should check with Meteor DevTools Evolved Chromium/Firefox extension that at minimongo is only text that user has permission to see.
- Do checking as logged in user, and logged out user.
- Check permissions and sanitize before allowing some change, because someone could modify content of input field,
PubSub/websocket data (for example with Burp Suite Community Edition), etc.
- If you have a REST API, also check that only those who have a login token and permission can view or edit text.
- If you have REST API, also check that only those that have login token, and have permission, can view or edit text
- You should not include any data user is not allowed to see. Not to webpage text, not to websockets/PubSub, etc.
- Minimongo should not have password hashes PubSub https://wekan.fi/hall-of-fame/userbleed/
- PubSub uses WebSockets, so you need those to be enabled on the webserver like Caddy/Nginx/Apache etc, examples of settings
- Minimongo should not have password hashes PubSub https://wekan.github.io/hall-of-fame/userbleed/
- PubSub uses Websockets, so you need those to be enabled at webserver like Caddy/Nginx/Apache etc, examples of settings
at right menu of https://github.com/wekan/wekan/wiki
- Clientside https://github.com/wekan/wekan/tree/main/client/components subscribes to
PubSub https://github.com/wekan/wekan/tree/main/server/publications or calls meteor methods
at https://github.com/wekan/wekan/tree/main/models
- Clientside https://github.com/wekan/wekan/tree/main/client/components subscribes to
PubSub https://github.com/wekan/wekan/tree/main/server/publications or calls meteor methods at https://github.com/wekan/wekan/tree/main/models
- For Admin:
- You can have input field for password https://github.com/wekan/wekan/blob/main/client/components/cards/attachments.js#L303-L312
- You can save password to database https://github.com/wekan/wekan/blob/main/client/components/cards/attachments.js#L303-L312
- Check that only current user or Admin can change password https://github.com/wekan/wekan/blob/main/client/components/cards/attachments.js#L303-L312
- Note that currentUser uses code like Meteor.user() in .js file
- Do not include password hashes in PubSub https://github.com/wekan/wekan/blob/main/server/publications/users.js
- Do not have password hashes in PubSub https://github.com/wekan/wekan/blob/main/server/publications/users.js
- Only show Admin Panel to Admin https://github.com/wekan/wekan/blob/main/client/components/settings/settingBody.jade#L3
- If there is a large amount of data, use pagination https://github.com/wekan/wekan/blob/main/client/components/settings/peopleBody.js
- Only publish a limited amount of data in PubSub. Limit in MongoDB query in publications how much is published. Too much could make browser too slow.
- Environment variables for email and other passwords.
- But what if you would like to remove Minimongo and only use Meteor methods for saving? In that case, you don't have realtime updates,
- If there is a lot of data, use pagination https://github.com/wekan/wekan/blob/main/client/components/settings/peopleBody.js
- Only have limited amount of data published in PubSub. Limit in MongoDB query in publications how much is published. Too much could make browser too slow.
- Use Environment variables for any email etc passwords.
- But what if you would like to remove minimongo? And only use Meteor methods for saving? In that case, you don't have realtime updates,
and you need to write much more code to load and save data yourself, handle any multi user data saving conflicts yourself,
and many Meteor Atmospherejs.com PubSub using packages would not work anymore https://github.com/wekan/we
@@ -154,14 +128,14 @@ A:
- With PubSub, there is checking, that someone modifying Websockets content, like permission isAdmin, can not change to Admin.
- https://github.com/wekan/wekan/commit/cbad4cf5943d47b916f64b4582f8ca76a9dfd743
- https://wekan.fi/hall-of-fame/adminbleed/
- https://wekan.github.io/hall-of-fame/adminbleed/
## Permissions and Roles
- For any user permissions, it's best to use Meteor package https://github.com/Meteor-Community-Packages/meteor-roles .
- Currently, WeKan has custom hardcoded permissions and does not yet use the meteor-roles package.
- For any user permissions, it's best to use Meteor package package https://github.com/Meteor-Community-Packages/meteor-roles .
- Currently WeKan has custom hardcoded permissions, WeKan does not yet use that meteor-roles package.
- Using permissions at WeKan sidebar https://github.com/wekan/wekan/blob/main/client/components/sidebar/sidebar.js#L1854-L1875
- List of roles: https://github.com/wekan/wekan/wiki/REST-API-Role . Change at board or Admin Panel. Also Organizations/Teams.
- List of roles https://github.com/wekan/wekan/wiki/REST-API-Role . Change at board or Admin Panel. Also Organizations/Teams.
- Worker role: https://github.com/wekan/wekan/issues/2788
- Not implemented yet: Granular Roles https://github.com/wekan/wekan/issues/3022
- Check is user logged in, with `if (Meteor.user()) {`
@@ -170,8 +144,8 @@ A:
## Environment variables
- For any passwords, use environment variables; those are serverside
- Do not copy environment variables to public variables that are visible on the browserside https://github.com/wekan/wekan/blob/main/server/max-size.js
- For any passwords, use environment variables, those are serverside
- Do not copy environment variable to public variable that is visible browserside https://github.com/wekan/wekan/blob/main/server/max-size.js
```
Meteor.startup(() => {
@@ -182,91 +156,42 @@ Meteor.startup(() => {
```
- For serverside, you can set Meteor.settings.variablename, without text public
- For WeKan kanban, there is a feature for setting a board to public; it can be viewed by anyone, with realtime updates. But
- For WeKan kanban, there is feature for setting board public, it can be viewed by anyone, there is realtime updates. But
- Some of those permissions are checked at users.js models at https://github.com/wekan/wekan/tree/main/models
- Environment variables are used for email server passwords, etc, at all platforms https://github.com/wekan/wekan/commit/a781c0e7dcfdbe34c1483ee83cec12455b7026f7
## Escape HTML comment tags so that HTML comments are visible
- Someone reported that it was a problem that the content of HTML comments in edit mode was not visible in view mode, so this change makes HTML comments visible.
- Someone reported, that it is problem that content of HTML comments in edit mode, are not visible at at view mode, so this makes HTML comments visible.
- https://github.com/wekan/wekan/commit/167863d95711249e69bb3511175d73b34acbbdb3
- https://wekan.fi/hall-of-fame/invisiblebleed/
- https://wekan.github.io/hall-of-fame/invisiblebleed/
## Attachments: XSS in filename is sanitized
- https://github.com/wekan/wekan/blob/main/client/components/cards/attachments.js#L303-L312
- https://wekan.fi/hall-of-fame/filebleed/
### Attachments: Forced download to prevent stored XSS
- To prevent browser-side execution of uploaded content under the app origin, all attachment downloads are served with safe headers:
- `Content-Type: application/octet-stream`
- `Content-Disposition: attachment`
- `X-Content-Type-Options: nosniff`
- A restrictive `Content-Security-Policy` with `sandbox`
- This means attachments are downloaded instead of rendered inline by default. This mitigates HTML/JS/SVG based stored XSS vectors.
- Avatars and inline images remain supported but SVG uploads are blocked and never rendered inline.
## Users: Client update restrictions
- Client-side updates to user documents are limited to safe fields only:
- `username`
- `profile.*`
- Sensitive fields are blocked from any client updates and can only be modified by server methods with authorization:
- `orgs`, `teams`, `roles`, `isAdmin`, `createdThroughApi`, `loginDisabled`, `authenticationMethod`, `services.*`, `emails.*`, `sessionData.*`
- Attempts to update forbidden fields from the client are denied.
- Admin operations like managing org/team membership or toggling flags must use server methods that check permissions.
## Voting: integrity and authorization
- Client updates to card `vote` fields are blocked to prevent forged votes and inconsistent policy enforcement.
- Voting is performed via a server method that enforces:
- Authentication and board membership, or an explicit per-card flag allowing non-members to vote.
- Only the caller's own userId is added/removed from `vote.positive`/`vote.negative`.
- This prevents members from fabricating other users' votes and ensures non-members cannot vote unless explicitly allowed.
## Planning Poker: integrity and authorization
- Client updates to card `poker` fields are blocked. All poker actions go through server methods that enforce:
- Authentication and board membership for configuration and results.
- For casting a poker vote, either board membership or an explicit per-card flag allowing non-members to participate.
- Only the caller's own userId is added/removed from the selected estimation bucket (e.g., one, two, five, etc.).
- Methods cover setting/unsetting poker question/end, casting votes, replaying, and setting final estimation.
## Attachment API: authentication and DoS prevention
- The attachment API (`/api/attachment/*`) requires proper authentication using `X-User-Id` and `X-Auth-Token` headers.
- Authentication validates tokens by hashing with `Accounts._hashLoginToken` and matching against stored login tokens, preventing identity spoofing.
- Request handlers implement:
- 30-second timeout to prevent hanging connections.
- Request body size limits (50MB for uploads, 10MB for metadata operations).
- Proper error handling and guaranteed response completion.
- Request error event handlers to clean up failed connections.
- This prevents:
- DoS attacks via concurrent unauthenticated or malformed requests.
- Identity spoofing by using arbitrary bearer tokens or user IDs.
- Resource exhaustion from hanging connections or excessive payloads.
- Access control: all attachment operations verify board membership before allowing access.
- https://wekan.github.io/hall-of-fame/filebleed/
## Brute force login protection
- https://github.com/wekan/wekan/commit/23e5e1e3bd081699ce39ce5887db7e612616014d
- https://github.com/wekan/wekan/tree/main/packages/wekan-accounts-lockout
### Sandstorm WeKan Security
### Sandstorm Wekan Security
On the Sandstorm platform, Standalone WeKan features like the Admin Panel are turned off using environment variables, because Sandstorm platform provides SSO for all apps running on Sandstorm.
On Sandstorm platform using environment variable Standalone Wekan features like Admin Panel etc are
turned off, because Sandstorm platform provides SSO for all apps running on Sandstorm.
[Sandstorm](https://sandstorm.io) is a separate Open Source platform that has been
[Sandstorm](https://sandstorm.io) is separate Open Source platform that has been
[security audited](https://sandstorm.io/news/2017-03-02-security-review) and found bugs fixed.
Sandstorm also has passwordless login, LDAP, SAML, Google, and other auth options already.
On Sandstorm, code is read-only and signed by app maintainers; only grain content can be modified.
WeKan on Sandstorm runs in a sandboxed grain; it does not have access elsewhere without a user-visible PowerBox request or opening a randomly-generated API key URL.
Sandstorm also has passwordless login, LDAP, SAML, Google etc auth options already.
At Sandstorm code is read-only and signed by app maintainers, only grain content can be modified.
Wekan at Sandstorm runs in sandboxed grain, it does not have access elsewhere without user-visible
PowerBox request or opening randomly-generated API key URL.
Also read [Sandstorm Security Practices](https://docs.sandstorm.io/en/latest/using/security-practices/) and
[Sandstorm Security non-events](https://docs.sandstorm.io/en/latest/using/security-non-events/).
For Sandstorm specific security issues you can contact [kentonv](https://github.com/kentonv) by email.
For Sandstorm specific security issues you can contact [kentonv](https://github.com/kentonv) by email.
## What WeKan bugs are eligible?
## What Wekan bugs are eligible?
Any typical web security bugs. If any of the previously mentioned is somehow problematic and
a security issue, we'd like to know about it, and also how to fix it:
@@ -278,12 +203,23 @@ a security issue, we'd like to know about it, and also how to fix it:
- Authentication bypass
- Server-side code execution
## What WeKan bugs are NOT eligible?
## What Wekan bugs are NOT eligible?
Typical already-known or 'no impact' bugs such as:
Typical already known or "no impact" bugs such as:
- [Wekan API old tokens not replaced correctly](https://github.com/wekan/wekan/issues/1437)
- Missing Cookie flags on non-session cookies or 3rd party cookies
- Logout CSRF
- Social engineering
- Denial of service
- SSL BEAST/CRIME/etc. WeKan does not have SSL built-in; it uses Caddy/Nginx/Apache on the front end.
- SSL BEAST/CRIME/etc. Wekan does not have SSL built-in, it uses Caddy/Nginx/Apache etc at front.
Integrated Caddy support is updated often.
- Email spoofing, SPF, DMARC & DKIM. Wekan does not include email server.
WeKan is Open Source with MIT license, and free to use also for commercial use.
Wekan is Open Source with MIT license, and free to use also for commercial use.
We welcome all fixes to improve security by email to security@wekan.team
## Bonus Points
If your Responsible Security Disclosure includes code for fixing security issue,
you get bonus points, as seen on [Hall of Fame](https://wekan.github.io/hall-of-fame).
+1 -1
View File
@@ -1,5 +1,5 @@
appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
appVersion: "v9.42.0"
appVersion: "v7.94.0"
files:
userUploads:
- README.md
+22 -760
View File
@@ -25,99 +25,39 @@ syntax = """=== Wekan API Python CLI: Shows IDs for addcard ===
# AUTHORID is USERID that writes card or custom field.
If *nix: chmod +x api.py => ./api.py users
Syntax:
User API:
User API:
python3 api.py user # Current user and list of current user boards
python3 api.py users # All users
python3 api.py boards # All Public Boards
python3 api.py boards USERID # Boards of USERID
python3 api.py board BOARDID # Info of BOARDID
Board/List/Card API:
python3 api.py swimlanes BOARDID # Swimlanes of BOARDID
python3 api.py lists BOARDID # Lists of BOARDID
python3 api.py list BOARDID LISTID # Info of LISTID
python3 api.py createlist BOARDID LISTTITLE # Create list
python3 api.py addcard AUTHORID BOARDID SWIMLANEID LISTID CARDTITLE CARDDESCRIPTION
python3 api.py editcard BOARDID LISTID CARDID NEWCARDTITLE NEWCARDDESCRIPTION
python3 api.py getcard BOARDID LISTID CARDID # Get card info
python3 api.py cardsbyswimlane BOARDID SWIMLANEID # Retrieve cards list on a swimlane
python3 api.py deleteallcards BOARDID SWIMLANEID ( * Be careful will delete ALL CARDS INSIDE the swimlanes automatically in every list * ) # Delete all cards on a swimlane
python3 api.py get_list_cards_count BOARDID LISTID # Retrieve how many cards in a list
python3 api.py get_board_cards_count BOARDID # Retrieve how many cards in a board
python3 api.py editboardtitle BOARDID NEWBOARDTITLE # Edit board title
python3 api.py copyboard BOARDID NEWBOARDTITLE # Copy a board
Custom Fields / Labels API:
python3 api.py customfields BOARDID # Custom Fields of BOARDID
python3 api.py customfield BOARDID CUSTOMFIELDID # Info of CUSTOMFIELDID
python3 api.py addcustomfieldtoboard AUTHORID BOARDID NAME TYPE SETTINGS SHOWONCARD AUTOMATICALLYONCARD SHOWLABELONMINICARD SHOWSUMATTOPOFLIST # Add Custom Field to Board
python3 api.py editcustomfield BOARDID LISTID CARDID CUSTOMFIELDID NEWCUSTOMFIELDVALUE # Edit Custom Field
python3 api.py createlabel BOARDID LABELCOLOR LABELNAME (Color available: `white`, `green`, `yellow`, `orange`, `red`, `purple`, `blue`, `sky`, `lime`, `pink`, `black`, `silver`, `peachpuff`, `crimson`, `plum`, `darkgreen`, `slateblue`, `magenta`, `gold`, `navy`, `gray`, `saddlebrown`, `paleturquoise`, `mistyrose`, `indigo`) # Create a new label
python3 api.py addlabel BOARDID LISTID CARDID LABELID # Add label to a card
python3 api.py addcardwithlabel AUTHORID BOARDID SWIMLANEID LISTID CARDTITLE CARDDESCRIPTION LABELIDS # Add a card and a label
python3 api.py editcardcolor BOARDID LISTID CARDID COLOR (Color available: `white`, `green`, `yellow`, `orange`, `red`, `purple`, `blue`, `sky`, `lime`, `pink`, `black`, `silver`, `peachpuff`, `crimson`, `plum`, `darkgreen`, `slateblue`, `magenta`, `gold`, `navy`, `gray`, `saddlebrown`, `paleturquoise`, `mistyrose`, `indigo`) # Edit card color
Checklist API:
python3 api.py addchecklist BOARDID CARDID TITLE ITEM1 ITEM2 ITEM3 ITEM4 (You can add multiple items or just one, or also without any item, just TITLE works as well. * If items or Title contains spaces, you should add ' between them.) # Add checklist + item on a card
python3 api.py checklistid BOARDID CARDID # Retrieve Checklist ID attached to a card
python3 api.py checklistinfo BOARDID CARDID CHECKLISTID # Get checklist info
Attachment API:
python3 api.py listattachments BOARDID # List attachments
python3 api.py uploadattachment BOARDID SWIMLANEID LISTID CARDID FILEPATH [STORAGE_BACKEND] # Upload attachment to card
python3 api.py downloadattachment ATTACHMENTID OUTPUTPATH # Download attachment to local file
python3 api.py attachmentinfo ATTACHMENTID # Get attachment information
python3 api.py listcardattachments BOARDID SWIMLANEID LISTID CARDID # List attachments for specific card
python3 api.py copymoveattachment ATTACHMENTID TARGETBOARDID TARGETSWIMLANEID TARGETLISTID TARGETCARDID [copy|move] # Copy or move attachment
python3 api.py deleteattachment ATTACHMENTID # Delete attachment
Board Member API (Issue #5998):
python3 api.py addboardmember BOARDID USERID ROLE # Add/activate board member. ROLE: admin, normal, comment, readonly, worker, normalassignedonly, commentassignedonly, readassignedonly, nocomments
python3 api.py removeboardmember BOARDID USERID # Remove (deactivate) board member
python3 api.py setboardmemberrole BOARDID USERID ROLE # Change permission/role of existing board member
Card Member / Assignee API (board member -> card member/assignee):
python3 api.py setcardmembers BOARDID LISTID CARDID MEMBERIDS # Set card members. MEMBERIDS = comma-separated userIds, or '' to clear
python3 api.py setcardassignees BOARDID LISTID CARDID ASSIGNEEIDS # Set card assignees. ASSIGNEEIDS = comma-separated userIds, or '' to clear
Card Dates API (Issue #5846):
python3 api.py setcarddate BOARDID LISTID CARDID DATETYPE [DATEVALUE] # DATETYPE: received, start, due, end. DATEVALUE: ISO 8601, e.g. 2026-06-07T12:00:00.000Z. Omit DATEVALUE (or pass '') to clear the date.
Card Labels API (Issue #5819):
python3 api.py setcardlabels BOARDID LISTID CARDID LABELIDS # Set (replace) card labels. LABELIDS = comma-separated labelIds, or '' to clear
Card Copy/Move API:
python3 api.py movecard BOARDID LISTID CARDID NEWBOARDID NEWSWIMLANEID NEWLISTID # Move card to another board/swimlane/list
Bulk Card API (Issue #4743, #5819):
python3 api.py bulkaddcards BOARDID LISTID AUTHORID SWIMLANEID CARDSJSONFILE # Create many cards in one request. CARDSJSONFILE = JSON file with a list of titles, or a list of card objects {title,description,members,assignees,...}
python3 api.py bulkdeletecards BOARDID CARDIDS # Delete many cards in one request. CARDIDS = comma-separated cardIds
python3 api.py bulkcardlabels BOARDID CARDIDS ADDLABELIDS REMOVELABELIDS # Merge-add/remove labels across many cards. CARDIDS/ADDLABELIDS/REMOVELABELIDS = comma-separated, or '' for none
Linked Card API (Issue #5897):
python3 api.py linkcard BOARDID LISTID SWIMLANEID AUTHORID LINKEDCARDID # Create a linked card in BOARDID/LISTID that references LINKEDCARDID (may be on another board)
Card Member/Assignee Merge API (Issue #5998):
python3 api.py addcardmember BOARDID LISTID CARDID MEMBERID # Add one board member to a card (validated)
python3 api.py removecardmember BOARDID LISTID CARDID MEMBERID # Remove one member from a card
python3 api.py addcardassignee BOARDID LISTID CARDID ASSIGNEEID # Add one assignee to a card (validated)
python3 api.py removecardassignee BOARDID LISTID CARDID ASSIGNEEID # Remove one assignee from a card
Copy/Move Swimlane/List/Card API (position = 0-based index from top-left):
python3 api.py copycard BOARDID LISTID CARDID TOSWIMLANEID [TOBOARDID] [TOLISTID] [POSITION] # Copy a card (deep copy)
python3 api.py copyswimlane BOARDID SWIMLANEID [TOBOARDID] [POSITION] # Copy a swimlane (deep copy)
python3 api.py moveswimlane BOARDID SWIMLANEID [TOBOARDID] [POSITION] # Move a swimlane
python3 api.py copylist BOARDID LISTID TOSWIMLANEID [TOBOARDID] [POSITION] # Copy a list (deep copy)
python3 api.py movelist BOARDID LISTID [TOSWIMLANEID] [TOBOARDID] [POSITION] # Move a list
My Cards / Due Cards API (Issue #4815):
python3 api.py mycards [due] [FROM] [TO] # Current user's cards. 'due' = only cards with a due date; FROM/TO = ISO 8601 due-date range
Card Settings API (Issue #3062):
python3 api.py getcardsettings BOARDID # Get board-level card settings (allows* toggles)
python3 api.py setcardsetting BOARDID KEY VALUE # Set one board card setting, e.g. allowsDueDate true
python3 api.py cardsbyswimlane SWIMLANEID LISTID # Retrieve cards list on a swimlane
python3 api.py getcard BOARDID LISTID CARDID # Get card info
python3 api.py addlabel BOARDID LISTID CARDID LABELID # Add label to a card
python3 api.py addcardwithlabel AUTHORID BOARDID SWIMLANEID LISTID CARDTITLE CARDDESCRIPTION LABELIDS # Add a card and a label
python3 api.py editboardtitle BOARDID NEWBOARDTITLE # Edit board title
python3 api.py copyboard BOARDID NEWBOARDTITLE # Copy a board
python3 api.py createlabel BOARDID LABELCOLOR LABELNAME (Color available: `white`, `green`, `yellow`, `orange`, `red`, `purple`, `blue`, `sky`, `lime`, `pink`, `black`, `silver`, `peachpuff`, `crimson`, `plum`, `darkgreen`, `slateblue`, `magenta`, `gold`, `navy`, `gray`, `saddlebrown`, `paleturquoise`, `mistyrose`, `indigo`) # Create a new label
python3 api.py editcardcolor BOARDID LISTID CARDID COLOR (Color available: `white`, `green`, `yellow`, `orange`, `red`, `purple`, `blue`, `sky`, `lime`, `pink`, `black`, `silver`, `peachpuff`, `crimson`, `plum`, `darkgreen`, `slateblue`, `magenta`, `gold`, `navy`, `gray`, `saddlebrown`, `paleturquoise`, `mistyrose`, `indigo`) # Edit card color
python3 api.py addchecklist BOARDID CARDID TITLE ITEM1 ITEM2 ITEM3 ITEM4 (You can add multiple items or just one, or also without any item, just TITLE works as well. * If items or Title contains spaces, you should add ' between them.) # Add checklist + item on a card
python3 api.py deleteallcards BOARDID SWIMLANEID ( * Be careful will delete ALL CARDS INSIDE the swimlanes automatically in every list * ) # Delete all cards on a swimlane
python3 api.py checklistid BOARDID CARDID # Retrieve Checklist ID attached to a card
python3 api.py checklistinfo BOARDID CARDID CHECKLISTID # Get checklist info
python3 api.py get_list_cards_count BOARDID LISTID # Retrieve how many cards in a list
python3 api.py get_board_cards_count BOARDID # Retrieve how many cards in a board
Admin API:
python3 api.py users # All users
python3 api.py boards # All Public Boards
python3 api.py newuser USERNAME EMAIL PASSWORD
"""
@@ -777,9 +717,9 @@ if arguments == 2:
print(f"Error: {e}")
# ------- GET BOARD CARDS COUNT END -----------
if arguments >= 1:
if arguments == 1:
if arguments == 1 and sys.argv[1] == 'users':
if sys.argv[1] == 'users':
# ------- LIST OF USERS START -----------
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
@@ -790,7 +730,7 @@ if arguments >= 1:
print(data2)
# ------- LIST OF USERS END -----------
if arguments == 1 and sys.argv[1] == 'user':
if sys.argv[1] == 'user':
# ------- LIST OF ALL USERS START -----------
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
print(user)
@@ -800,7 +740,7 @@ if arguments >= 1:
print(data2)
# ------- LIST OF ALL USERS END -----------
if arguments == 1 and sys.argv[1] == 'boards':
if sys.argv[1] == 'boards':
# ------- LIST OF PUBLIC BOARDS START -----------
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
@@ -810,681 +750,3 @@ if arguments >= 1:
data2 = body.text.replace('}',"}\n")
print(data2)
# ------- LIST OF PUBLIC BOARDS END -----------
# ------- NEW ATTACHMENT API ENDPOINTS START -----------
if sys.argv[1] == 'uploadattachment':
# ------- UPLOAD ATTACHMENT START -----------
if arguments < 6:
print("Usage: python3 api.py uploadattachment BOARDID SWIMLANEID LISTID CARDID FILEPATH [STORAGE_BACKEND]")
print("Storage backends: fs, gridfs, s3")
exit(1)
boardid = sys.argv[2]
swimlaneid = sys.argv[3]
listid = sys.argv[4]
cardid = sys.argv[5]
filepath = sys.argv[6]
storage_backend = sys.argv[7] if arguments > 6 else None
# Read file and convert to base64
try:
with open(filepath, 'rb') as f:
file_data = f.read()
import base64
base64_data = base64.b64encode(file_data).decode('utf-8')
except FileNotFoundError:
print(f"Error: File '{filepath}' not found")
exit(1)
except Exception as e:
print(f"Error reading file: {e}")
exit(1)
# Get file info
import os
filename = os.path.basename(filepath)
import mimetypes
file_type = mimetypes.guess_type(filepath)[0] or 'application/octet-stream'
# Prepare request data
upload_data = {
'boardId': boardid,
'swimlaneId': swimlaneid,
'listId': listid,
'cardId': cardid,
'fileData': base64_data,
'fileName': filename,
'fileType': file_type
}
if storage_backend:
upload_data['storageBackend'] = storage_backend
# Make API call
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
upload_url = wekanurl + 'api/attachment/upload'
try:
response = requests.post(upload_url, headers=headers, json=upload_data)
response.raise_for_status()
result = response.json()
print(f"Upload successful!")
print(f"Attachment ID: {result.get('attachmentId')}")
print(f"File: {result.get('fileName')}")
print(f"Size: {result.get('fileSize')} bytes")
print(f"Storage: {result.get('storageBackend')}")
except requests.exceptions.RequestException as e:
print(f"Upload failed: {e}")
if hasattr(e, 'response') and e.response is not None:
print(f"Response: {e.response.text}")
# ------- UPLOAD ATTACHMENT END -----------
if sys.argv[1] == 'downloadattachment':
# ------- DOWNLOAD ATTACHMENT START -----------
if arguments < 3:
print("Usage: python3 api.py downloadattachment ATTACHMENTID OUTPUTPATH")
exit(1)
attachmentid = sys.argv[2]
outputpath = sys.argv[3]
# Make API call
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
download_url = wekanurl + f'api/attachment/download/{attachmentid}'
try:
response = requests.get(download_url, headers=headers)
response.raise_for_status()
result = response.json()
if result.get('success'):
# Decode base64 data and save to file
import base64
file_data = base64.b64decode(result.get('base64Data'))
with open(outputpath, 'wb') as f:
f.write(file_data)
print(f"Download successful!")
print(f"File saved to: {outputpath}")
print(f"Original filename: {result.get('fileName')}")
print(f"Size: {result.get('fileSize')} bytes")
print(f"Storage: {result.get('storageBackend')}")
else:
print(f"Download failed: {result.get('message', 'Unknown error')}")
except requests.exceptions.RequestException as e:
print(f"Download failed: {e}")
if hasattr(e, 'response') and e.response is not None:
print(f"Response: {e.response.text}")
# ------- DOWNLOAD ATTACHMENT END -----------
if sys.argv[1] == 'attachmentinfo':
# ------- ATTACHMENT INFO START -----------
if arguments < 2:
print("Usage: python3 api.py attachmentinfo ATTACHMENTID")
exit(1)
attachmentid = sys.argv[2]
# Make API call
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
info_url = wekanurl + f'api/attachment/info/{attachmentid}'
try:
response = requests.get(info_url, headers=headers)
response.raise_for_status()
result = response.json()
if result.get('success'):
print("=== ATTACHMENT INFO ===")
print(f"Attachment ID: {result.get('attachmentId')}")
print(f"File Name: {result.get('fileName')}")
print(f"File Size: {result.get('fileSize')} bytes")
print(f"File Type: {result.get('fileType')}")
print(f"Storage Backend: {result.get('storageBackend')}")
print(f"Board ID: {result.get('boardId')}")
print(f"Swimlane ID: {result.get('swimlaneId')}")
print(f"List ID: {result.get('listId')}")
print(f"Card ID: {result.get('cardId')}")
print(f"Created At: {result.get('createdAt')}")
print(f"Is Image: {result.get('isImage')}")
print(f"Versions: {len(result.get('versions', []))}")
else:
print(f"Failed to get attachment info: {result.get('message', 'Unknown error')}")
except requests.exceptions.RequestException as e:
print(f"Request failed: {e}")
if hasattr(e, 'response') and e.response is not None:
print(f"Response: {e.response.text}")
# ------- ATTACHMENT INFO END -----------
if sys.argv[1] == 'listcardattachments':
# ------- LIST CARD ATTACHMENTS START -----------
if arguments < 5:
print("Usage: python3 api.py listcardattachments BOARDID SWIMLANEID LISTID CARDID")
exit(1)
boardid = sys.argv[2]
swimlaneid = sys.argv[3]
listid = sys.argv[4]
cardid = sys.argv[5]
# Make API call
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
list_url = wekanurl + f'api/attachment/list/{boardid}/{swimlaneid}/{listid}/{cardid}'
try:
response = requests.get(list_url, headers=headers)
response.raise_for_status()
result = response.json()
if result.get('success'):
attachments = result.get('attachments', [])
print(f"=== CARD ATTACHMENTS ({len(attachments)}) ===")
for attachment in attachments:
print(f"ID: {attachment.get('attachmentId')}")
print(f"Name: {attachment.get('fileName')}")
print(f"Size: {attachment.get('fileSize')} bytes")
print(f"Type: {attachment.get('fileType')}")
print(f"Storage: {attachment.get('storageBackend')}")
print(f"Created: {attachment.get('createdAt')}")
print("---")
else:
print(f"Failed to list attachments: {result.get('message', 'Unknown error')}")
except requests.exceptions.RequestException as e:
print(f"Request failed: {e}")
if hasattr(e, 'response') and e.response is not None:
print(f"Response: {e.response.text}")
# ------- LIST CARD ATTACHMENTS END -----------
if sys.argv[1] == 'copymoveattachment':
# ------- COPY/MOVE ATTACHMENT START -----------
if arguments < 6:
print("Usage: python3 api.py copymoveattachment ATTACHMENTID TARGETBOARDID TARGETSWIMLANEID TARGETLISTID TARGETCARDID [copy|move]")
exit(1)
attachmentid = sys.argv[2]
targetboardid = sys.argv[3]
targetswimlaneid = sys.argv[4]
targetlistid = sys.argv[5]
targetcardid = sys.argv[6]
operation = sys.argv[7] if arguments > 6 else 'copy'
if operation not in ['copy', 'move']:
print("Operation must be 'copy' or 'move'")
exit(1)
# Prepare request data
request_data = {
'attachmentId': attachmentid,
'targetBoardId': targetboardid,
'targetSwimlaneId': targetswimlaneid,
'targetListId': targetlistid,
'targetCardId': targetcardid
}
# Make API call
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
api_url = wekanurl + f'api/attachment/{operation}'
try:
response = requests.post(api_url, headers=headers, json=request_data)
response.raise_for_status()
result = response.json()
if result.get('success'):
print(f"{operation.capitalize()} successful!")
if operation == 'copy':
print(f"Source Attachment ID: {result.get('sourceAttachmentId')}")
print(f"New Attachment ID: {result.get('newAttachmentId')}")
else:
print(f"Attachment ID: {result.get('attachmentId')}")
print(f"Source Board: {result.get('sourceBoardId')}")
print(f"Target Board: {result.get('targetBoardId')}")
print(f"File: {result.get('fileName')}")
print(f"Size: {result.get('fileSize')} bytes")
else:
print(f"{operation.capitalize()} failed: {result.get('message', 'Unknown error')}")
except requests.exceptions.RequestException as e:
print(f"{operation.capitalize()} failed: {e}")
if hasattr(e, 'response') and e.response is not None:
print(f"Response: {e.response.text}")
# ------- COPY/MOVE ATTACHMENT END -----------
if sys.argv[1] == 'deleteattachment':
# ------- DELETE ATTACHMENT START -----------
if arguments < 2:
print("Usage: python3 api.py deleteattachment ATTACHMENTID")
exit(1)
attachmentid = sys.argv[2]
# Make API call
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
delete_url = wekanurl + f'api/attachment/delete/{attachmentid}'
try:
response = requests.delete(delete_url, headers=headers)
response.raise_for_status()
result = response.json()
if result.get('success'):
print("Delete successful!")
print(f"Attachment ID: {result.get('attachmentId')}")
print(f"File: {result.get('fileName')}")
else:
print(f"Delete failed: {result.get('message', 'Unknown error')}")
except requests.exceptions.RequestException as e:
print(f"Delete failed: {e}")
if hasattr(e, 'response') and e.response is not None:
print(f"Response: {e.response.text}")
# ------- DELETE ATTACHMENT END -----------
# ------- NEW ATTACHMENT API ENDPOINTS END -----------
# ------- BOARD MEMBER / CARD FIELD API ENDPOINTS START -----------
# These commands wrap REST endpoints that already exist in Wekan.
# See CHANGELOG.md "Upcoming" section for related issues and design notes.
# Map a human readable ROLE to the board member permission boolean flags
# used by /api/boards/:boardId/members/:userId/add and .../members/:memberId
def board_role_flags(role):
flags = {
'isAdmin': 'false',
'isNoComments': 'false',
'isCommentOnly': 'false',
'isWorker': 'false',
'isNormalAssignedOnly': 'false',
'isCommentAssignedOnly': 'false',
'isReadOnly': 'false',
'isReadAssignedOnly': 'false',
}
role = (role or 'normal').lower()
rolemap = {
'admin': 'isAdmin',
'nocomments': 'isNoComments',
'comment': 'isCommentOnly',
'commentonly': 'isCommentOnly',
'worker': 'isWorker',
'normalassignedonly': 'isNormalAssignedOnly',
'commentassignedonly': 'isCommentAssignedOnly',
'readonly': 'isReadOnly',
'readassignedonly': 'isReadAssignedOnly',
}
if role in ('normal', 'member'):
return flags # all false == Normal board member
if role in rolemap:
flags[rolemap[role]] = 'true'
return flags
print("Invalid ROLE. Valid: admin, normal, comment, readonly, worker, "
"normalassignedonly, commentassignedonly, readassignedonly, nocomments")
sys.exit(1)
if sys.argv[1] == 'addboardmember':
# ------- ADD BOARD MEMBER START -----------
if arguments < 3:
print("Usage: python3 api.py addboardmember BOARDID USERID ROLE")
exit(1)
boardid = sys.argv[2]
userid = sys.argv[3]
role = sys.argv[4] if arguments > 3 else 'normal'
add_member_url = wekanurl + apiboards + boardid + s + 'members' + s + userid + s + 'add'
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
post_data = {'action': 'add'}
post_data.update(board_role_flags(role))
body = requests.post(add_member_url, data=post_data, headers=headers)
print("=== ADD BOARD MEMBER ===\n")
print(body.text)
# ------- ADD BOARD MEMBER END -----------
if sys.argv[1] == 'removeboardmember':
# ------- REMOVE BOARD MEMBER START -----------
if arguments < 3:
print("Usage: python3 api.py removeboardmember BOARDID USERID")
exit(1)
boardid = sys.argv[2]
userid = sys.argv[3]
remove_member_url = wekanurl + apiboards + boardid + s + 'members' + s + userid + s + 'remove'
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
post_data = {'action': 'remove'}
body = requests.post(remove_member_url, data=post_data, headers=headers)
print("=== REMOVE BOARD MEMBER ===\n")
print(body.text)
# ------- REMOVE BOARD MEMBER END -----------
if sys.argv[1] == 'setboardmemberrole':
# ------- SET BOARD MEMBER ROLE START -----------
if arguments < 4:
print("Usage: python3 api.py setboardmemberrole BOARDID USERID ROLE")
exit(1)
boardid = sys.argv[2]
userid = sys.argv[3]
role = sys.argv[4]
set_role_url = wekanurl + apiboards + boardid + s + 'members' + s + userid
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
post_data = board_role_flags(role)
body = requests.post(set_role_url, data=post_data, headers=headers)
print("=== SET BOARD MEMBER ROLE ===\n")
print(body.text)
# ------- SET BOARD MEMBER ROLE END -----------
if sys.argv[1] in ('setcardmembers', 'setcardassignees'):
# ------- SET CARD MEMBERS / ASSIGNEES START -----------
if arguments < 4:
print("Usage: python3 api.py {} BOARDID LISTID CARDID IDS".format(sys.argv[1]))
print("IDS = comma-separated userIds, or '' to clear")
exit(1)
boardid = sys.argv[2]
listid = sys.argv[3]
cardid = sys.argv[4]
ids_raw = sys.argv[5] if arguments > 4 else ''
ids = [x for x in ids_raw.split(',') if x] if ids_raw else []
field = 'members' if sys.argv[1] == 'setcardmembers' else 'assignees'
edcard = wekanurl + apiboards + boardid + s + l + s + listid + s + cs + s + cardid
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
# Empty array clears the field; send as JSON so the array type is preserved.
put_data = {field: ids if ids else []}
body = requests.put(edcard, json=put_data, headers=headers)
print("=== SET CARD {} ===\n".format(field.upper()))
print(body.text)
# ------- SET CARD MEMBERS / ASSIGNEES END -----------
if sys.argv[1] == 'setcarddate':
# ------- SET CARD DATE START -----------
if arguments < 5:
print("Usage: python3 api.py setcarddate BOARDID LISTID CARDID DATETYPE DATEVALUE")
print("DATETYPE: received, start, due, end")
print("DATEVALUE: ISO 8601, e.g. 2026-06-07T12:00:00.000Z")
exit(1)
boardid = sys.argv[2]
listid = sys.argv[3]
cardid = sys.argv[4]
datetype = sys.argv[5].lower()
datevalue = sys.argv[6] if arguments > 5 else ''
datemap = {
'received': 'receivedAt',
'start': 'startAt',
'due': 'dueAt',
'end': 'endAt',
}
if datetype not in datemap:
print("Invalid DATETYPE. Valid: received, start, due, end")
sys.exit(1)
edcard = wekanurl + apiboards + boardid + s + l + s + listid + s + cs + s + cardid
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
put_data = {datemap[datetype]: datevalue}
body = requests.put(edcard, data=put_data, headers=headers)
print("=== SET CARD DATE {} ===\n".format(datetype.upper()))
print(body.text)
# ------- SET CARD DATE END -----------
if sys.argv[1] == 'setcardlabels':
# ------- SET CARD LABELS START -----------
if arguments < 4:
print("Usage: python3 api.py setcardlabels BOARDID LISTID CARDID LABELIDS")
print("LABELIDS = comma-separated labelIds, or '' to clear")
exit(1)
boardid = sys.argv[2]
listid = sys.argv[3]
cardid = sys.argv[4]
labelids_raw = sys.argv[5] if arguments > 4 else ''
labelids = [x for x in labelids_raw.split(',') if x] if labelids_raw else []
edcard = wekanurl + apiboards + boardid + s + l + s + listid + s + cs + s + cardid
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
# NOTE: this REPLACES the card's labels. Bulk add/remove (merge) is not yet
# implemented server-side, see Issue #5819 in CHANGELOG.md "Upcoming".
put_data = {'labelIds': labelids if labelids else []}
body = requests.put(edcard, json=put_data, headers=headers)
print("=== SET CARD LABELS ===\n")
print(body.text)
# ------- SET CARD LABELS END -----------
if sys.argv[1] == 'movecard':
# ------- MOVE CARD START -----------
if arguments < 7:
print("Usage: python3 api.py movecard BOARDID LISTID CARDID NEWBOARDID NEWSWIMLANEID NEWLISTID")
exit(1)
boardid = sys.argv[2]
listid = sys.argv[3]
cardid = sys.argv[4]
newboardid = sys.argv[5]
newswimlaneid = sys.argv[6]
newlistid = sys.argv[7]
edcard = wekanurl + apiboards + boardid + s + l + s + listid + s + cs + s + cardid
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
put_data = {
'newBoardId': newboardid,
'newSwimlaneId': newswimlaneid,
'newListId': newlistid,
}
body = requests.put(edcard, data=put_data, headers=headers)
print("=== MOVE CARD ===\n")
print(body.text)
# ------- MOVE CARD END -----------
if sys.argv[1] == 'bulkaddcards':
# ------- BULK ADD CARDS START -----------
if arguments < 5:
print("Usage: python3 api.py bulkaddcards BOARDID LISTID AUTHORID SWIMLANEID CARDSJSONFILE")
print("CARDSJSONFILE = JSON file with a list of titles, e.g. [\"Card A\", \"Card B\"],")
print(" or a list of card objects [{\"title\": \"Card A\", \"description\": \"...\"}, ...]")
exit(1)
boardid = sys.argv[2]
listid = sys.argv[3]
authorid = sys.argv[4]
swimlaneid = sys.argv[5]
cardsjsonfile = sys.argv[6] if arguments > 5 else None
if not cardsjsonfile:
print("CARDSJSONFILE is required")
exit(1)
try:
with open(cardsjsonfile, 'r') as f:
raw_cards = json.load(f)
except Exception as e:
print("Error reading CARDSJSONFILE: {}".format(e))
exit(1)
if not isinstance(raw_cards, list) or len(raw_cards) == 0:
print("CARDSJSONFILE must contain a non-empty JSON list")
exit(1)
cards = []
for item in raw_cards:
if isinstance(item, str):
cards.append({'title': item})
elif isinstance(item, dict):
cards.append(item)
else:
print("Each card must be a string (title) or an object")
exit(1)
bulk_url = wekanurl + apiboards + boardid + s + l + s + listid + s + cs + s + 'bulk'
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
post_data = {'authorId': authorid, 'swimlaneId': swimlaneid, 'cards': cards}
body = requests.post(bulk_url, json=post_data, headers=headers)
print("=== BULK ADD CARDS ===\n")
print(body.text)
# ------- BULK ADD CARDS END -----------
if sys.argv[1] == 'bulkdeletecards':
# ------- BULK DELETE CARDS START -----------
if arguments < 3:
print("Usage: python3 api.py bulkdeletecards BOARDID CARDIDS")
print("CARDIDS = comma-separated cardIds")
exit(1)
boardid = sys.argv[2]
cardids_raw = sys.argv[3]
cardids = [x for x in cardids_raw.split(',') if x]
bulk_url = wekanurl + apiboards + boardid + s + cs + s + 'bulk'
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
# requests sends a body with DELETE via the json= parameter.
body = requests.delete(bulk_url, json={'cardIds': cardids}, headers=headers)
print("=== BULK DELETE CARDS ===\n")
print(body.text)
# ------- BULK DELETE CARDS END -----------
if sys.argv[1] == 'bulkcardlabels':
# ------- BULK CARD LABELS START -----------
if arguments < 5:
print("Usage: python3 api.py bulkcardlabels BOARDID CARDIDS ADDLABELIDS REMOVELABELIDS")
print("CARDIDS / ADDLABELIDS / REMOVELABELIDS = comma-separated, or '' for none")
exit(1)
boardid = sys.argv[2]
cardids = [x for x in sys.argv[3].split(',') if x]
add_labelids = [x for x in sys.argv[4].split(',') if x]
remove_labelids = [x for x in sys.argv[5].split(',') if x]
bulk_url = wekanurl + apiboards + boardid + s + cs + s + 'labels'
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
post_data = {'cardIds': cardids, 'addLabelIds': add_labelids, 'removeLabelIds': remove_labelids}
body = requests.post(bulk_url, json=post_data, headers=headers)
print("=== BULK CARD LABELS ===\n")
print(body.text)
# ------- BULK CARD LABELS END -----------
if sys.argv[1] == 'linkcard':
# ------- CREATE LINKED CARD START -----------
if arguments < 6:
print("Usage: python3 api.py linkcard BOARDID LISTID SWIMLANEID AUTHORID LINKEDCARDID")
exit(1)
boardid = sys.argv[2]
listid = sys.argv[3]
swimlaneid = sys.argv[4]
authorid = sys.argv[5]
linkedcardid = sys.argv[6]
link_url = wekanurl + apiboards + boardid + s + l + s + listid + s + cs
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
post_data = {'authorId': authorid, 'swimlaneId': swimlaneid, 'linkedId': linkedcardid}
body = requests.post(link_url, data=post_data, headers=headers)
print("=== CREATE LINKED CARD ===\n")
print(body.text)
# ------- CREATE LINKED CARD END -----------
if sys.argv[1] in ('addcardmember', 'removecardmember', 'addcardassignee', 'removecardassignee'):
# ------- ADD/REMOVE CARD MEMBER / ASSIGNEE START -----------
if arguments < 5:
print("Usage: python3 api.py {} BOARDID LISTID CARDID USERID".format(sys.argv[1]))
exit(1)
boardid = sys.argv[2]
listid = sys.argv[3]
cardid = sys.argv[4]
userid = sys.argv[5]
field = 'members' if 'member' in sys.argv[1] else 'assignees'
url = wekanurl + apiboards + boardid + s + l + s + listid + s + cs + s + cardid + s + field + s + userid
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
if sys.argv[1].startswith('add'):
body = requests.post(url, headers=headers)
else:
body = requests.delete(url, headers=headers)
print("=== {} ===\n".format(sys.argv[1].upper()))
print(body.text)
# ------- ADD/REMOVE CARD MEMBER / ASSIGNEE END -----------
if sys.argv[1] == 'copycard':
# ------- COPY CARD START -----------
if arguments < 4:
print("Usage: python3 api.py copycard BOARDID LISTID CARDID TOSWIMLANEID [TOBOARDID] [TOLISTID] [POSITION]")
exit(1)
boardid = sys.argv[2]
listid = sys.argv[3]
cardid = sys.argv[4]
toswimlaneid = sys.argv[5] if arguments > 4 else None
if not toswimlaneid:
print("TOSWIMLANEID is required")
exit(1)
toboardid = sys.argv[6] if arguments > 5 else boardid
tolistid = sys.argv[7] if arguments > 6 else listid
url = wekanurl + apiboards + boardid + s + l + s + listid + s + cs + s + cardid + s + 'copy'
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
post_data = {'toBoardId': toboardid, 'toSwimlaneId': toswimlaneid, 'toListId': tolistid}
if arguments > 7:
post_data['position'] = int(sys.argv[8])
body = requests.post(url, json=post_data, headers=headers)
print("=== COPY CARD ===\n")
print(body.text)
# ------- COPY CARD END -----------
if sys.argv[1] in ('copyswimlane', 'moveswimlane'):
# ------- COPY/MOVE SWIMLANE START -----------
if arguments < 3:
print("Usage: python3 api.py {} BOARDID SWIMLANEID [TOBOARDID] [POSITION]".format(sys.argv[1]))
exit(1)
boardid = sys.argv[2]
swimlaneid = sys.argv[3]
toboardid = sys.argv[4] if arguments > 3 else boardid
action = 'copy' if sys.argv[1] == 'copyswimlane' else 'move'
url = wekanurl + apiboards + boardid + s + sws + s + swimlaneid + s + action
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
post_data = {'toBoardId': toboardid}
if arguments > 4:
post_data['position'] = int(sys.argv[5])
body = requests.post(url, json=post_data, headers=headers)
print("=== {} ===\n".format(sys.argv[1].upper()))
print(body.text)
# ------- COPY/MOVE SWIMLANE END -----------
if sys.argv[1] in ('copylist', 'movelist'):
# ------- COPY/MOVE LIST START -----------
if arguments < 3:
print("Usage: python3 api.py {} BOARDID LISTID [TOSWIMLANEID] [TOBOARDID] [POSITION]".format(sys.argv[1]))
exit(1)
boardid = sys.argv[2]
listid = sys.argv[3]
toswimlaneid = sys.argv[4] if arguments > 3 else None
toboardid = sys.argv[5] if arguments > 4 else boardid
action = 'copy' if sys.argv[1] == 'copylist' else 'move'
url = wekanurl + apiboards + boardid + s + l + s + listid + s + action
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
post_data = {'toBoardId': toboardid}
if toswimlaneid:
post_data['toSwimlaneId'] = toswimlaneid
if arguments > 5:
post_data['position'] = int(sys.argv[6])
body = requests.post(url, json=post_data, headers=headers)
print("=== {} ===\n".format(sys.argv[1].upper()))
print(body.text)
# ------- COPY/MOVE LIST END -----------
if sys.argv[1] == 'mycards':
# ------- MY CARDS / DUE CARDS START -----------
params = {}
if arguments >= 2 and sys.argv[2].lower() == 'due':
params['due'] = 'true'
if arguments >= 3:
params['from'] = sys.argv[3]
if arguments >= 4:
params['to'] = sys.argv[4]
url = wekanurl + 'api/user/cards'
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
body = requests.get(url, headers=headers, params=params)
print("=== MY CARDS ===\n")
data2 = body.text.replace('}', "}\n")
print(data2)
# ------- MY CARDS / DUE CARDS END -----------
if sys.argv[1] == 'getcardsettings':
# ------- GET CARD SETTINGS START -----------
if arguments < 2:
print("Usage: python3 api.py getcardsettings BOARDID")
exit(1)
boardid = sys.argv[2]
url = wekanurl + apiboards + boardid + s + 'cardSettings'
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey)}
body = requests.get(url, headers=headers)
print("=== CARD SETTINGS ===\n")
data2 = body.text.replace(',', ",\n")
print(data2)
# ------- GET CARD SETTINGS END -----------
if sys.argv[1] == 'setcardsetting':
# ------- SET CARD SETTING START -----------
if arguments < 4:
print("Usage: python3 api.py setcardsetting BOARDID KEY VALUE")
print("e.g. python3 api.py setcardsetting BOARDID allowsDueDate true")
exit(1)
boardid = sys.argv[2]
key = sys.argv[3]
value = sys.argv[4]
url = wekanurl + apiboards + boardid + s + 'cardSettings'
headers = {'Accept': 'application/json', 'Authorization': 'Bearer {}'.format(apikey), 'Content-Type': 'application/json'}
body = requests.put(url, json={key: value}, headers=headers)
print("=== SET CARD SETTING ===\n")
print(body.text)
# ------- SET CARD SETTING END -----------
# ------- BOARD MEMBER / CARD FIELD API ENDPOINTS END -----------
+2 -165
View File
@@ -1,169 +1,6 @@
// Fix dynamic-import chunk loading when Wekan runs under a sub-path (ROOT_URL includes a pathname,
// e.g. https://example.com/wekan). Rspack uses __webpack_public_path__ to prefix chunk URLs; by
// default it is set to /build-chunks/ (no context path). We update it here, at module evaluation
// time, before any import() expression runs (those are deferred to Meteor.startup callbacks).
/* global __webpack_public_path__:writable */
try {
const _cfg = typeof window !== 'undefined' && window.__meteor_runtime_config__;
const _rootUrl = _cfg && _cfg.ROOT_URL;
if (_rootUrl) {
const _rootPath = new URL(_rootUrl).pathname.replace(/\/+$/, '');
if (_rootPath && _rootPath !== '/') {
__webpack_public_path__ = _rootPath + '/build-chunks/';
}
}
} catch (_) {}
// Fix bug in jam:offline 0.4.1: s?.message.includes() crashes when s.message is
// undefined (e.g. during WebSocket reconnect errors that don't carry a .message).
// Wrap _debug so the optional chain is complete: s?.message?.includes().
(function patchJamOfflineDebug() {
const patched = Meteor._debug;
Meteor._debug = function (m, s) {
try {
return patched.call(this, m, s);
} catch (e) {
// jam:offline's override threw — fall back to the bare console log.
if (typeof console !== 'undefined') {
console.log(m, s);
}
}
};
})();
// PWA — use Meteor.absoluteUrl so the path is correct under sub-URL deployments
// PWA
if ('serviceWorker' in navigator) {
window.addEventListener('load', function() {
const swPath = new URL('pwa-service-worker.js', Meteor.absoluteUrl()).pathname;
navigator.serviceWorker.register(swPath);
navigator.serviceWorker.register('/pwa-service-worker.js');
});
}
// Import Blaze helpers (replaces removed raix:handlebar-helpers package)
import '/client/config/blazeHelpers';
// Import board converter for on-demand conversion
import '/client/lib/boardConverter';
import '/client/features/boardConversion';
import { Utils } from '/client/lib/utils';
// Import migration manager and progress UI - COMMENTED OUT
// import '/client/lib/attachmentMigrationManager';
// import '/client/components/settings/migrationProgress';
// Import cron settings - COMMENTED OUT
// import '/client/components/settings/cronSettings';
// Custom head tags
// Mirror Meteor login token into a cookie for server-side file route auth
// This enables cookie-based auth for /cdn/storage/* without leaking ROOT_URL
// Token already lives in localStorage; cookie adds same-origin send-on-request semantics
Meteor.startup(() => {
const COOKIE_NAME = 'meteor_login_token';
const USER_ID_COOKIE = 'meteor_user_id';
const TOKEN_EXPIRES_COOKIE = 'meteor_login_token_expires';
const getCookie = (name) => {
try {
const parts = document.cookie ? document.cookie.split(';') : [];
for (const part of parts) {
const [k, ...rest] = part.trim().split('=');
if (decodeURIComponent(k) === name) {
return decodeURIComponent(rest.join('='));
}
}
} catch (_) {}
return '';
};
const cookieAttrs = () => {
const attrs = ['Path=/', 'SameSite=Lax'];
try {
if (window.location && window.location.protocol === 'https:') {
attrs.push('Secure');
}
} catch (_) {}
return attrs.join('; ');
};
const setCookie = (name, value) => {
if (!value) return;
document.cookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)}; ${cookieAttrs()}`;
};
const clearCookie = (name) => {
document.cookie = `${encodeURIComponent(name)}=; Expires=Thu, 01 Jan 1970 00:00:00 GMT; ${cookieAttrs()}`;
};
const syncCookie = () => {
try {
const token = Accounts && typeof Accounts._storedLoginToken === 'function' ? Accounts._storedLoginToken() : null;
if (token) setCookie(COOKIE_NAME, token); else clearCookie(COOKIE_NAME);
} catch (e) {
// ignore
}
};
const bootstrapTokenFromCookie = () => {
try {
const currentToken = Accounts && typeof Accounts._storedLoginToken === 'function'
? Accounts._storedLoginToken()
: null;
if (currentToken) {
return;
}
const token = getCookie(COOKIE_NAME);
const userId = getCookie(USER_ID_COOKIE);
const tokenExpiresRaw = getCookie(TOKEN_EXPIRES_COOKIE);
const tokenExpires = tokenExpiresRaw ? new Date(tokenExpiresRaw) : null;
if (!token || !userId || !tokenExpires || Number.isNaN(tokenExpires.getTime())) {
return;
}
if (Accounts && typeof Accounts._storeLoginToken === 'function') {
Accounts._storeLoginToken(userId, token, tokenExpires);
if (Meteor.status && !Meteor.status().connected && typeof Meteor.reconnect === 'function') {
Meteor.reconnect();
}
}
} catch (_) {
// ignore
}
};
bootstrapTokenFromCookie();
// Initial sync on startup
syncCookie();
// Keep cookie in sync on login/logout
if (Accounts && typeof Accounts.onLogin === 'function') Accounts.onLogin(syncCookie);
if (Accounts && typeof Accounts.onLogout === 'function') Accounts.onLogout(syncCookie);
// Sync across tabs/windows when localStorage changes
window.addEventListener('storage', (ev) => {
if (ev && typeof ev.key === 'string' && ev.key.indexOf('Meteor.loginToken') !== -1) {
syncCookie();
}
});
});
// Subscribe to per-user small publications
Meteor.startup(() => {
Tracker.autorun(() => {
if (Meteor.userId()) {
Meteor.subscribe('userGreyIcons');
Meteor.subscribe('userDesktopDragHandles');
}
});
// Initialize mobile mode on startup for iOS devices
// This ensures mobile mode is applied correctly on page load
Tracker.afterFlush(() => {
if (typeof Utils !== 'undefined' && Utils.initializeUserSettings) {
Utils.initializeUserSettings();
}
});
});
+14 -14
View File
@@ -1,16 +1,16 @@
.activity-title {
margin: 0 0.7vw 1vh;
margin: 0 0.5em 0.8em;
display: flex;
justify-content: space-between;
}
.reactions-popup .add-comment-reaction {
display: inline-block;
cursor: pointer;
border-radius: 0.7vw;
font-size: clamp(18px, 4vw, 22px);
border-radius: 5px;
font-size: 22px;
text-align: center;
line-height: 1.3;
width: 5vw;
line-height: 30px;
width: 40px;
}
.reactions-popup .add-comment-reaction:hover {
background-color: #b0c4de;
@@ -19,13 +19,13 @@
clear: both;
}
.activities .activity {
margin: 0.1vh 0;
padding: 0.8vh 0;
margin: 0.5px 0;
padding: 6px 0;
display: flex;
}
.activities .activity .member {
width: 4vw;
height: 4vw;
width: 32px;
height: 32px;
}
.activities .activity .activity-member {
font-weight: 700;
@@ -36,18 +36,18 @@
flex: 1;
align-self: center;
margin: 0;
margin-left: 0.4vw;
margin-left: 3px;
overflow: hidden;
word-break: break-word;
}
.activities .activity .activity-desc .activity-comment {
display: block;
border-radius: 0.4vw;
border-radius: 3px;
background: #fff;
text-decoration: none;
box-shadow: 0 0.2vh 0.3vh rgba(0,0,0,0.2);
margin-top: 0.7vh;
padding: 0.7vh 0.7vw;
box-shadow: 0 1px 2px rgba(0,0,0,0.2);
margin-top: 5px;
padding: 5px;
}
.activities .activity .activity-desc .activity-checklist {
display: block;
+5 -6
View File
@@ -9,11 +9,11 @@ template(name="activities")
+cardActivities
template(name="boardActivities")
each activityData in boardActivitiesList
each activityData in currentBoard.activities
+activity(activity=activityData card=card mode=mode)
template(name="cardActivities")
each activityData in cardActivitiesList
each activityData in activities
+activity(activity=activityData card=card mode=mode)
template(name="activity")
@@ -189,15 +189,14 @@ template(name="activity")
if(currentData.timeKey)
| {{_ activity.activityType }}
= ' '
i(title=currentData.timeValue).activity-meta {{ displayDate currentData.timeValue 'LLL' }}
i(title=currentData.timeValue).activity-meta {{ moment currentData.timeValue 'LLL' }}
if (currentData.timeOldValue)
= ' '
| {{{_ "previous_as" }}}
= ' '
i(title=currentData.timeOldValue).activity-meta {{ displayDate currentData.timeOldValue 'LLL' }}
i(title=currentData.timeOldValue).activity-meta {{ moment currentData.timeOldValue 'LLL' }}
= ' @'
else if(currentData.timeValue)
| {{_ activity.activityType currentData.timeValue}}
if($neq mode 'none')
div(title=activity.createdAt).activity-meta {{ displayDate activity.createdAt }}
div(title=activity.createdAt).activity-meta {{ moment activity.createdAt }}
+127 -152
View File
@@ -1,192 +1,167 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { ReactiveVar } from 'meteor/reactive-var';
import { Template } from 'meteor/templating';
import DOMPurify from 'dompurify';
import { sanitizeHTML, sanitizeText } from '/imports/lib/secureDOMPurify';
import { TAPi18n } from '/imports/i18n';
import { Utils } from '/client/lib/utils';
import { getSidebarInstance } from '/client/features/sidebar/service';
const activitiesPerPage = 500;
Template.activities.onCreated(function () {
// Register with sidebar so it can call loadNextPage on us
const Sidebar = getSidebarInstance();
if (Sidebar) {
Sidebar.activitiesInstance = this;
}
BlazeComponent.extendComponent({
onCreated() {
// XXX Should we use ReactiveNumber?
this.page = new ReactiveVar(1);
this.loadNextPageLocked = false;
// TODO is sidebar always available? E.g. on small screens/mobile devices
const sidebar = Sidebar;
sidebar && sidebar.callFirstWith(null, 'resetNextPeak');
this.autorun(() => {
let mode = this.data()?.mode;
if (mode) {
const capitalizedMode = Utils.capitalize(mode);
let searchId;
const showActivities = this.showActivities();
if (mode === 'linkedcard' || mode === 'linkedboard') {
const currentCard = Utils.getCurrentCard();
searchId = currentCard.linkedId;
mode = mode.replace('linked', '');
} else if (mode === 'card') {
searchId = Utils.getCurrentCardId();
} else {
searchId = Session.get(`current${capitalizedMode}`);
}
const limit = this.page.get() * activitiesPerPage;
if (searchId === null) return;
// XXX Should we use ReactiveNumber?
this.page = new ReactiveVar(1);
this.loadNextPageLocked = false;
this.loadNextPage = () => {
this.subscribe('activities', mode, searchId, limit, showActivities, () => {
this.loadNextPageLocked = false;
// TODO the guard can be removed as soon as the TODO above is resolved
if (!sidebar) return;
// If the sibear peak hasn't increased, that mean that there are no more
// activities, and we can stop calling new subscriptions.
// XXX This is hacky! We need to know excatly and reactively how many
// activities there are, we probably want to denormalize this number
// dirrectly into card and board documents.
const nextPeakBefore = sidebar.callFirstWith(null, 'getNextPeak');
sidebar.calculateNextPeak();
const nextPeakAfter = sidebar.callFirstWith(null, 'getNextPeak');
if (nextPeakBefore === nextPeakAfter) {
sidebar.callFirstWith(null, 'resetNextPeak');
}
});
}
});
},
loadNextPage() {
if (this.loadNextPageLocked === false) {
this.page.set(this.page.get() + 1);
this.loadNextPageLocked = true;
}
};
// TODO is sidebar always available? E.g. on small screens/mobile devices
const sidebar = getSidebarInstance();
if (sidebar && sidebar.infiniteScrolling) {
sidebar.infiniteScrolling.resetNextPeak();
}
this.autorun(() => {
const data = Template.currentData();
let mode = data?.mode;
},
showActivities() {
let ret = false;
let mode = this.data()?.mode;
if (mode) {
const capitalizedMode = Utils.capitalize(mode);
let searchId;
const showActivities = _showActivities(data);
if (mode === 'linkedcard' || mode === 'linkedboard') {
const currentCard = Utils.getCurrentCard();
searchId = currentCard.linkedId;
mode = mode.replace('linked', '');
ret = currentCard.showActivities ?? false;
} else if (mode === 'card') {
searchId = Utils.getCurrentCardId();
ret = this.data()?.card?.showActivities ?? false;
} else {
searchId = Session.get(`current${capitalizedMode}`);
ret = Utils.getCurrentBoard().showActivities ?? false;
}
const limit = this.page.get() * activitiesPerPage;
if (searchId === null) return;
this.subscribe('activities', mode, searchId, limit, showActivities, () => {
this.loadNextPageLocked = false;
// TODO the guard can be removed as soon as the TODO above is resolved
if (!sidebar || !sidebar.infiniteScrolling) return;
// If the sidebar peak hasn't increased, that means that there are no more
// activities, and we can stop calling new subscriptions.
const nextPeakBefore = sidebar.infiniteScrolling.getNextPeak();
sidebar.calculateNextPeak();
const nextPeakAfter = sidebar.infiniteScrolling.getNextPeak();
if (nextPeakBefore === nextPeakAfter) {
sidebar.infiniteScrolling.resetNextPeak();
}
});
}
});
});
function _showActivities(data) {
let ret = false;
let mode = data?.mode;
if (mode) {
if (mode === 'linkedcard' || mode === 'linkedboard') {
const currentCard = Utils.getCurrentCard();
ret = currentCard.showActivities ?? false;
} else if (mode === 'card') {
// For card mode, get showActivities from data context or fallback to current card
const card = data?.card || Utils.getCurrentCard();
ret = card?.showActivities ?? false;
} else {
ret = Utils.getCurrentBoard().showActivities ?? false;
}
}
return ret;
}
Template.activities.helpers({
showActivities() {
const data = Template.currentData();
return _showActivities(data);
return ret;
},
activities() {
const activities = this.card?.activities?.();
return activities || [];
const ret = this.data().card.activities();
return ret;
},
});
}).register('activities');
Template.boardActivities.helpers({
boardActivitiesList() {
const board = Utils.getCurrentBoard();
const activities = board?.activities?.();
return activities || [];
},
});
Template.cardActivities.helpers({
cardActivitiesList() {
const data = Template.currentData();
const card = data?.card;
const activities = card?.activities?.();
return activities || [];
},
});
Template.activity.helpers({
BlazeComponent.extendComponent({
checkItem() {
const checkItemId = this.activity.checklistItemId;
const checkItemId = this.currentData().activity.checklistItemId;
const checkItem = ReactiveCache.getChecklistItem(checkItemId);
return checkItem && checkItem.title;
},
boardLabelLink() {
const data = this.currentData();
const currentBoardId = Session.get('currentBoard');
if (this.mode !== 'board') {
return createBoardLink(this.activity.board(), this.activity.listName ? this.activity.listName : null);
if (data.mode !== 'board') {
// data.mode: card, linkedcard, linkedboard
return createBoardLink(data.activity.board(), data.activity.listName ? data.activity.listName : null);
}
else if (currentBoardId != this.activity.boardId) {
return createBoardLink(this.activity.board(), this.activity.listName ? this.activity.listName : null);
else if (currentBoardId != data.activity.boardId) {
// data.mode: board
// current activitie is linked
return createBoardLink(data.activity.board(), data.activity.listName ? data.activity.listName : null);
}
return TAPi18n.__('this-board');
},
cardLabelLink() {
const data = this.currentData();
const currentBoardId = Session.get('currentBoard');
if (this.mode == 'card') {
if (data.mode == 'card') {
// data.mode: card
return TAPi18n.__('this-card');
}
else if (this.mode !== 'board') {
return createCardLink(this.activity.card(), null);
else if (data.mode !== 'board') {
// data.mode: linkedcard, linkedboard
return createCardLink(data.activity.card(), null);
}
else if (currentBoardId != this.activity.boardId) {
return createCardLink(this.activity.card(), this.activity.board().title);
else if (currentBoardId != data.activity.boardId) {
// data.mode: board
// current activitie is linked
return createCardLink(data.activity.card(), data.activity.board().title);
}
return createCardLink(this.activity.card(), null);
return createCardLink(this.currentData().activity.card(), null);
},
cardLink() {
const data = this.currentData();
const currentBoardId = Session.get('currentBoard');
if (this.mode !== 'board') {
return createCardLink(this.activity.card(), null);
if (data.mode !== 'board') {
// data.mode: card, linkedcard, linkedboard
return createCardLink(data.activity.card(), null);
}
else if (currentBoardId != this.activity.boardId) {
return createCardLink(this.activity.card(), this.activity.board().title);
else if (currentBoardId != data.activity.boardId) {
// data.mode: board
// current activitie is linked
return createCardLink(data.activity.card(), data.activity.board().title);
}
return createCardLink(this.activity.card(), null);
return createCardLink(this.currentData().activity.card(), null);
},
receivedDate() {
const card = this.activity.card();
if (!card) return null;
return card.receivedAt;
const receivedDate = this.currentData().activity.card();
if (!receivedDate) return null;
return receivedDate.receivedAt;
},
startDate() {
const card = this.activity.card();
if (!card) return null;
return card.startAt;
const startDate = this.currentData().activity.card();
if (!startDate) return null;
return startDate.startAt;
},
dueDate() {
const card = this.activity.card();
if (!card) return null;
return card.dueAt;
const dueDate = this.currentData().activity.card();
if (!dueDate) return null;
return dueDate.dueAt;
},
endDate() {
const card = this.activity.card();
if (!card) return null;
return card.endAt;
const endDate = this.currentData().activity.card();
if (!endDate) return null;
return endDate.endAt;
},
lastLabel() {
const lastLabelId = this.activity.labelId;
const lastLabelId = this.currentData().activity.labelId;
if (!lastLabelId) return null;
const lastLabel = ReactiveCache.getBoard(
this.activity.boardId,
this.currentData().activity.boardId,
).getLabelById(lastLabelId);
if (lastLabel && (lastLabel.name === undefined || lastLabel.name === '')) {
return lastLabel.color;
@@ -199,7 +174,7 @@ Template.activity.helpers({
lastCustomField() {
const lastCustomField = ReactiveCache.getCustomField(
this.activity.customFieldId,
this.currentData().activity.customFieldId,
);
if (!lastCustomField) return null;
return lastCustomField.name;
@@ -207,15 +182,16 @@ Template.activity.helpers({
lastCustomFieldValue() {
const lastCustomField = ReactiveCache.getCustomField(
this.activity.customFieldId,
this.currentData().activity.customFieldId,
);
if (!lastCustomField) return null;
const value = this.activity.value;
const value = this.currentData().activity.value;
if (
lastCustomField.settings.dropdownItems &&
lastCustomField.settings.dropdownItems.length > 0
) {
const dropDownValue = lastCustomField.settings.dropdownItems.find(
const dropDownValue = _.find(
lastCustomField.settings.dropdownItems,
item => {
return item._id === value;
},
@@ -226,13 +202,13 @@ Template.activity.helpers({
},
listLabel() {
const activity = this.activity;
const activity = this.currentData().activity;
const list = activity.list();
return (list && list.title) || activity.title;
},
sourceLink() {
const source = this.activity.source;
const source = this.currentData().activity.source;
if (source) {
if (source.url) {
return Blaze.toHTML(
@@ -240,11 +216,15 @@ Template.activity.helpers({
{
href: source.url,
},
sanitizeHTML(source.system),
DOMPurify.sanitize(source.system, {
ALLOW_UNKNOWN_PROTOCOLS: true,
}),
),
);
} else {
return sanitizeHTML(source.system);
return DOMPurify.sanitize(source.system, {
ALLOW_UNKNOWN_PROTOCOLS: true,
});
}
}
return null;
@@ -252,50 +232,46 @@ Template.activity.helpers({
memberLink() {
return Blaze.toHTMLWithData(Template.memberName, {
user: this.activity.member(),
user: this.currentData().activity.member(),
});
},
attachmentLink() {
const attachment = this.activity.attachment();
const attachmentUrl = attachment && typeof attachment.link === 'function'
? attachment.link()
: '';
const attachment = this.currentData().activity.attachment();
// trying to display url before file is stored generates js errors
return (
(attachment &&
attachment.path &&
attachmentUrl &&
Blaze.toHTML(
HTML.A(
{
href: `${attachmentUrl}?download=true`,
href: `${attachment.link()}?download=true`,
target: '_blank',
},
sanitizeText(attachment.name),
DOMPurify.sanitize(attachment.name),
),
)) ||
sanitizeText(this.activity.attachmentName)
DOMPurify.sanitize(this.currentData().activity.attachmentName)
);
},
customField() {
const customField = this.activity.customField();
const customField = this.currentData().activity.customField();
if (!customField) return null;
return customField.name;
},
});
}).register('activity');
Template.activity.helpers({
sanitize(value) {
return sanitizeHTML(value);
return DOMPurify.sanitize(value, { ALLOW_UNKNOWN_PROTOCOLS: true });
},
});
Template.commentReactions.events({
'click .reaction'(event) {
const user = ReactiveCache.getCurrentUser();
if (user && user.isBoardMember()) {
if (ReactiveCache.getCurrentUser().isBoardMember()) {
const codepoint = event.currentTarget.dataset['codepoint'];
const commentId = Template.instance().data.commentId;
const cardComment = ReactiveCache.getCardComment(commentId);
@@ -307,8 +283,7 @@ Template.commentReactions.events({
Template.addReactionPopup.events({
'click .add-comment-reaction'(event) {
const user = ReactiveCache.getCurrentUser();
if (user && user.isBoardMember()) {
if (ReactiveCache.getCurrentUser().isBoardMember()) {
const codepoint = event.currentTarget.dataset['codepoint'];
const commentId = Template.instance().data.commentId;
const cardComment = ReactiveCache.getCardComment(commentId);
@@ -361,7 +336,7 @@ function createCardLink(card, board) {
href: card.originRelativeUrl(),
class: 'action-card',
},
sanitizeHTML(text),
DOMPurify.sanitize(text, { ALLOW_UNKNOWN_PROTOCOLS: true }),
),
)
);
@@ -378,7 +353,7 @@ function createBoardLink(board, list) {
href: board.originRelativeUrl(),
class: 'action-board',
},
sanitizeHTML(text),
DOMPurify.sanitize(text, { ALLOW_UNKNOWN_PROTOCOLS: true }),
),
)
);
+8 -5
View File
@@ -108,12 +108,15 @@
text-decoration: none;
height: 24px;
}
.comments .comment .comment-desc .reactions .open-comment-reaction-popup span {
display: inline-block;
font-size: clamp(14px, 2vw, 18px);
.comments .comment .comment-desc .reactions .open-comment-reaction-popup i.fa.fa-smile-o {
font-size: 17px;
font-weight: 500;
line-height: 1;
margin-left: 4px;
margin-left: 2px;
}
.comments .comment .comment-desc .reactions .open-comment-reaction-popup i.fa.fa-plus {
font-size: 8px;
margin-top: -7px;
margin-left: 1px;
}
.comments .comment .comment-desc .reactions .reaction {
cursor: pointer;
+5 -8
View File
@@ -25,14 +25,13 @@ template(name="comment")
= text
.edit-controls
button.primary(type="submit") {{_ 'edit'}}
a.js-close-inlined-form(title="{{_ 'close' }}")
i.fa.fa-times-thin
.fa.fa-times-thin.js-close-inlined-form
else
.comment-text
+viewer
= text
+commentReactions(reactions=reactions commentId=_id)
span(title=createdAt).comment-meta {{ displayDate createdAt }}
span(title=createdAt).comment-meta {{ moment createdAt }}
if($eq currentUser._id userId)
+editOrDeleteComment
else if currentUser.isBoardAdmin
@@ -55,11 +54,9 @@ template(name="commentReactions")
span.reaction-codepoint !{reaction.reactionCodepoint}
span.reaction-count #{reaction.userIds.length}
if (currentUser.isBoardMember)
unless currentUser.isReadOnly
unless currentUser.isReadAssignedOnly
a.open-comment-reaction-popup(title="{{_ 'addReactionPopup-title'}}")
span(title="{{_ 'reaction' }}") 😀
span(title="{{_ 'add' }}") ➕
a.open-comment-reaction-popup(title="{{_ 'addReactionPopup-title'}}")
i.fa.fa-smile-o
i.fa.fa-plus
template(name="addReactionPopup")
.reactions-popup
+78 -69
View File
@@ -1,85 +1,94 @@
import { ReactiveCache } from '/imports/reactiveCache';
import CardComments from '/models/cardComments';
import { UnsavedEdits } from '/client/lib/unsavedEdits';
import { EscapeActions } from '/client/lib/escapeActions';
import { Utils } from '/client/lib/utils';
import autosize from 'autosize';
const commentFormIsOpen = new ReactiveVar(false);
Template.commentForm.onDestroyed(function () {
commentFormIsOpen.set(false);
$('.note-popover').hide();
});
BlazeComponent.extendComponent({
onDestroyed() {
commentFormIsOpen.set(false);
$('.note-popover').hide();
},
Template.commentForm.helpers({
commentFormIsOpen() {
return commentFormIsOpen.get();
},
});
Template.commentForm.events({
'submit .js-new-comment-form'(evt, tpl) {
const input = tpl.$('.js-new-comment-input');
const text = input.val().trim();
const card = Template.currentData();
let boardId = card.boardId;
let cardId = card._id;
if (card.isLinkedCard()) {
boardId = ReactiveCache.getCard(card.linkedId).boardId;
cardId = card.linkedId;
} else if (card.isLinkedBoard()) {
boardId = card.linkedId;
}
if (text) {
CardComments.insert({
text,
boardId,
cardId,
});
resetCommentInput(input);
Tracker.flush();
autosize.update(input);
input.trigger('submitted');
}
evt.preventDefault();
getInput() {
return this.$('.js-new-comment-input');
},
// Pressing Ctrl+Enter should submit the form
'keydown form textarea'(evt, tpl) {
if (evt.keyCode === 13 && (evt.metaKey || evt.ctrlKey)) {
tpl.find('button[type=submit]').click();
}
},
});
Template.comments.helpers({
getComments() {
const data = Template.currentData();
if (!data || typeof data.comments !== 'function') return [];
return data.comments();
},
});
Template.comment.events({
'click .js-delete-comment': Popup.afterConfirm('deleteComment', function () {
const commentId = this._id;
CardComments.remove(commentId);
Popup.back();
}),
'submit .js-edit-comment'(evt, tpl) {
evt.preventDefault();
const textarea = tpl.find('.js-edit-comment textarea,input[type=text]');
const commentText = textarea && textarea.value ? textarea.value.trim() : '';
const commentId = tpl.data._id;
if (commentText) {
CardComments.update(commentId, {
$set: {
text: commentText,
events() {
return [
{
'submit .js-new-comment-form'(evt) {
const input = this.getInput();
const text = input.val().trim();
const card = this.currentData();
let boardId = card.boardId;
let cardId = card._id;
if (card.isLinkedCard()) {
boardId = ReactiveCache.getCard(card.linkedId).boardId;
cardId = card.linkedId;
} else if (card.isLinkedBoard()) {
boardId = card.linkedId;
}
if (text) {
CardComments.insert({
text,
boardId,
cardId,
});
resetCommentInput(input);
Tracker.flush();
autosize.update(input);
input.trigger('submitted');
}
evt.preventDefault();
},
});
}
// Pressing Ctrl+Enter should submit the form
'keydown form textarea'(evt) {
if (evt.keyCode === 13 && (evt.metaKey || evt.ctrlKey)) {
this.find('button[type=submit]').click();
}
},
},
];
},
});
}).register('commentForm');
BlazeComponent.extendComponent({
getComments() {
const ret = this.data().comments();
return ret;
},
}).register("comments");
BlazeComponent.extendComponent({
events() {
return [
{
'click .js-delete-comment': Popup.afterConfirm('deleteComment', () => {
const commentId = this.data()._id;
CardComments.remove(commentId);
Popup.back();
}),
'submit .js-edit-comment'(evt) {
evt.preventDefault();
const commentText = this.currentComponent()
.getValue()
.trim();
const commentId = this.data()._id;
if (commentText) {
CardComments.update(commentId, {
$set: {
text: commentText,
},
});
}
},
},
];
},
}).register("comment");
// XXX This should be a static method of the `commentForm` component
function resetCommentInput(input) {
@@ -1,184 +0,0 @@
/* Board Conversion Progress Styles */
.board-conversion-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.7);
z-index: 9999;
display: none;
align-items: center;
justify-content: center;
}
.board-conversion-overlay.active {
display: flex;
}
.board-conversion-modal {
background: white;
border-radius: 8px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
max-width: 500px;
width: 90%;
max-height: 80vh;
overflow: hidden;
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.board-conversion-header {
padding: 20px 24px 16px;
border-bottom: 1px solid #e0e0e0;
text-align: center;
}
.board-conversion-header h3 {
margin: 0 0 8px 0;
color: #333;
font-size: 20px;
font-weight: 500;
}
.board-conversion-header h3 i {
margin-right: 8px;
color: #2196F3;
}
.board-conversion-header p {
margin: 0;
color: #666;
font-size: 14px;
}
.board-conversion-content {
padding: 24px;
}
.conversion-progress {
margin-bottom: 20px;
}
.progress-bar {
width: 100%;
height: 8px;
background-color: #e0e0e0;
border-radius: 4px;
overflow: hidden;
margin-bottom: 8px;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #2196F3, #21CBF3);
border-radius: 4px;
transition: width 0.3s ease;
position: relative;
}
.progress-fill::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.3),
transparent
);
animation: shimmer 2s infinite;
}
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
.progress-text {
text-align: center;
font-weight: 600;
color: #2196F3;
font-size: 16px;
}
.conversion-status {
text-align: center;
margin-bottom: 16px;
color: #333;
font-size: 16px;
}
.conversion-status i {
margin-right: 8px;
color: #2196F3;
}
.conversion-time {
text-align: center;
color: #666;
font-size: 14px;
background-color: #f5f5f5;
padding: 8px 12px;
border-radius: 4px;
margin-bottom: 16px;
}
.conversion-time i {
margin-right: 6px;
color: #FF9800;
}
.board-conversion-footer {
padding: 16px 24px 20px;
border-top: 1px solid #e0e0e0;
background-color: #f9f9f9;
}
.conversion-info {
text-align: center;
color: #666;
font-size: 13px;
line-height: 1.4;
}
.conversion-info i {
margin-right: 6px;
color: #2196F3;
}
/* Responsive design */
@media (max-width: 600px) {
.board-conversion-modal {
width: 95%;
margin: 20px;
}
.board-conversion-header,
.board-conversion-content,
.board-conversion-footer {
padding-left: 16px;
padding-right: 16px;
}
.board-conversion-header h3 {
font-size: 18px;
}
}
@@ -1,27 +0,0 @@
template(name="boardConversionProgress")
.board-conversion-overlay(class="{{#if isConverting}}active{{/if}}")
.board-conversion-modal
.board-conversion-header
h3
i.fa.fa-cog
| {{_ 'converting-board'}}
p {{_ 'converting-board-description'}}
.board-conversion-content
.conversion-progress
.progress-bar
.progress-fill(style="width: {{conversionProgress}}%")
.progress-text {{conversionProgress}}%
.conversion-status
i.fa.fa-cog
| {{conversionStatus}}
.conversion-time(style="{{#unless conversionEstimatedTime}}display: none;{{/unless}}")
i.fa.fa-clock-o
| {{_ 'estimated-time-remaining'}}: {{conversionEstimatedTime}}
.board-conversion-footer
.conversion-info
i.fa.fa-info-circle
| {{_ 'conversion-info-text'}}
@@ -1,37 +0,0 @@
import { Template } from 'meteor/templating';
import { ReactiveVar } from 'meteor/reactive-var';
import {
boardConverter,
isConverting,
conversionProgress,
conversionStatus,
conversionEstimatedTime
} from '/client/lib/boardConverter';
Template.boardConversionProgress.helpers({
isConverting() {
return isConverting.get();
},
conversionProgress() {
return conversionProgress.get();
},
conversionStatus() {
return conversionStatus.get();
},
conversionEstimatedTime() {
return conversionEstimatedTime.get();
}
});
Template.boardConversionProgress.onCreated(function() {
// Subscribe to conversion state changes
this.autorun(() => {
isConverting.get();
conversionProgress.get();
conversionStatus.get();
conversionEstimatedTime.get();
});
});
+5 -6
View File
@@ -1,21 +1,20 @@
template(name="archivedBoards")
h2
span(title="{{_ 'archived-boards'}}")
i.fa.fa-archive
i.fa.fa-archive
| {{_ 'archived-boards'}}
ul.archived-lists
each archivedBoards
li.archived-lists-item
div.board-header-btns
button.primary.board-header-btn.js-delete-board
i.fa.fa-trash
button.board-header-btn.js-delete-board
i.fa.fa-trash-o
| {{_ 'delete-board'}}
button.primary.board-header-btn.js-restore-board
button.board-header-btn.js-restore-board
i.fa.fa-undo
| {{_ 'restore-board'}}
= title
span {{ displayDate archivedAt 'LLL' }}
span {{ moment archivedAt 'LLL' }}
else
li.no-items-message {{_ 'no-archived-boards'}}
+37 -36
View File
@@ -1,13 +1,10 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
import Boards from '/models/boards';
import { Utils } from '/client/lib/utils';
Template.archivedBoards.onCreated(function () {
this.subscribe('archivedBoards');
});
BlazeComponent.extendComponent({
onCreated() {
this.subscribe('archivedBoards');
},
Template.archivedBoards.helpers({
isBoardAdmin() {
return ReactiveCache.getCurrentUser().isBoardAdmin();
},
@@ -21,34 +18,38 @@ Template.archivedBoards.helpers({
);
return ret;
},
});
Template.archivedBoards.events({
async 'click .js-restore-board'() {
// TODO : Make isSandstorm variable global
const isSandstorm =
Meteor.settings &&
Meteor.settings.public &&
Meteor.settings.public.sandstorm;
if (isSandstorm && Utils.getCurrentBoardId()) {
const currentBoard = Utils.getCurrentBoard();
await currentBoard.archive();
}
const board = this;
await board.restore();
Utils.goBoardId(board._id);
events() {
return [
{
'click .js-restore-board'() {
// TODO : Make isSandstorm variable global
const isSandstorm =
Meteor.settings &&
Meteor.settings.public &&
Meteor.settings.public.sandstorm;
if (isSandstorm && Utils.getCurrentBoardId()) {
const currentBoard = Utils.getCurrentBoard();
currentBoard.archive();
}
const board = this.currentData();
board.restore();
Utils.goBoardId(board._id);
},
'click .js-delete-board': Popup.afterConfirm('boardDelete', function() {
Popup.back();
const isSandstorm =
Meteor.settings &&
Meteor.settings.public &&
Meteor.settings.public.sandstorm;
if (isSandstorm && Utils.getCurrentBoardId()) {
const currentBoard = Utils.getCurrentBoard();
Boards.remove(currentBoard._id);
}
Boards.remove(this._id);
FlowRouter.go('home');
}),
},
];
},
'click .js-delete-board': Popup.afterConfirm('boardDelete', async function() {
Popup.back();
const isSandstorm =
Meteor.settings &&
Meteor.settings.public &&
Meteor.settings.public.sandstorm;
if (isSandstorm && Utils.getCurrentBoardId()) {
const currentBoard = Utils.getCurrentBoard();
await Boards.removeAsync(currentBoard._id);
}
await Boards.removeAsync(this._id);
FlowRouter.go('home');
}),
});
}).register('archivedBoards');
+4 -356
View File
@@ -6,27 +6,6 @@
bottom: 0;
overflow-x: hidden;
overflow-y: hidden;
width: 100%;
min-width: 100%;
}
/* When zoom is 50% or lower, ensure full width like content */
.board-wrapper[style*="transform: scale(0.5)"] {
width: 100% !important;
max-width: 100% !important;
margin: 0 !important;
}
.board-wrapper[style*="transform: scale(0.4)"] {
width: 100% !important;
max-width: 100% !important;
margin: 0 !important;
}
.board-wrapper[style*="transform: scale(0.3)"] {
width: 100% !important;
max-width: 100% !important;
margin: 0 !important;
}
.board-wrapper .board-canvas {
position: absolute;
@@ -36,243 +15,6 @@
bottom: 0;
transition: margin 0.1s;
overflow-y: auto;
width: 100%;
min-width: 100%;
}
/* Ensure horizontal scrollbar is visible for high zoom levels */
.board-wrapper .board-canvas[style*="overflow-x: auto"] {
overflow-x: auto !important;
overflow-y: auto !important;
}
/* Style the scrollbar for better visibility */
.board-wrapper .board-canvas::-webkit-scrollbar {
height: 12px;
width: 12px;
}
.board-wrapper .board-canvas::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 6px;
}
.board-wrapper .board-canvas::-webkit-scrollbar-thumb {
background: #888;
border-radius: 6px;
}
.board-wrapper .board-canvas::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* Ensure content area can scroll horizontally for high zoom levels */
#content[style*="overflow-x: auto"] {
overflow-x: auto !important;
overflow-y: auto !important;
}
/* Style content area scrollbar */
#content::-webkit-scrollbar {
height: 12px;
width: 12px;
}
#content::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 6px;
}
#content::-webkit-scrollbar-thumb {
background: #888;
border-radius: 6px;
}
#content::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* Ensure scrollbars are always visible for high zoom levels */
#content[style*="overflow-x: auto"] {
position: relative;
}
#content[style*="overflow-x: auto"]::-webkit-scrollbar {
height: 12px;
width: 12px;
}
/* Force vertical scrollbar to always be visible */
#content[style*="overflow-y: auto"] {
overflow-y: scroll !important;
}
/* Mobile - make all text 2x bigger inside #content by default (icons stay same size) */
@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) {
#content {
font-size: 2em !important; /* 2x bigger base font size for content area */
}
/* Make all text elements 2x bigger */
#content h1, #content h2, #content h3, #content h4, #content h5, #content h6,
#content p, #content span, #content div, #content a, #content button,
#content .minicard, #content .list-header-name, #content .board-header-btn,
#content .card-title, #content .card-details, #content .card-description,
#content .swimlane-header, #content .list-title, #content .card-text,
#content .member, #content .member-name, #content .member-initials,
#content .checklist-item, #content .checklist-title, #content .comment,
#content .activity, #content .activity-text, #content .activity-time,
#content .board-title, #content .board-description, #content .list-name,
#content .card-text, #content .card-title, #content .card-description,
#content .swimlane-title, #content .swimlane-description,
#content .board-header-title, #content .board-header-description,
#content .card-detail-title, #content .card-detail-description,
#content .list-header-title, #content .list-header-description,
#content .swimlane-header-title, #content .swimlane-header-description,
#content .minicard-title, #content .minicard-description,
#content .card-comment, #content .card-comment-text,
#content .checklist-item-text, #content .checklist-item-title,
#content .activity-item, #content .activity-item-text,
#content .board-member, #content .board-member-name,
#content .team-member, #content .team-member-name,
#content .org-member, #content .org-member-name,
#content .template-member, #content .template-member-name,
#content .user-name, #content .user-email, #content .user-role,
#content .setting-title, #content .setting-description,
#content .popup-title, #content .popup-description,
#content .modal-title, #content .modal-description,
#content .notification-title, #content .notification-text,
#content .announcement-title, #content .announcement-text,
#content .offline-warning-title, #content .offline-warning-text,
#content .error-title, #content .error-text,
#content .success-title, #content .success-text,
#content .info-title, #content .info-text,
#content .warning-title, #content .warning-text {
font-size: 1em !important; /* Use inherited 2x scaling */
}
/* Keep icons the same size (don't scale them) */
#content .fa, #content .icon, #content i {
font-size: 1em !important; /* Keep original icon size */
}
/* Reset specific icon sizes to prevent double scaling */
#content .fa-home, #content .fa-bars, #content .fa-search,
#content .fa-bell, #content .fa-user, #content .fa-cog,
#content .fa-plus, #content .fa-minus, #content .fa-edit,
#content .fa-trash, #content .fa-save, #content .fa-cancel,
#content .fa-arrow-left, #content .fa-arrow-right,
#content .fa-arrow-up, #content .fa-arrow-down,
#content .fa-check, #content .fa-times, #content .fa-close,
#content .fa-star, #content .fa-heart, #content .fa-thumbs-up,
#content .fa-thumbs-down, #content .fa-comment, #content .fa-reply,
#content .fa-share, #content .fa-download, #content .fa-upload,
#content .fa-copy, #content .fa-paste, #content .fa-cut,
#content .fa-undo, #content .fa-redo, #content .fa-refresh,
#content .fa-sync, #content .fa-spinner, #content .fa-loading,
#content .fa-info, #content .fa-question, #content .fa-exclamation,
#content .fa-warning, #content .fa-error, #content .fa-success,
#content .fa-check-circle, #content .fa-times-circle,
#content .fa-exclamation-circle, #content .fa-question-circle,
#content .fa-info-circle, #content .fa-warning-circle,
#content .fa-error-circle, #content .fa-success-circle {
font-size: 1em !important; /* Keep original icon size */
}
}
/* Fallback for iPhone devices using JavaScript detection */
.iphone-device #content {
font-size: 2em !important; /* 2x bigger base font size for content area */
}
.iphone-device #content h1, .iphone-device #content h2, .iphone-device #content h3, .iphone-device #content h4, .iphone-device #content h5, .iphone-device #content h6,
.iphone-device #content p, .iphone-device #content span, .iphone-device #content div, .iphone-device #content a, .iphone-device #content button,
.iphone-device #content .minicard, .iphone-device #content .list-header-name, .iphone-device #content .board-header-btn,
.iphone-device #content .card-title, .iphone-device #content .card-details, .iphone-device #content .card-description,
.iphone-device #content .swimlane-header, .iphone-device #content .list-title, .iphone-device #content .card-text,
.iphone-device #content .member, .iphone-device #content .member-name, .iphone-device #content .member-initials,
.iphone-device #content .checklist-item, .iphone-device #content .checklist-title, .iphone-device #content .comment,
.iphone-device #content .activity, .iphone-device #content .activity-text, .iphone-device #content .activity-time,
.iphone-device #content .board-title, .iphone-device #content .board-description, .iphone-device #content .list-name,
.iphone-device #content .card-text, .iphone-device #content .card-title, .iphone-device #content .card-description,
.iphone-device #content .swimlane-title, .iphone-device #content .swimlane-description,
.iphone-device #content .board-header-title, .iphone-device #content .board-header-description,
.iphone-device #content .card-detail-title, .iphone-device #content .card-detail-description,
.iphone-device #content .list-header-title, .iphone-device #content .list-header-description,
.iphone-device #content .swimlane-header-title, .iphone-device #content .swimlane-header-description,
.iphone-device #content .minicard-title, .iphone-device #content .minicard-description,
.iphone-device #content .card-comment, .iphone-device #content .card-comment-text,
.iphone-device #content .checklist-item-text, .iphone-device #content .checklist-item-title,
.iphone-device #content .activity-item, .iphone-device #content .activity-item-text,
.iphone-device #content .board-member, .iphone-device #content .board-member-name,
.iphone-device #content .team-member, .iphone-device #content .team-member-name,
.iphone-device #content .org-member, .iphone-device #content .org-member-name,
.iphone-device #content .template-member, .iphone-device #content .template-member-name,
.iphone-device #content .user-name, .iphone-device #content .user-email, .iphone-device #content .user-role,
.iphone-device #content .setting-title, .iphone-device #content .setting-description,
.iphone-device #content .popup-title, .iphone-device #content .popup-description,
.iphone-device #content .modal-title, .iphone-device #content .modal-description,
.iphone-device #content .notification-title, .iphone-device #content .notification-text,
.iphone-device #content .announcement-title, .iphone-device #content .announcement-text,
.iphone-device #content .offline-warning-title, .iphone-device #content .offline-warning-text,
.iphone-device #content .error-title, .iphone-device #content .error-text,
.iphone-device #content .success-title, .iphone-device #content .success-text,
.iphone-device #content .info-title, .iphone-device #content .info-text,
.iphone-device #content .warning-title, .iphone-device #content .warning-text {
font-size: 1em !important; /* Use inherited 2x scaling */
}
/* Keep icons the same size for iPhone devices */
.iphone-device #content .fa, .iphone-device #content .icon, .iphone-device #content i {
font-size: 1em !important; /* Keep original icon size */
}
/* Mobile iPhone: scale card details text and icons to 2x */
body.mobile-mode.iphone-device .card-details {
font-size: 2em !important;
}
body.mobile-mode.iphone-device .card-details .fa,
body.mobile-mode.iphone-device .card-details .icon,
body.mobile-mode.iphone-device .card-details i,
body.mobile-mode.iphone-device .card-details .emoji-icon,
body.mobile-mode.iphone-device .card-details a,
body.mobile-mode.iphone-device .card-details p,
body.mobile-mode.iphone-device .card-details span,
body.mobile-mode.iphone-device .card-details div,
body.mobile-mode.iphone-device .card-details button,
body.mobile-mode.iphone-device .card-details input,
body.mobile-mode.iphone-device .card-details select,
body.mobile-mode.iphone-device .card-details textarea {
font-size: inherit !important;
}
/* Section titles slightly larger than content but not as big as card title */
body.mobile-mode.iphone-device .card-details .card-details-item-title {
font-size: 1.1em !important;
font-weight: bold;
}
/* Ensure scrollbars are positioned correctly */
#content[style*="overflow-x: auto"]::-webkit-scrollbar:vertical {
width: 12px;
}
#content[style*="overflow-x: auto"]::-webkit-scrollbar:horizontal {
height: 12px;
}
/* Force both scrollbars to always be visible for high zoom levels */
#content[style*="overflow-x: auto"][style*="overflow-y: auto"] {
overflow-x: scroll !important;
overflow-y: scroll !important;
}
/* Ensure the right scrollbar is always visible on the inner right edge */
#content[style*="overflow-y: auto"] {
scrollbar-gutter: stable;
}
.board-wrapper .board-canvas .board-overlay {
position: fixed;
@@ -287,106 +29,19 @@ body.mobile-mode.iphone-device .card-details .card-details-item-title {
animation: fadeIn 0.2s;
z-index: 16;
}
/* Fix for mobile Safari: ensure overlay stays behind card details */
@media screen and (max-width: 800px) {
.board-wrapper .board-canvas .board-overlay {
z-index: 17 !important;
}
/* In desktop mode on small screens, still keep overlay behind card */
body.desktop-mode .board-wrapper .board-canvas .board-overlay {
z-index: 17 !important;
}
}
/* In mobile mode, lower the overlay z-index to stay behind card details */
body.mobile-mode .board-wrapper .board-canvas .board-overlay {
z-index: 17 !important;
}
/* iPhone in desktop mode: remove overlay to avoid blocking card */
body.desktop-mode.iphone-device .board-wrapper .board-canvas .board-overlay {
display: none !important;
pointer-events: none !important;
}
/* Desktop mode: hide overlay to allow multiple cards and board interaction */
body.desktop-mode .board-wrapper .board-canvas .board-overlay {
display: none !important;
pointer-events: none !important;
}
.board-wrapper .board-canvas.is-dragging-active .open-minicard-composer,
.board-wrapper .board-canvas.is-dragging-active .minicard-wrapper.is-checked {
display: none;
}
/* Mobile view styles - applied when isMiniScreen is true (iPhone, etc.) */
.board-wrapper.mobile-view {
width: 100vw !important;
max-width: 100vw !important;
min-width: 100vw !important;
left: 0 !important;
right: 0 !important;
overflow-x: hidden !important;
overflow-y: auto !important;
}
.board-wrapper.mobile-view .board-canvas {
width: 100vw !important;
max-width: 100vw !important;
min-width: 100vw !important;
left: 0 !important;
right: 0 !important;
overflow-x: hidden !important;
overflow-y: auto !important;
}
.board-wrapper.mobile-view .board-canvas.mobile-view .swimlane {
border-bottom: 1px solid #ccc;
display: block !important;
flex-direction: column;
margin: 0;
padding: 0;
overflow-x: hidden !important;
overflow-y: auto;
width: 100vw !important;
max-width: 100vw !important;
min-width: 100vw !important;
}
@media screen and (max-width: 800px),
screen and (max-device-width: 932px) and (-webkit-min-device-pixel-ratio: 3) {
.board-wrapper {
width: 100vw !important;
max-width: 100vw !important;
min-width: 100vw !important;
left: 0 !important;
right: 0 !important;
overflow-x: hidden !important;
overflow-y: auto !important;
}
.board-wrapper .board-canvas {
width: 100vw !important;
max-width: 100vw !important;
min-width: 100vw !important;
left: 0 !important;
right: 0 !important;
overflow-x: hidden !important;
overflow-y: auto !important;
}
@media screen and (max-width: 800px) {
.board-wrapper .board-canvas .swimlane {
border-bottom: 1px solid #ccc;
display: block !important;
display: flex;
flex-direction: column;
margin: 0;
padding: 0;
overflow-x: hidden !important;
padding: 0 0px 0px 0;
overflow-x: hidden;
overflow-y: auto;
width: 100vw !important;
max-width: 100vw !important;
min-width: 100vw !important;
}
}
.calendar-event-green {
@@ -564,10 +219,3 @@ body.desktop-mode .board-wrapper .board-canvas .board-overlay {
font-size: 25px;
cursor: pointer;
}
/* Global file drag over state for board canvas */
.board-canvas.file-drag-over {
background-color: rgba(0, 123, 255, 0.05) !important;
border: 2px dashed #007bff !important;
transition: all 0.2s ease;
}
+11 -29
View File
@@ -1,8 +1,5 @@
template(name="board")
if isConverting
+boardConversionProgress
else if isBoardReady
if isBoardReady.get
if currentBoard
if onlyShowCurrentCard
+cardDetails(currentCard)
@@ -19,19 +16,14 @@ template(name="boardBody")
if notDisplayThisBoard
| {{_ 'tableVisibilityMode-allowPrivateOnly'}}
else
// Debug information (remove in production)
if debugBoardState
.debug-info(style="position: fixed; top: 0; left: 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=currentBoard.colorClass)
.board-canvas.js-swimlanes(
class="{{#if hasSwimlanes}}dragscroll{{/if}}"
class="{{#if Sidebar.isOpen}}is-sibling-sidebar-open{{/if}}"
class="{{#if MultiSelection.isActive}}is-multiselection-active{{/if}}"
class="{{#if draggingActive}}is-dragging-active{{/if}}"
class="{{#unless isVerticalScrollbars}}no-scrollbars{{/unless}}"
class="{{#if isMiniScreen}}mobile-view{{/if}}")
if showOverlay
class="{{#if draggingActive.get}}is-dragging-active{{/if}}"
class="{{#unless isVerticalScrollbars}}no-scrollbars{{/unless}}")
if showOverlay.get
.board-overlay
if currentBoard.isTemplatesBoard
each currentBoard.swimlanes
@@ -41,30 +33,20 @@ template(name="boardBody")
each currentBoard.swimlanes
+swimlane(this)
else
// Fallback: If no swimlanes exist, show lists instead of empty message
+listsGroup(currentBoard)
a.js-empty-board-add-swimlane(title="{{_ 'add-swimlane'}}")
h1.big-message.quiet
| {{_ 'add-swimlane'}} +
else if isViewLists
+listsGroup(currentBoard)
else if isViewCalendar
+calendarView
else if isViewGantt
+ganttView
else
// Default view - show swimlanes if they exist, otherwise show lists
if hasSwimlanes
each currentBoard.swimlanes
+swimlane(this)
else
+listsGroup(currentBoard)
//- Render multiple open cards in desktop mode
unless isMiniScreen
each openCards
+cardDetails(this cardIndex=@index)
+listsGroup(currentBoard)
+sidebar
template(name="calendarView")
if isViewCalendar
.calendar-view.swimlane.dragscroll
.calendar-view.swimlane
if currentCard
+cardDetails(currentCard)
+fullcalendar(calendarOptions)
+fullcalendar(calendarOptions)
File diff suppressed because it is too large. Load diff
+27 -157
View File
@@ -1,4 +1,4 @@
/* ===============
/* ===============
THEME - NEPHRITIS
=================*/
@@ -49,12 +49,6 @@ THEME - NEPHRITIS
border-bottom: 2px solid #27ae60;
border-right: 2px solid #27ae60;
}
.board-color-nephritis .checklist-progress-bar {
background-color: #d4f1dd !important;
}
.board-color-nephritis .checklist-progress-bar .checklist-progress {
background-color: #27ae60 !important;
}
.board-color-nephritis .is-multiselection-active .multi-selection-checkbox.is-checked + .minicard {
background: #e7faef;
}
@@ -105,7 +99,7 @@ THEME - NEPHRITIS
/* === END NEPHRITIS THEME === */
/* ===============
/* ===============
THEME - Pomegranate
=================*/
@@ -156,12 +150,6 @@ THEME - Pomegranate
border-bottom: 2px solid #c0392b;
border-right: 2px solid #c0392b;
}
.board-color-pomegranate .checklist-progress-bar {
background-color: #f5d5d2 !important;
}
.board-color-pomegranate .checklist-progress-bar .checklist-progress {
background-color: #c0392b !important;
}
.board-color-pomegranate .is-multiselection-active .multi-selection-checkbox.is-checked + .minicard {
background: #faeae9;
}
@@ -212,7 +200,7 @@ THEME - Pomegranate
/* === END Pomegranate THEME === */
/* ===============
/* ===============
THEME - Belize
=================*/
@@ -263,12 +251,6 @@ THEME - Belize
border-bottom: 2px solid #2980b9;
border-right: 2px solid #2980b9;
}
.board-color-belize .checklist-progress-bar {
background-color: #d1e7f5 !important;
}
.board-color-belize .checklist-progress-bar .checklist-progress {
background-color: #2980b9 !important;
}
.board-color-belize .is-multiselection-active .multi-selection-checkbox.is-checked + .minicard {
background: #e8f3fa;
}
@@ -319,7 +301,7 @@ THEME - Belize
/* === END Belize THEME === */
/* ===============
/* ===============
THEME - Wisteria
=================*/
@@ -370,12 +352,6 @@ THEME - Wisteria
border-bottom: 2px solid #8e44ad;
border-right: 2px solid #8e44ad;
}
.board-color-wisteria .checklist-progress-bar {
background-color: #e8d9f0 !important;
}
.board-color-wisteria .checklist-progress-bar .checklist-progress {
background-color: #8e44ad !important;
}
.board-color-wisteria .is-multiselection-active .multi-selection-checkbox.is-checked + .minicard {
background: #f4ecf7;
}
@@ -426,7 +402,7 @@ THEME - Wisteria
/* === END Wisteria THEME === */
/* ===============
/* ===============
THEME - Midnight
=================*/
@@ -477,12 +453,6 @@ THEME - Midnight
border-bottom: 2px solid #2c3e50;
border-right: 2px solid #2c3e50;
}
.board-color-midnight .checklist-progress-bar {
background-color: #d2dae2 !important;
}
.board-color-midnight .checklist-progress-bar .checklist-progress {
background-color: #2c3e50 !important;
}
.board-color-midnight .is-multiselection-active .multi-selection-checkbox.is-checked + .minicard {
background: #e6ecf1;
}
@@ -533,7 +503,7 @@ THEME - Midnight
/* === END Midnight THEME === */
/* ===============
/* ===============
THEME - Pumpkin
=================*/
@@ -584,12 +554,6 @@ THEME - Pumpkin
border-bottom: 2px solid #e67e22;
border-right: 2px solid #e67e22;
}
.board-color-pumpkin .checklist-progress-bar {
background-color: #f9e5d1 !important;
}
.board-color-pumpkin .checklist-progress-bar .checklist-progress {
background-color: #e67e22 !important;
}
.board-color-pumpkin .is-multiselection-active .multi-selection-checkbox.is-checked + .minicard {
background: #fdf2e9;
}
@@ -640,7 +604,7 @@ THEME - Pumpkin
/* === END Pumpkin THEME === */
/* ===============
/* ===============
THEME - Moderate Pink
=================*/
@@ -691,12 +655,6 @@ THEME - Moderate Pink
border-bottom: 2px solid #cd5a91;
border-right: 2px solid #cd5a91;
}
.board-color-moderatepink .checklist-progress-bar {
background-color: #f4dde8 !important;
}
.board-color-moderatepink .checklist-progress-bar .checklist-progress {
background-color: #cd5a91 !important;
}
.board-color-moderatepink .is-multiselection-active .multi-selection-checkbox.is-checked + .minicard {
background: #faeef4;
}
@@ -747,7 +705,7 @@ THEME - Moderate Pink
/* === END Moderatepink THEME === */
/* ===============
/* ===============
THEME - Strong Cyan
=================*/
@@ -798,12 +756,6 @@ THEME - Strong Cyan
border-bottom: 2px solid #00aecc;
border-right: 2px solid #00aecc;
}
.board-color-strongcyan .checklist-progress-bar {
background-color: #ccf2f9 !important;
}
.board-color-strongcyan .checklist-progress-bar .checklist-progress {
background-color: #00aecc !important;
}
.board-color-strongcyan .is-multiselection-active .multi-selection-checkbox.is-checked + .minicard {
background: #e0fbff;
}
@@ -854,7 +806,7 @@ THEME - Strong Cyan
/* === END Strongcyan THEME === */
/* ===============
/* ===============
THEME - Lime Green
=================*/
@@ -905,12 +857,6 @@ THEME - Lime Green
border-bottom: 2px solid #4bbf6b;
border-right: 2px solid #4bbf6b;
}
.board-color-limegreen .checklist-progress-bar {
background-color: #daf4de !important;
}
.board-color-limegreen .checklist-progress-bar .checklist-progress {
background-color: #4bbf6b !important;
}
.board-color-limegreen .is-multiselection-active .multi-selection-checkbox.is-checked + .minicard {
background: #edf9f0;
}
@@ -961,7 +907,7 @@ THEME - Lime Green
/* === END Limegreen THEME === */
/* ===============
/* ===============
THEME - Dark
=================*/
@@ -1013,12 +959,6 @@ THEME - Dark
border-bottom: 2px solid #2c3e51;
border-right: 2px solid #2c3e51;
}
.board-color-dark .checklist-progress-bar {
background-color: #d2dae2 !important;
}
.board-color-dark .checklist-progress-bar .checklist-progress {
background-color: #2c3e51 !important;
}
.board-color-dark .is-multiselection-active .multi-selection-checkbox.is-checked + .minicard {
background: #e6ecf1;
}
@@ -1171,7 +1111,7 @@ THEME - Dark
/* === END Dark THEME === */
/* ===============
/* ===============
THEME - Relax
=================*/
@@ -1222,12 +1162,6 @@ THEME - Relax
border-bottom: 2px solid #27ae61;
border-right: 2px solid #27ae61;
}
.board-color-relax .checklist-progress-bar {
background-color: #d4f1dd !important;
}
.board-color-relax .checklist-progress-bar .checklist-progress {
background-color: #27ae61 !important;
}
.board-color-relax .is-multiselection-active .multi-selection-checkbox.is-checked + .minicard {
background: #e7faef;
}
@@ -1307,7 +1241,7 @@ THEME - Relax
/* === END Relax THEME === */
/* ===============
/* ===============
THEME - Corteza
=================*/
@@ -1358,12 +1292,6 @@ THEME - Corteza
border-bottom: 2px solid #568ba2;
border-right: 2px solid #568ba2;
}
.board-color-corteza .checklist-progress-bar {
background-color: #dce6ec !important;
}
.board-color-corteza .checklist-progress-bar .checklist-progress {
background-color: #568ba2 !important;
}
.board-color-corteza .is-multiselection-active .multi-selection-checkbox.is-checked + .minicard {
background: #eef3f6;
}
@@ -1414,7 +1342,7 @@ THEME - Corteza
/* === END Corteza THEME === */
/* ===============
/* ===============
THEME - Clear Blue
=================*/
@@ -1469,12 +1397,6 @@ THEME - Clear Blue
border-bottom: 2px solid #499bea;
border-right: 2px solid #499bea;
}
.board-color-clearblue .checklist-progress-bar {
background-color: #daeefb !important;
}
.board-color-clearblue .checklist-progress-bar .checklist-progress {
background-color: #499bea !important;
}
.board-color-clearblue .is-multiselection-active .multi-selection-checkbox.is-checked + .minicard {
background: #e0fbff;
}
@@ -1503,7 +1425,7 @@ THEME - Clear Blue
}
.board-color-clearblue .list {
background: rgba(255,255,255,0.35);
margin: 10px 0;
margin: 10px;
border: 0;
border-radius: 14px;
}
@@ -1687,7 +1609,7 @@ THEME - Clear Blue
/* === END Clearblue THEME === */
/* ===============
/* ===============
THEME - Natural
=================*/
@@ -1738,12 +1660,6 @@ THEME - Natural
border-bottom: 2px solid #596557;
border-right: 2px solid #596557;
}
.board-color-natural .checklist-progress-bar {
background-color: #dee0dd !important;
}
.board-color-natural .checklist-progress-bar .checklist-progress {
background-color: #596557 !important;
}
.board-color-natural .is-multiselection-active .multi-selection-checkbox.is-checked + .minicard {
background: #eef0ee;
}
@@ -1803,7 +1719,7 @@ THEME - Natural
/* === END Natural THEME === */
/* ===============
/* ===============
THEME - Modern
=================*/
@@ -1854,12 +1770,6 @@ THEME - Modern
border-bottom: 2px solid #2a80b8;
border-right: 2px solid #2a80b8;
}
.board-color-modern .checklist-progress-bar {
background-color: #d1e7f5 !important;
}
.board-color-modern .checklist-progress-bar .checklist-progress {
background-color: #2a80b8 !important;
}
.board-color-modern .is-multiselection-active .multi-selection-checkbox.is-checked + .minicard {
background: #e8f3fa;
}
@@ -2096,7 +2006,7 @@ THEME - Modern
/* === END Modern THEME === */
/* ===============
/* ===============
THEME - Modern Dark
=================*/
@@ -2152,12 +2062,6 @@ THEME - Modern Dark
border-bottom: 2px solid #2a2a2a;
border-right: 2px solid #2a2a2a;
}
.board-color-moderndark .checklist-progress-bar {
background-color: #d1d1d1 !important;
}
.board-color-moderndark .checklist-progress-bar .checklist-progress {
background-color: #2a2a2a !important;
}
.board-color-moderndark .is-multiselection-active .multi-selection-checkbox.is-checked + .minicard {
background: #eaeaea;
}
@@ -2592,7 +2496,7 @@ THEME - Modern Dark
/* === END ModernDark THEME === */
/* ===============
/* ===============
THEME - Exodark
=================*/
@@ -2643,12 +2547,6 @@ THEME - Exodark
border-bottom: 2px solid #dbdbdb!important;/*Fix contrast of checkbox*/
border-right: 2px solid #dbdbdb!important;
}
.board-color-exodark .checklist-progress-bar {
background-color: #cccccc !important;
}
.board-color-exodark .checklist-progress-bar .checklist-progress {
background-color: #222 !important;
}
.board-color-exodark .is-multiselection-active .multi-selection-checkbox.is-checked + .minicard {
background: #e9e9e9;
}
@@ -2690,7 +2588,7 @@ THEME - Exodark
background: #222;
}
.board-color-exodark .list {
margin: 10px 0;
margin: 10px;
color: #fff;
border-radius: 15px;
background-color: #1c1c1c;
@@ -2846,7 +2744,7 @@ THEME - Exodark
/* === END Exodark THEME === */
/* ===============
/* ===============
THEME - Clean Dark
=================*/
@@ -3242,12 +3140,6 @@ THEME - Clean Dark
margin-left: 3px;
margin-top: 3px;
}
.board-color-cleandark .checklist-progress-bar {
background-color: #6b6b78 !important;
}
.board-color-cleandark .checklist-progress-bar .checklist-progress {
background-color: #23232B !important;
}
.board-color-cleandark .allBoards {
white-space: nowrap;
@@ -3291,7 +3183,7 @@ THEME - Clean Dark
.board-color-cleandark.background-box {
background-color:#23232B;
} /*Fixes contrast issues with background box in theme selection list*/
/* ===============
/* ===============
THEME - Clean Light
=================*/
/* Please note Clean Light theme elements also contain references to some cleandark theme elements so if unable to find code you're looking for under CleanDark it might be here. This should probably be cleaned up*/
@@ -3370,19 +3262,18 @@ THEME - Clean Light
.board-color-cleanlight .list,
.board-color-cleandark .list {
background: none !important;
border-left: none !important;
background: none;
border-left: none;
}
.board-color-cleanlight .list .list-header,
.board-color-cleandark .list .list-header {
border-bottom: none !important;
display: flex !important;
border-bottom: none;
display: flex;
justify-content: space-between;
align-items: center;
font-size: 16px;
background: none !important;
background-color: transparent !important;
background: none;
}
.board-color-cleanlight .list .list-header div:has(.list-header-name),
@@ -3431,13 +3322,7 @@ THEME - Clean Light
.board-color-cleanlight .list .list-header .list-header-menu .js-collapse,
.board-color-cleandark .list .list-header .list-header-menu .js-collapse {
/* Make collapse button visible in Clean Light / Clean Dark themes.
Previously this was hidden which caused the missing Collapse button
when using these themes. Use inline-block so it lines up with other
header controls. */
display: inline-block;
vertical-align: middle;
color: inherit;
display: none;
}
.board-color-cleanlight .list-header-add,
@@ -4001,13 +3886,6 @@ THEME - Clean Light
margin-left: 3px;
margin-top: 3px;
}
.board-color-cleanlight .checklist-progress-bar {
background-color: #f5f5f5 !important;
}
.board-color-cleanlight .checklist-progress-bar .checklist-progress {
background-color: #c0c0c0 !important;
color: #010101 !important;
}
.board-color-cleanlight .allBoards {
white-space: nowrap;
@@ -4280,14 +4158,6 @@ THEME - Clean Light
font-weight: 500;
}
.board-color-cleandark .pop-over-list li > a {
color: rgba(255, 255, 255, 1) !important;
}
.board-color-cleanlight .pop-over-list li > a {
color: rgba(10, 10, 20, 0.85) !important;
}
.board-color-cleanlight .pop-over-list li > a i,
.board-color-cleandark .pop-over-list li > a i {
margin-right: 6px !important;
+7 -923
View File
@@ -1,5 +1,5 @@
.integration-form {
padding: 0.7vh 0.7vw;
padding: 5px;
border-bottom: 1px solid #ccc;
}
.flex,
@@ -12,928 +12,12 @@
display: flex;
}
.option {
-webkit-border-radius: 0.4vw;
border-radius: 0.4vw;
-webkit-border-radius: 3px;
border-radius: 3px;
background: #fff;
text-decoration: none;
-webkit-box-shadow: 0 0.2vh 0.3vh rgba(0,0,0,0.2);
box-shadow: 0 0.2vh 0.3vh rgba(0,0,0,0.2);
margin-top: 0.7vh;
padding: 0.7vh 0.7vw;
}
/* Zoom and Mobile Mode Controls */
.board-header-btns.center {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
}
.zoom-controls {
display: flex;
align-items: center;
gap: 0.5vw;
background: rgba(255, 255, 255, 0.9);
padding: 0.5vh 1vw;
border-radius: 0.5vw;
box-shadow: 0 0.2vh 0.5vh rgba(0,0,0,0.1);
}
.zoom-controls .board-header-btn {
padding: 0.5vh 0.8vw !important;
border-radius: 0.3vw !important;
background: #fff !important;
border: 1px solid #000 !important;
transition: all 0.2s ease !important;
color: #000 !important;
height: auto !important;
line-height: normal !important;
margin: 0 !important;
float: none !important;
overflow: visible !important;
}
.zoom-controls .board-header-btn i {
color: #000 !important;
float: none !important;
display: inline !important;
line-height: normal !important;
margin: 0 !important;
}
.zoom-controls .board-header-btn:hover {
background: #000 !important;
border-color: #000 !important;
color: #fff !important;
}
.zoom-controls .board-header-btn:hover i {
color: #fff !important;
}
.zoom-controls .board-header-btn.is-active {
background: #0079bf;
color: white;
border-color: #005a8a;
}
.zoom-controls .board-header-btn.is-active i {
color: white;
}
.zoom-level {
font-weight: bold;
color: #333;
min-width: 3vw;
text-align: center;
font-size: clamp(12px, 2vw, 14px);
cursor: pointer;
padding: 0.3vh 0.5vw;
border-radius: 0.3vw;
transition: all 0.2s ease;
}
.zoom-level:hover {
background: #f0f0f0;
color: #000;
}
/* Mobile Mode Styles */
.mobile-mode .board-wrapper {
width: 100%;
height: 100%;
}
.mobile-mode .board-canvas {
height: 100%;
}
.mobile-mode .minicard {
font-size: clamp(16px, 4vw, 20px);
padding: 1.2vh 1.5vw 0.5vh;
min-height: 3vh;
}
.mobile-mode .list-header-name {
font-size: clamp(18px, 4.5vw, 24px);
}
.mobile-mode .board-header-btn {
padding: 1vh 1.5vw;
font-size: clamp(14px, 3.5vw, 18px);
}
.mobile-mode .zoom-controls {
padding: 1vh 1.5vw;
gap: 1vw;
}
.mobile-mode .zoom-controls .board-header-btn {
padding: 1vh 1.5vw !important;
font-size: clamp(14px, 3.5vw, 18px) !important;
background: #fff !important;
border: 1px solid #000 !important;
color: #000 !important;
height: auto !important;
line-height: normal !important;
margin: 0 !important;
float: none !important;
overflow: visible !important;
}
.mobile-mode .zoom-controls .board-header-btn i {
color: #000 !important;
float: none !important;
display: inline !important;
line-height: normal !important;
margin: 0 !important;
}
.mobile-mode .zoom-controls .board-header-btn:hover {
background: #000 !important;
border-color: #000 !important;
color: #fff !important;
}
.mobile-mode .zoom-controls .board-header-btn:hover i {
color: #fff !important;
}
.mobile-mode .zoom-level {
font-size: clamp(14px, 3.5vw, 18px);
min-width: 4vw;
}
/* Comprehensive Mobile Mode Styles - Works on all screen sizes */
.mobile-mode .board-wrapper {
width: 100% !important;
height: 100% !important;
transform: none !important;
transform-origin: initial !important;
max-width: 100% !important;
}
.mobile-mode .board-canvas {
height: 100% !important;
overflow-x: hidden !important;
overflow-y: auto !important;
width: 100% !important;
max-width: 100% !important;
}
.mobile-mode .swimlane {
width: 100% !important;
min-width: 100% !important;
max-width: 100% !important;
margin-bottom: 2rem !important;
display: block !important;
float: none !important;
}
.mobile-mode .swimlane-header {
width: 100% !important;
min-width: 100% !important;
max-width: 100% !important;
font-size: clamp(18px, 2.5vw, 32px) !important;
padding: 1rem !important;
margin-bottom: 1rem !important;
display: block !important;
}
.mobile-mode .list {
width: 100% !important;
min-width: 100% !important;
max-width: 100% !important;
display: block !important;
float: none !important;
margin-bottom: 2rem !important;
border-left: none !important;
border-bottom: 2px solid #ccc !important;
clear: both !important;
}
.mobile-mode .list-header {
width: 100% !important;
min-width: 100% !important;
max-width: 100% !important;
padding: 1rem !important;
font-size: clamp(18px, 2.5vw, 32px) !important;
display: grid !important;
grid-template-columns: 30px 1fr auto auto !important;
gap: 10px !important;
align-items: center !important;
position: relative !important;
}
.mobile-mode .list-header .list-header-name {
font-size: clamp(18px, 2.5vw, 32px) !important;
font-weight: bold !important;
grid-row: 1 !important;
grid-column: 2 !important;
align-self: end !important;
display: block !important;
visibility: visible !important;
opacity: 1 !important;
}
.mobile-mode .list-header .cardCount {
font-size: clamp(14px, 2vw, 24px) !important;
grid-row: 2 !important;
grid-column: 2 !important;
align-self: start !important;
display: block !important;
visibility: visible !important;
opacity: 1 !important;
}
.mobile-mode .list-header .list-header-menu-icon {
position: static !important;
right: auto !important;
top: auto !important;
transform: none !important;
grid-row: 1/3 !important;
grid-column: 3 !important;
padding: 14px !important;
font-size: clamp(24px, 3vw, 48px) !important;
text-align: center !important;
display: block !important;
visibility: visible !important;
opacity: 1 !important;
}
.mobile-mode .list-header .list-header-handle {
position: static !important;
right: auto !important;
top: auto !important;
transform: none !important;
grid-row: 1/3 !important;
grid-column: 4 !important;
padding: 14px !important;
font-size: clamp(28px, 3.5vw, 56px) !important;
text-align: center !important;
display: block !important;
visibility: visible !important;
opacity: 1 !important;
}
.mobile-mode .list-body {
width: 100% !important;
min-width: 100% !important;
max-width: 100% !important;
padding: 1rem !important;
display: block !important;
}
.mobile-mode .minicard {
width: 100% !important;
min-width: 100% !important;
max-width: 100% !important;
font-size: clamp(16px, 2vw, 24px) !important;
padding: 1.2vh 1.5vw 0.5vh !important;
min-height: 3vh !important;
margin-bottom: 0.5rem !important;
display: block !important;
float: none !important;
}
.mobile-mode .minicard .minicard-title {
font-size: clamp(16px, 2vw, 24px) !important;
font-weight: bold !important;
}
.mobile-mode .minicard .minicard-members {
font-size: clamp(12px, 1.5vw, 18px) !important;
}
.mobile-mode .minicard .minicard-lists {
font-size: clamp(12px, 1.5vw, 18px) !important;
}
/* Desktop Mode Styles */
.desktop-mode .board-wrapper {
width: auto !important;
height: auto !important;
}
.desktop-mode .swimlane {
width: auto !important;
min-width: auto !important;
}
.desktop-mode .list {
width: auto !important;
min-width: auto !important;
display: flex !important;
float: left !important;
margin-bottom: 0 !important;
border-left: 1px solid #ccc !important;
border-bottom: none !important;
}
.desktop-mode .list-header {
width: auto !important;
min-width: auto !important;
padding: 2.5vh 1.5vw 0.5vh !important;
font-size: clamp(14px, 3vw, 18px) !important;
display: block !important;
}
.desktop-mode .list-header .list-header-name {
font-size: clamp(14px, 3vw, 18px) !important;
display: inline !important;
grid-row: auto !important;
grid-column: auto !important;
align-self: auto !important;
}
.desktop-mode .list-header .cardCount {
font-size: 12px !important;
grid-row: auto !important;
grid-column: auto !important;
align-self: auto !important;
}
.desktop-mode .list-header .list-header-menu-icon {
position: absolute !important;
right: 60px !important;
top: 50% !important;
transform: translateY(-50%) !important;
grid-row: auto !important;
grid-column: auto !important;
padding: 14px !important;
font-size: 40px !important;
}
.desktop-mode .list-header .list-header-handle {
position: absolute !important;
right: 10px !important;
top: 50% !important;
transform: translateY(-50%) !important;
grid-row: auto !important;
grid-column: auto !important;
padding: 7px !important;
font-size: clamp(16px, 3vw, 20px) !important;
}
.desktop-mode .list-body {
width: auto !important;
min-width: auto !important;
padding: 5px 11px !important;
}
.desktop-mode .minicard {
width: auto !important;
min-width: auto !important;
font-size: clamp(12px, 2.5vw, 16px) !important;
padding: 0.5vh 0.8vw !important;
min-height: auto !important;
margin-bottom: 9px !important;
}
.desktop-mode .minicard .minicard-title {
font-size: clamp(12px, 2.5vw, 16px) !important;
}
.desktop-mode .minicard .minicard-members {
font-size: 10px !important;
}
.desktop-mode .minicard .minicard-lists {
font-size: 10px !important;
}
/* Additional Mobile Mode Styles for Other Elements - Works on all screen sizes */
.mobile-mode .swimlane-header .swimlane-title {
font-size: clamp(18px, 2.5vw, 32px) !important;
font-weight: bold !important;
display: block !important;
visibility: visible !important;
opacity: 1 !important;
}
.mobile-mode .swimlane-header .swimlane-description {
font-size: clamp(14px, 2vw, 24px) !important;
display: block !important;
visibility: visible !important;
opacity: 1 !important;
}
.mobile-mode .board-header {
font-size: clamp(18px, 2.5vw, 32px) !important;
padding: 1rem !important;
width: 100% !important;
max-width: 100% !important;
}
.mobile-mode .board-header .board-header-title {
font-size: clamp(18px, 2.5vw, 32px) !important;
font-weight: bold !important;
display: block !important;
visibility: visible !important;
opacity: 1 !important;
}
.mobile-mode .board-header .board-header-description {
font-size: clamp(14px, 2vw, 24px) !important;
display: block !important;
visibility: visible !important;
opacity: 1 !important;
}
.mobile-mode .board-header .board-header-btn {
font-size: clamp(14px, 2vw, 24px) !important;
padding: 1vh 1.5vw !important;
display: inline-block !important;
visibility: visible !important;
opacity: 1 !important;
}
.mobile-mode .board-header .board-header-btn i {
font-size: clamp(14px, 2vw, 24px) !important;
display: inline !important;
visibility: visible !important;
opacity: 1 !important;
}
/* Force mobile mode visibility on all screen sizes */
.mobile-mode .list-header .fa-angle-right,
.mobile-mode .list-header .fa-arrows {
display: block !important;
visibility: visible !important;
opacity: 1 !important;
position: static !important;
right: auto !important;
top: auto !important;
transform: none !important;
}
.mobile-mode .list-header .fa-angle-right {
grid-row: 1/3 !important;
grid-column: 3 !important;
padding: 14px !important;
font-size: clamp(24px, 3vw, 48px) !important;
text-align: center !important;
}
.mobile-mode .list-header .fa-arrows {
grid-row: 1/3 !important;
grid-column: 4 !important;
padding: 14px !important;
font-size: clamp(28px, 3.5vw, 56px) !important;
text-align: center !important;
}
/* Override any media queries that might hide elements in mobile mode */
.mobile-mode * {
max-width: none !important;
}
.mobile-mode .list,
.mobile-mode .swimlane,
.mobile-mode .board-wrapper,
.mobile-mode .board-canvas {
max-width: 100% !important;
width: 100% !important;
min-width: 100% !important;
}
/* Force mobile mode list styling on all screen sizes - override desktop CSS */
.mobile-mode .board-canvas {
display: block !important;
flex-direction: column !important;
flex-wrap: nowrap !important;
align-items: stretch !important;
justify-content: flex-start !important;
width: 100vw !important;
max-width: 100vw !important;
min-width: 100vw !important;
overflow-x: hidden !important;
overflow-y: auto !important;
}
.mobile-mode .swimlane {
display: block !important;
width: 100vw !important;
max-width: 100vw !important;
min-width: 100vw !important;
margin: 0 0 2rem 0 !important;
padding: 0 !important;
float: none !important;
clear: both !important;
}
.mobile-mode .swimlane .swimlane-header {
display: block !important;
width: 100vw !important;
max-width: 100vw !important;
min-width: 100vw !important;
margin: 0 0 1rem 0 !important;
padding: 1rem !important;
font-size: clamp(18px, 2.5vw, 32px) !important;
font-weight: bold !important;
border-bottom: 2px solid #ccc !important;
}
.mobile-mode .swimlane .lists {
display: block !important;
width: 100vw !important;
max-width: 100vw !important;
min-width: 100vw !important;
margin: 0 !important;
padding: 0 !important;
flex-direction: column !important;
flex-wrap: nowrap !important;
align-items: stretch !important;
justify-content: flex-start !important;
}
.mobile-mode .list {
display: block !important;
width: 100vw !important;
max-width: 100vw !important;
min-width: 100vw !important;
margin: 0 0 2rem 0 !important;
padding: 0 !important;
float: none !important;
clear: both !important;
border-left: none !important;
border-right: none !important;
border-top: none !important;
border-bottom: 2px solid #ccc !important;
flex: none !important;
flex-basis: auto !important;
flex-grow: 0 !important;
flex-shrink: 0 !important;
position: static !important;
left: auto !important;
right: auto !important;
top: auto !important;
bottom: auto !important;
transform: none !important;
}
.mobile-mode .list:first-child {
margin-left: 0 !important;
margin-top: 0 !important;
}
.mobile-mode .list:last-child {
margin-right: 0 !important;
margin-bottom: 0 !important;
}
.mobile-mode .list.ui-sortable-helper {
display: block !important;
width: 100% !important;
max-width: 100% !important;
min-width: 100% !important;
height: auto !important;
min-height: 60px !important;
margin: 0 0 2rem 0 !important;
padding: 0 !important;
float: none !important;
clear: both !important;
border-left: none !important;
border-right: none !important;
border-top: none !important;
border-bottom: 2px solid #ccc !important;
flex: none !important;
}
.mobile-mode .list.placeholder {
display: block !important;
width: 100% !important;
max-width: 100% !important;
min-width: 100% !important;
height: auto !important;
min-height: 60px !important;
margin: 0 0 2rem 0 !important;
padding: 0 !important;
float: none !important;
clear: both !important;
border-left: none !important;
border-right: none !important;
border-top: none !important;
border-bottom: 2px solid #ccc !important;
flex: none !important;
}
/* Override any existing responsive CSS that might interfere with mobile mode */
.mobile-mode .board-canvas .swimlane .lists {
display: block !important;
width: 100% !important;
max-width: 100% !important;
min-width: 100% !important;
margin: 0 !important;
padding: 0 !important;
flex-direction: column !important;
flex-wrap: nowrap !important;
align-items: stretch !important;
justify-content: flex-start !important;
overflow: visible !important;
}
.mobile-mode .board-canvas .swimlane .lists .list {
display: block !important;
width: 100% !important;
max-width: 100% !important;
min-width: 100% !important;
margin: 0 0 2rem 0 !important;
padding: 0 !important;
float: none !important;
clear: both !important;
border-left: none !important;
border-right: none !important;
border-top: none !important;
border-bottom: 2px solid #ccc !important;
flex: none !important;
flex-basis: auto !important;
flex-grow: 0 !important;
flex-shrink: 0 !important;
position: static !important;
left: auto !important;
right: auto !important;
top: auto !important;
bottom: auto !important;
transform: none !important;
}
/* Force mobile mode to override any media query styles */
@media screen and (min-width: 801px) {
.mobile-mode .board-canvas {
display: block !important;
flex-direction: column !important;
flex-wrap: nowrap !important;
align-items: stretch !important;
justify-content: flex-start !important;
width: 100vw !important;
max-width: 100vw !important;
min-width: 100vw !important;
overflow-x: hidden !important;
overflow-y: auto !important;
}
.mobile-mode .swimlane {
display: block !important;
width: 100% !important;
max-width: 100% !important;
min-width: 100% !important;
margin: 0 0 2rem 0 !important;
padding: 0 !important;
float: none !important;
clear: both !important;
}
.mobile-mode .swimlane .lists {
display: block !important;
width: 100% !important;
max-width: 100% !important;
min-width: 100% !important;
margin: 0 !important;
padding: 0 !important;
flex-direction: column !important;
flex-wrap: nowrap !important;
align-items: stretch !important;
justify-content: flex-start !important;
}
.mobile-mode .list {
display: block !important;
width: 100% !important;
max-width: 100% !important;
min-width: 100% !important;
margin: 0 0 2rem 0 !important;
padding: 0 !important;
float: none !important;
clear: both !important;
border-left: none !important;
border-right: none !important;
border-top: none !important;
border-bottom: 2px solid #ccc !important;
flex: none !important;
flex-basis: auto !important;
flex-grow: 0 !important;
flex-shrink: 0 !important;
position: static !important;
left: auto !important;
right: auto !important;
top: auto !important;
bottom: auto !important;
transform: none !important;
}
}
/* Hide desktop-only elements in mobile mode (like mobile media queries do) */
.mobile-mode .board-header-btn i.fa + span {
display: none !important;
}
.mobile-mode .board-header-btn span {
display: none !important;
}
.mobile-mode .board-header-btn .fa + span {
display: none !important;
}
.mobile-mode .board-header-btn .fa + .board-header-btn-text {
display: none !important;
}
.mobile-mode .board-header-btn .fa + .board-header-btn-label {
display: none !important;
}
/* Show only icons in mobile mode */
.mobile-mode .board-header-btn {
width: auto !important;
min-width: auto !important;
padding: 8px !important;
text-align: center !important;
}
.mobile-mode .board-header-btn i {
display: inline-block !important;
margin: 0 !important;
}
/* Hide desktop-specific elements that shouldn't show in mobile mode */
.mobile-mode .desktop-only,
.mobile-mode .board-header .desktop-only {
display: none !important;
}
.mobile-mode .board-header .board-header-btn.desktop-only {
display: none !important;
}
/* Hide desktop-specific board header buttons in mobile mode */
.mobile-mode .board-header-btns.left {
display: none !important;
}
.mobile-mode .board-header-btns.center {
display: none !important;
}
/* Show only the right section buttons in mobile mode, but hide text labels */
.mobile-mode .board-header-btns.right {
display: block !important;
}
.mobile-mode .board-header-btns.right .board-header-btn span {
display: none !important;
}
.mobile-mode .board-header-btns.right .board-header-btn .fa + span {
display: none !important;
}
.mobile-mode .board-header-btns.right .board-header-btn .fa + .board-header-btn-text {
display: none !important;
}
.mobile-mode .board-header-btns.right .board-header-btn .fa + .board-header-btn-label {
display: none !important;
}
/* Hide specific desktop-only buttons that shouldn't show in mobile mode */
.mobile-mode .board-header-btn.js-star-board span,
.mobile-mode .board-header-btn.js-change-visibility span,
.mobile-mode .board-header-btn.js-watch-board span,
.mobile-mode .board-header-btn.js-sort-cards span {
display: none !important;
}
/* Show only icons for mobile mode buttons */
.mobile-mode .board-header-btns.right .board-header-btn {
width: auto !important;
min-width: auto !important;
padding: 8px !important;
text-align: center !important;
margin: 0 2px !important;
}
.mobile-mode .board-header-btns.right .board-header-btn i {
display: inline-block !important;
margin: 0 !important;
}
/* Ensure mobile mode looks like small screen mobile view */
.mobile-mode .board-header {
height: 40px !important;
}
.mobile-mode .board-header .board-header-btns {
margin-top: 0px !important;
}
.mobile-mode .board-header .board-header-btn {
height: 32px !important;
line-height: 32px !important;
font-size: 15px !important;
}
.mobile-mode .board-header .board-header-btn i.fa {
line-height: 32px !important;
}
/* Copy mobile media query styles to mobile mode for consistent appearance */
.mobile-mode .board-header {
height: 40px !important;
padding: 0 !important;
margin: 0 !important;
}
.mobile-mode .board-header .board-header-btns {
margin-top: 0px !important;
height: 40px !important;
display: flex !important;
align-items: center !important;
justify-content: flex-end !important;
}
.mobile-mode .board-header .board-header-btn {
height: 32px !important;
line-height: 32px !important;
font-size: 15px !important;
margin: 0 2px !important;
padding: 4px 8px !important;
border-radius: 4px !important;
background: rgba(255, 255, 255, 0.1) !important;
border: 1px solid rgba(255, 255, 255, 0.2) !important;
color: #fff !important;
text-decoration: none !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
min-width: 32px !important;
width: auto !important;
}
.mobile-mode .board-header .board-header-btn:hover {
background: rgba(255, 255, 255, 0.2) !important;
border-color: rgba(255, 255, 255, 0.3) !important;
}
.mobile-mode .board-header .board-header-btn i.fa {
line-height: 32px !important;
font-size: 15px !important;
margin: 0 !important;
padding: 0 !important;
}
.mobile-mode .board-header .board-header-btn i.fa + span {
display: none !important;
}
.mobile-mode .board-header .board-header-btn span {
display: none !important;
}
/* Hide the board title in mobile mode to match mobile view */
.mobile-mode .header-board-menu {
display: none !important;
}
/* Ensure the board header takes full width in mobile mode */
.mobile-mode .board-header {
width: 100% !important;
max-width: 100% !important;
display: flex !important;
align-items: center !important;
justify-content: space-between !important;
padding: 0 10px !important;
}
/* Additional Desktop Mode Styles for Other Elements */
.desktop-mode .swimlane-header .swimlane-title {
font-size: clamp(14px, 3vw, 18px) !important;
}
.desktop-mode .swimlane-header .swimlane-description {
font-size: 12px !important;
}
.desktop-mode .board-header {
font-size: clamp(14px, 3vw, 18px) !important;
padding: 2.5vh 1.5vw 0.5vh !important;
}
.desktop-mode .board-header .board-header-title {
font-size: clamp(14px, 3vw, 18px) !important;
}
.desktop-mode .board-header .board-header-description {
font-size: 12px !important;
}
.desktop-mode .board-header .board-header-btn {
font-size: clamp(12px, 2.5vw, 16px) !important;
padding: 0.5vh 0.8vw !important;
}
.desktop-mode .board-header .board-header-btn i {
font-size: clamp(12px, 2.5vw, 16px) !important;
-webkit-box-shadow: 0 1px 2px rgba(0,0,0,0.2);
box-shadow: 0 1px 2px rgba(0,0,0,0.2);
margin-top: 5px;
padding: 5px;
}
+163 -281
View File
@@ -1,159 +1,155 @@
template(name="boardHeaderBar")
unless notDisplayThisBoard
h1.header-board-menu
with currentBoard
if $eq title 'Templates'
| {{_ 'templates'}}
else
+viewer
= title
h1.header-board-menu
with currentBoard
if $eq title 'Templates'
| {{_ 'templates'}}
else
+viewer
= title
.board-header-btns.left
unless isMiniScreen
if currentBoard
if currentUser
with currentBoard
if currentUser.isBoardAdmin
a.board-header-btn(class="{{#if currentUser.isBoardAdmin}}js-edit-board-title{{else}}is-disabled{{/if}}" title="{{_ 'edit'}}" value=title)
i.fa.fa-pencil-square-o
a.board-header-btn(
class="{{#if currentUser.isBoardAdmin}}js-change-visibility{{else}}is-disabled{{/if}}"
title="{{_ currentBoard.permission}}")
i.fa(class="{{#if currentBoard.isPublic}}fa-globe{{else}}fa-lock{{/if}}")
span {{_ currentBoard.permission}}
a.board-header-btn.js-watch-board(
title="{{_ watchLevel }}")
if $eq watchLevel "watching"
i.fa.fa-eye
if $eq watchLevel "tracking"
i.fa.fa-bell
if $eq watchLevel "muted"
i.fa.fa-bell-slash
span {{_ watchLevel}}
a.board-header-btn.js-star-board(class="{{#if isStarred}}is-active{{/if}}"
title="{{#if isStarred}}{{_ 'click-to-unstar'}}{{else}}{{_ 'click-to-star'}}{{/if}} {{_ 'starred-boards-description'}}")
i.fa(class="fa-star{{#unless isStarred}}-o{{/unless}}")
if showStarCounter
span.board-star-counter {{currentBoard.stars}}
a.board-header-btn(title="{{_ 'sort-cards'}}" class="{{#if isSortActive }}emphasis{{else}} js-sort-cards {{/if}}")
i.fa.fa-sort
span {{#if isSortActive }}{{_ 'sort-is-on'}}{{else}}{{_ 'sort-cards'}}{{/if}}
if isSortActive
a.board-header-btn-close.js-sort-reset(title="{{_ 'remove-sort'}}")
i.fa.fa-times-thin
else
a.board-header-btn.js-log-in(
title="{{_ 'log-in'}}")
i.fa.fa-sign-in
span {{_ 'log-in'}}
.board-header-btns.center
.board-header-btns.right
.board-header-btns.left
unless isMiniScreen
if currentBoard
if isMiniScreen
if currentUser
with currentBoard
if currentUser
with currentBoard
if currentUser.isBoardAdmin
a.board-header-btn(class="{{#if currentUser.isBoardAdmin}}js-edit-board-title{{else}}is-disabled{{/if}}" title="{{_ 'edit'}}" value=title)
i.fa.fa-pencil-square-o
a.board-header-btn(
class="{{#if currentUser.isBoardAdmin}}js-change-visibility{{else}}is-disabled{{/if}}"
title="{{_ currentBoard.permission}}")
i.fa(class="{{#if currentBoard.isPublic}}fa-globe{{else}}fa-lock{{/if}}")
a.board-header-btn.js-star-board(class="{{#if isStarred}}is-active{{/if}}"
title="{{#if isStarred}}{{_ 'click-to-unstar'}}{{else}}{{_ 'click-to-star'}}{{/if}} {{_ 'starred-boards-description'}}")
i.fa(class="fa-star{{#unless isStarred}}-o{{/unless}}")
if showStarCounter
span
= currentBoard.stars
a.board-header-btn.js-watch-board(
title="{{_ watchLevel }}")
if $eq watchLevel "watching"
i.fa.fa-eye
if $eq watchLevel "tracking"
i.fa.fa-bell
if $eq watchLevel "muted"
i.fa.fa-bell-slash
a.board-header-btn.js-star-board(class="{{#if isStarred}}is-active{{/if}}"
title="{{#if isStarred}}{{_ 'click-to-unstar'}}{{else}}{{_ 'click-to-star'}}{{/if}} {{_ 'starred-boards-description'}}")
i.fa(class="fa-star{{#unless isStarred}}-o{{/unless}}")
a.board-header-btn(title="{{_ 'sort-cards'}}" class="{{#if isSortActive }}emphasis{{else}} js-sort-cards {{/if}}")
i.fa.fa-sort
span {{#if isSortActive }}{{_ 'sort-is-on'}}{{else}}{{_ 'sort-cards'}}{{/if}}
if isSortActive
a.board-header-btn-close.js-sort-reset(title="{{_ 'remove-sort'}}")
i.fa.fa-times-thin
a.board-header-btn(
class="{{#if currentUser.isBoardAdmin}}js-change-visibility{{else}}is-disabled{{/if}}"
title="{{_ currentBoard.permission}}")
i.fa(class="{{#if currentBoard.isPublic}}fa-globe{{else}}fa-lock{{/if}}")
span {{_ currentBoard.permission}}
else
a.board-header-btn.js-log-in(
title="{{_ 'log-in'}}")
i.fa.fa-sign-in
if isSandstorm
if currentUser
a.board-header-btn.js-open-archived-board
i.fa.fa-archive
//if showSort
//
a.board-header-btn.js-open-sort-view(title="{{_ 'sort-desc'}}")
//
i.fa(class="{{directionClass}}")
//
span {{_ 'sort'}}{{_ listSortShortDesc}}
a.board-header-btn.js-open-filter-view(
title="{{#if Filter.isActive}}{{_ 'filter-on-desc'}}{{else}}{{_ 'filter'}}{{/if}}"
class="{{#if Filter.isActive}}js-filter-active{{/if}}")
i.fa.fa-filter
span {{#if Filter.isActive}}{{_ 'filter-on-desc'}}{{else}}{{_ 'filter'}}{{/if}}
if Filter.isActive
a.board-header-btn-close.js-filter-reset(title="{{_ 'filter-clear'}}")
i.fa.fa-times-thin
a.board-header-btn.js-open-search-view(title="{{_ 'search'}}")
i.fa.fa-search
span {{_ 'search'}}
unless currentBoard.isTemplatesBoard
a.board-header-btn.js-toggle-board-view
i.fa.fa-caret-down
if $eq boardView 'board-view-swimlanes'
i.fa.fa-th-large
if $eq boardView 'board-view-lists'
i.fa.fa-trello
if $eq boardView 'board-view-cal'
i.fa.fa-calendar
if $eq boardView 'board-view-gantt'
i.fa.fa-bar-chart
span
if $eq boardView 'board-view-swimlanes'
| {{_ 'swimlanes'}}
if $eq boardView 'board-view-lists'
| {{_ 'lists'}}
if $eq boardView 'board-view-cal'
| {{_ 'calendar'}}
if $eq boardView 'board-view-gantt'
| {{_ 'gantt'}}
if canModifyBoard
a.board-header-btn.js-multiselection-activate(
title="{{#if MultiSelection.isActive}}{{_ 'multi-selection-on'}}{{else}}{{_ 'multi-selection'}}{{/if}}"
class="{{#if MultiSelection.isActive}}emphasis{{/if}}")
i.fa.fa-check-square-o
span {{#if MultiSelection.isActive}}{{_ 'multi-selection-on'}}{{else}}{{_ 'multi-selection'}}{{/if}}
if MultiSelection.isActive
a.board-header-btn-close.js-multiselection-reset(title="{{_ 'filter-clear'}}")
a.board-header-btn.js-watch-board(
title="{{_ watchLevel }}")
if $eq watchLevel "watching"
i.fa.fa-eye
if $eq watchLevel "tracking"
i.fa.fa-bell
if $eq watchLevel "muted"
i.fa.fa-bell-slash
span {{_ watchLevel}}
a.board-header-btn(title="{{_ 'sort-cards'}}" class="{{#if isSortActive }}emphasis{{else}} js-sort-cards {{/if}}")
i.fa.fa-sort
span {{#if isSortActive }}{{_ 'sort-is-on'}}{{else}}{{_ 'sort-cards'}}{{/if}}
if isSortActive
a.board-header-btn-close.js-sort-reset(title="{{_ 'remove-sort'}}")
i.fa.fa-times-thin
.separator
a.board-header-btn.js-toggle-sidebar(title="{{_ 'sidebar-open'}} {{_ 'or'}} {{_ 'sidebar-close'}}")
i.fa.fa-bars
else
a.board-header-btn.js-log-in(
title="{{_ 'log-in'}}")
i.fa.fa-sign-in
span {{_ 'log-in'}}
.board-header-btns.right
if currentBoard
if isMiniScreen
if currentUser
with currentBoard
a.board-header-btn(class="{{#if currentUser.isBoardAdmin}}js-edit-board-title{{else}}is-disabled{{/if}}" title="{{_ 'edit'}}" value=title)
i.fa.fa-pencil-square-o
a.board-header-btn.js-star-board(class="{{#if isStarred}}is-active{{/if}}"
title="{{#if isStarred}}{{_ 'click-to-unstar'}}{{else}}{{_ 'click-to-star'}}{{/if}} {{_ 'starred-boards-description'}}")
i.fa(class="fa-star{{#unless isStarred}}-o{{/unless}}")
if showStarCounter
span
= currentBoard.stars
a.board-header-btn(
class="{{#if currentUser.isBoardAdmin}}js-change-visibility{{else}}is-disabled{{/if}}"
title="{{_ currentBoard.permission}}")
i.fa(class="{{#if currentBoard.isPublic}}fa-globe{{else}}fa-lock{{/if}}")
span {{_ currentBoard.permission}}
a.board-header-btn.js-watch-board(
title="{{_ watchLevel }}")
if $eq watchLevel "watching"
i.fa.fa-eye
if $eq watchLevel "tracking"
i.fa.fa-bell
if $eq watchLevel "muted"
i.fa.fa-bell-slash
span {{_ watchLevel}}
a.board-header-btn(title="{{_ 'sort-cards'}}" class="{{#if isSortActive }}emphasis{{else}} js-sort-cards {{/if}}")
i.fa.fa-sort
span {{#if isSortActive }}{{_ 'sort-is-on'}}{{else}}{{_ 'sort-cards'}}{{/if}}
if isSortActive
a.board-header-btn-close.js-sort-reset(title="{{_ 'remove-sort'}}")
i.fa.fa-times-thin
else
a.board-header-btn.js-log-in(
title="{{_ 'log-in'}}")
i.fa.fa-sign-in
span {{_ 'log-in'}}
if isSandstorm
if currentUser
a.board-header-btn.js-open-archived-board
i.fa.fa-archive
span {{_ 'archives'}}
//if showSort
// a.board-header-btn.js-open-sort-view(title="{{_ 'sort-desc'}}")
// i.fa(class="{{directionClass}}")
// span {{_ 'sort'}}{{_ listSortShortDesc}}
a.board-header-btn.js-open-filter-view(
title="{{#if Filter.isActive}}{{_ 'filter-on-desc'}}{{else}}{{_ 'filter'}}{{/if}}"
class="{{#if Filter.isActive}}emphasis{{/if}}")
i.fa.fa-filter
span {{#if Filter.isActive}}{{_ 'filter-on'}}{{else}}{{_ 'filter'}}{{/if}}
if Filter.isActive
a.board-header-btn-close.js-filter-reset(title="{{_ 'filter-clear'}}")
i.fa.fa-times-thin
a.board-header-btn.js-open-search-view(title="{{_ 'search'}}")
i.fa.fa-search
span {{_ 'search'}}
unless currentBoard.isTemplatesBoard
a.board-header-btn.js-toggle-board-view(
title="{{_ 'board-view'}}")
i.fa.fa-caret-down
if $eq boardView 'board-view-swimlanes'
i.fa.fa-th-large
if $eq boardView 'board-view-lists'
i.fa.fa-trello
if $eq boardView 'board-view-cal'
i.fa.fa-calendar
span {{#if boardView}}{{_ boardView}}{{else}}{{_ 'board-view-swimlanes'}}{{/if}}
if canModifyBoard
a.board-header-btn.js-multiselection-activate(
title="{{#if MultiSelection.isActive}}{{_ 'multi-selection-on'}}{{else}}{{_ 'multi-selection'}}{{/if}}"
class="{{#if MultiSelection.isActive}}emphasis{{/if}}")
i.fa.fa-check-square-o
span {{#if MultiSelection.isActive}}{{_ 'multi-selection-on'}}{{else}}{{_ 'multi-selection'}}{{/if}}
if MultiSelection.isActive
a.board-header-btn-close.js-multiselection-reset(title="{{_ 'filter-clear'}}")
i.fa.fa-times-thin
.separator
a.board-header-btn.js-toggle-sidebar(title="{{_ 'sidebar-open'}} {{_ 'or'}} {{_ 'sidebar-close'}}")
i.fa.fa-navicon
template(name="boardVisibilityList")
ul.pop-over-list
li
with "private"
a.js-select-visibility
i.fa.fa-lock
i.fa.fa-lock.colorful
| {{_ 'private'}}
if visibilityCheck
i.fa.fa-check
@@ -162,7 +158,7 @@ template(name="boardVisibilityList")
li
with "public"
a.js-select-visibility
i.fa.fa-globe
i.fa.fa-globe.colorful
| {{_ 'public'}}
if visibilityCheck
i.fa.fa-check
@@ -176,7 +172,7 @@ template(name="boardChangeWatchPopup")
li
with "watching"
a.js-select-watch
i.fa.fa-eye
i.fa.fa-eye.colorful
| {{_ 'watching'}}
if watchCheck
i.fa.fa-check
@@ -184,7 +180,7 @@ template(name="boardChangeWatchPopup")
li
with "tracking"
a.js-select-watch
i.fa.fa-bell
i.fa.fa-bell.colorful
| {{_ 'tracking'}}
if watchCheck
i.fa.fa-check
@@ -192,7 +188,7 @@ template(name="boardChangeWatchPopup")
li
with "muted"
a.js-select-watch
i.fa.fa-bell-slash
i.fa.fa-bell-slash.colorful
| {{_ 'muted'}}
if watchCheck
i.fa.fa-check
@@ -203,130 +199,35 @@ template(name="boardChangeViewPopup")
li
with "board-view-swimlanes"
a.js-open-swimlanes-view
i.fa.fa-th-large
| {{_ 'swimlanes'}}
i.fa.fa-th-large.colorful
| {{_ 'board-view-swimlanes'}}
if $eq Utils.boardView "board-view-swimlanes"
i.fa.fa-check
li
with "board-view-lists"
a.js-open-lists-view
i.fa.fa-trello
i.fa.fa-trello.colorful
| {{_ 'board-view-lists'}}
if $eq Utils.boardView "board-view-lists"
i.fa.fa-check
li
with "board-view-cal"
a.js-open-cal-view
i.fa.fa-calendar
i.fa.fa-calendar.colorful
| {{_ 'board-view-cal'}}
if $eq Utils.boardView "board-view-cal"
i.fa.fa-check
li
with "board-view-gantt"
a.js-open-gantt-view
i.fa.fa-bar-chart
| {{_ 'board-view-gantt'}}
if $eq Utils.boardView "board-view-gantt"
i.fa.fa-check
template(name="createBoard")
form
label
| {{_ 'title'}}
input.js-new-board-title(type="text" placeholder="{{_ 'bucket-example'}}" autofocus required)
if visibilityMenuIsOpen
if visibilityMenuIsOpen.get
+boardVisibilityList
else
p.quiet
if $eq visibility 'public'
span.fa.fa-globe.colorful
= " "
| {{{_ 'board-public-info'}}}
else
span.fa.fa-lock.colorful
= " "
| {{{_ 'board-private-info'}}}
a.js-change-visibility {{_ 'change'}}.
a.flex.js-toggle-add-template-container
.materialCheckBox#add-template-container
span {{_ 'add-template-container'}}
input.primary.wide(type="submit" value="{{_ 'create'}}")
span.quiet
| {{_ 'or'}}
a.js-import-board {{_ 'import'}}
span.quiet
| /
a.js-board-template {{_ 'template'}}
template(name="createBoardPopup")
form
label
| {{_ 'title'}}
input.js-new-board-title(type="text" placeholder="{{_ 'bucket-example'}}" autofocus required)
if visibilityMenuIsOpen
+boardVisibilityList
else
p.quiet
if $eq visibility 'public'
span.fa.fa-globe.colorful
= " "
| {{{_ 'board-public-info'}}}
else
span.fa.fa-lock.colorful
= " "
| {{{_ 'board-private-info'}}}
a.js-change-visibility {{_ 'change'}}.
a.flex.js-toggle-add-template-container
.materialCheckBox#add-template-container
span {{_ 'add-template-container'}}
input.primary.wide(type="submit" value="{{_ 'create'}}")
span.quiet
| {{_ 'or'}}
a.js-import-board {{_ 'import'}}
span.quiet
| /
a.js-board-template {{_ 'template'}}
template(name="headerBarCreateBoardPopup")
form
label
| {{_ 'title'}}
input.js-new-board-title(type="text" placeholder="{{_ 'bucket-example'}}" autofocus required)
if visibilityMenuIsOpen
+boardVisibilityList
else
p.quiet
if $eq visibility 'public'
span.fa.fa-globe.colorful
= " "
| {{{_ 'board-public-info'}}}
else
span.fa.fa-lock.colorful
= " "
| {{{_ 'board-private-info'}}}
a.js-change-visibility {{_ 'change'}}.
a.flex.js-toggle-add-template-container
.materialCheckBox#add-template-container
span {{_ 'add-template-container'}}
input.primary.wide(type="submit" value="{{_ 'create'}}")
span.quiet
| {{_ 'or'}}
a.js-import-board {{_ 'import'}}
span.quiet
| /
a.js-board-template {{_ 'template'}}
// New popup for Template Container creation; shares the same form content
template(name="createTemplateContainerPopup")
form
label
| {{_ 'title'}}
input.js-new-board-title(type="text" placeholder="{{_ 'bucket-example'}}" autofocus required)
if visibilityMenuIsOpen
+boardVisibilityList
else
p.quiet
if $eq visibility 'public'
if $eq visibility.get 'public'
span.fa.fa-globe.colorful
= " "
| {{{_ 'board-public-info'}}}
@@ -347,30 +248,19 @@ template(name="createTemplateContainerPopup")
a.js-board-template {{_ 'template'}}
//template(name="listsortPopup")
//
h2
//
| {{_ 'list-sort-by'}}
//
hr
//
ul.pop-over-list
//
each value in allowedSortValues
//
li
//
a.js-sort-by(name="{{value.name}}")
//
if $eq sortby value.name
//
| {{#if $eq Direction "fa-arrow-up"}}⬆️{{else}}⬇️{{/if}}
//
| {{_ value.label }}{{_ value.shortLabel}}
//
if $eq sortby value.name
//
i.fa.fa-check
// h2
// | {{_ 'list-sort-by'}}
// hr
// ul.pop-over-list
// each value in allowedSortValues
// li
// a.js-sort-by(name="{{value.name}}")
// if $eq sortby value.name
// i(class="fa {{Direction}}")
// | {{_ value.label }}{{_ value.shortLabel}}
// if $eq sortby value.name
// i(class="fa fa-check")
template(name="boardChangeTitlePopup")
form
label
@@ -389,22 +279,14 @@ template(name="boardCreateRulePopup")
template(name="cardsSortPopup")
ul.pop-over-list
li
a.js-sort-due
i.fa.fa-calendar
| {{_ 'due-date'}}
a.js-sort-due {{_ 'due-date'}}
hr
li
a.js-sort-title
i.fa.fa-sort-alpha-asc
| {{_ 'title-alphabetically'}}
a.js-sort-title {{_ 'title-alphabetically'}}
hr
li
a.js-sort-created-desc
i.fa.fa-arrow-down
| {{_ 'created-at-newest-first'}}
a.js-sort-created-desc {{_ 'created-at-newest-first'}}
hr
li
a.js-sort-created-asc
i.fa.fa-arrow-up
| {{_ 'created-at-oldest-first'}}
a.js-sort-created-asc {{_ 'created-at-oldest-first'}}
+285 -386
View File
@@ -1,15 +1,6 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { TAPi18n } from '/imports/i18n';
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
import dragscroll from '@wekanteam/dragscroll';
import getSlug from 'limax';
import Boards from '/models/boards';
import Swimlanes from '/models/swimlanes';
import TableVisibilityModeSettings from '/models/tableVisibilityModeSettings';
import { Filter } from '/client/lib/filter';
import { MultiSelection } from '/client/lib/multiSelection';
import { getSidebarInstance } from '/client/features/sidebar/service';
import { Utils } from '/client/lib/utils';
/*
const DOWNCLS = 'fa-sort-down';
@@ -18,8 +9,7 @@ const UPCLS = 'fa-sort-up';
const sortCardsBy = new ReactiveVar('');
Template.boardChangeTitlePopup.events({
async submit(event, templateInstance) {
event.preventDefault();
submit(event, templateInstance) {
const newTitle = templateInstance
.$('.js-board-name')
.val()
@@ -29,32 +19,15 @@ Template.boardChangeTitlePopup.events({
.val()
.trim();
if (newTitle) {
const board = Utils.getCurrentBoard();
if (board) {
await board.rename(newTitle);
await board.setDescription(newDesc);
}
this.rename(newTitle);
this.setDescription(newDesc);
Popup.back();
}
event.preventDefault();
},
});
Template.boardHeaderBar.onCreated(function () {
Meteor.subscribe('tableVisibilityModeSettings');
});
Template.boardHeaderBar.helpers({
notDisplayThisBoard() {
const allowPrivateVisibilityOnly = TableVisibilityModeSettings.findOne('tableVisibilityMode-allowPrivateOnly');
const currentBoard = Utils.getCurrentBoard();
return (
allowPrivateVisibilityOnly !== undefined &&
allowPrivateVisibilityOnly.booleanValue &&
currentBoard &&
currentBoard.permission === 'public'
);
},
BlazeComponent.extendComponent({
watchLevel() {
const currentBoard = Utils.getCurrentBoard();
return currentBoard && currentBoard.getWatchLevel(Meteor.userId());
@@ -71,130 +44,97 @@ Template.boardHeaderBar.helpers({
const currentBoard = Utils.getCurrentBoard();
return currentBoard && currentBoard.stars >= 2;
},
/*
showSort() {
return ReactiveCache.getCurrentUser().hasSortBy();
},
directionClass() {
return this.currentDirection() === -1 ? DOWNCLS : UPCLS;
},
changeDirection() {
const direction = 0 - this.currentDirection() === -1 ? '-' : '';
Meteor.call('setListSortBy', direction + this.currentListSortBy());
},
currentDirection() {
return ReactiveCache.getCurrentUser().getListSortByDirection();
},
currentListSortBy() {
return ReactiveCache.getCurrentUser().getListSortBy();
},
listSortShortDesc() {
return `list-label-short-${this.currentListSortBy()}`;
},
*/
events() {
return [
{
'click .js-edit-board-title': Popup.open('boardChangeTitle'),
'click .js-star-board'() {
ReactiveCache.getCurrentUser().toggleBoardStar(Session.get('currentBoard'));
},
'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'() {
Modal.open('archivedBoards');
},
'click .js-toggle-board-view': Popup.open('boardChangeView'),
'click .js-toggle-sidebar'() {
Sidebar.toggle();
},
'click .js-open-filter-view'() {
Sidebar.setView('filter');
},
'click .js-sort-cards': Popup.open('cardsSort'),
/*
'click .js-open-sort-view'(evt) {
const target = evt.target;
if (target.tagName === 'I') {
// click on the text, popup choices
this.changeDirection();
} else {
// change the sort order
Popup.open('listsort')(evt);
}
},
*/
'click .js-filter-reset'(event) {
event.stopPropagation();
Sidebar.setView();
Filter.reset();
},
'click .js-sort-reset'() {
Session.set('sortBy', '');
},
'click .js-open-search-view'() {
Sidebar.setView('search');
},
'click .js-multiselection-activate'() {
const currentCard = Utils.getCurrentCardId();
MultiSelection.activate();
if (currentCard) {
MultiSelection.add(currentCard);
}
},
'click .js-multiselection-reset'(event) {
event.stopPropagation();
MultiSelection.disable();
},
'click .js-log-in'() {
FlowRouter.go('atSignIn');
},
},
];
},
}).register('boardHeaderBar');
Template.boardHeaderBar.helpers({
boardView() {
return Utils.boardView();
},
isSortActive() {
return Session.get('sortBy') ? true : false;
},
sortCardsIcon() {
const sortBy = Session.get('sortBy');
if (!sortBy) {
return '🃏'; // Card icon when nothing is selected
}
// Determine which sort option is active based on sortBy object
if (sortBy.dueAt) {
return '📅'; // Due date icon
} else if (sortBy.title) {
return '🔤'; // Alphabet icon
} else if (sortBy.createdAt) {
return sortBy.createdAt === 1 ? '⬆️' : '⬇️'; // Up/down arrow based on direction
}
return '🃏'; // Default card icon
},
});
Template.boardHeaderBar.events({
'click .js-edit-board-title': Popup.open('boardChangeTitle'),
'click .js-star-board'() {
const boardId = Session.get('currentBoard');
if (boardId) {
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'() {
Modal.open('archivedBoards');
},
'click .js-toggle-board-view': Popup.open('boardChangeView'),
'click .js-toggle-sidebar'() {
if (process.env.DEBUG === 'true') {
console.log('Hamburger menu clicked');
}
// Use the same approach as keyboard shortcuts
const sidebar = getSidebarInstance();
if (sidebar && typeof sidebar.toggle === 'function') {
if (process.env.DEBUG === 'true') {
console.log('Using Sidebar.toggle()');
}
sidebar.toggle();
} else {
if (process.env.DEBUG === 'true') {
console.warn('Sidebar not available, trying alternative approach');
}
// Try to trigger the sidebar through the global Blaze helper
if (typeof Blaze !== 'undefined' && Blaze._globalHelpers && Blaze._globalHelpers.Sidebar) {
const blazeSidebar = Blaze._globalHelpers.Sidebar();
if (blazeSidebar && typeof blazeSidebar.toggle === 'function') {
if (process.env.DEBUG === 'true') {
console.log('Using Blaze helper Sidebar.toggle()');
}
blazeSidebar.toggle();
}
}
}
},
'click .js-open-filter-view'() {
const sidebar = getSidebarInstance();
if (sidebar) {
sidebar.setView('filter');
} else {
console.warn('Sidebar not available for setView');
}
},
'click .js-sort-cards': Popup.open('cardsSort'),
/*
'click .js-open-sort-view'(evt) {
const target = evt.target;
if (target.tagName === 'I') {
// click on the text, popup choices
this.changeDirection();
} else {
// change the sort order
Popup.open('listsort')(evt);
}
},
*/
'click .js-filter-reset'(event) {
event.stopPropagation();
const sidebar = getSidebarInstance();
if (sidebar) {
sidebar.setView();
} else {
console.warn('Sidebar not available for setView');
}
Filter.reset();
},
'click .js-sort-reset'() {
Session.set('sortBy', '');
},
'click .js-open-search-view'() {
const sidebar = getSidebarInstance();
if (sidebar) {
sidebar.setView('search');
} else {
console.warn('Sidebar not available for setView');
}
},
'click .js-multiselection-activate'() {
const currentCard = Utils.getCurrentCardId();
MultiSelection.activate();
if (currentCard) {
MultiSelection.add(currentCard);
}
},
'click .js-multiselection-reset'(event) {
event.stopPropagation();
MultiSelection.disable();
},
'click .js-log-in'() {
FlowRouter.go('atSignIn');
},
});
Template.boardChangeViewPopup.events({
@@ -210,241 +150,194 @@ Template.boardChangeViewPopup.events({
Utils.setBoardView('board-view-cal');
Popup.back();
},
'click .js-open-gantt-view'() {
Utils.setBoardView('board-view-gantt');
Popup.back();
});
const CreateBoard = BlazeComponent.extendComponent({
template() {
return 'createBoard';
},
});
// Shared setup for all create board popups
function setupCreateBoardState(tpl) {
tpl.visibilityMenuIsOpen = new ReactiveVar(false);
tpl.visibility = new ReactiveVar('private');
tpl.boardId = new ReactiveVar('');
Meteor.subscribe('tableVisibilityModeSettings');
}
function createBoardHelpers() {
return {
visibilityMenuIsOpen() {
return Template.instance().visibilityMenuIsOpen.get();
},
visibility() {
return Template.instance().visibility.get();
},
notAllowPrivateVisibilityOnly() {
return !TableVisibilityModeSettings.findOne('tableVisibilityMode-allowPrivateOnly')?.booleanValue;
},
visibilityCheck() {
return Template.currentData() === Template.instance().visibility.get();
},
};
}
async function createBoardSubmit(tpl, event) {
event.preventDefault();
const title = tpl.find('.js-new-board-title').value;
const slug = getSlug(title) || 'board';
const addTemplateContainer = $('#add-template-container.is-checked').length > 0;
if (addTemplateContainer) {
tpl.boardId.set(
await Meteor.callAsync('createBoardWithInitialSwimlanes', {
title,
slug,
permission: 'private',
type: 'template-container',
migrationVersion: 1,
swimlanes: [
{ title: 'Card Templates', sort: 1, type: 'template-container' },
{ title: 'List Templates', sort: 2, type: 'template-container' },
{ title: 'Board Templates', sort: 3, type: 'template-container' },
],
}),
);
// Assign to space if one was selected
const spaceId = Session.get('createBoardInWorkspace');
if (spaceId) {
Meteor.call('assignBoardToWorkspace', tpl.boardId.get(), spaceId, (err) => {
if (err) console.error('Error assigning board to space:', err);
});
Session.set('createBoardInWorkspace', null); // Clear after use
}
FlowRouter.go('board', { id: tpl.boardId.get(), slug });
} else {
const visibility = tpl.visibility.get();
tpl.boardId.set(
await Meteor.callAsync('createBoardWithInitialSwimlanes', {
title,
slug,
permission: visibility,
migrationVersion: 1,
swimlanes: [{ title: 'Default' }],
}),
);
// Assign to space if one was selected
const spaceId = Session.get('createBoardInWorkspace');
if (spaceId) {
Meteor.call('assignBoardToWorkspace', tpl.boardId.get(), spaceId, (err) => {
if (err) console.error('Error assigning board to space:', err);
});
Session.set('createBoardInWorkspace', null); // Clear after use
}
FlowRouter.go('board', { id: tpl.boardId.get(), slug });
}
}
function createBoardEvents() {
return {
'click .js-select-visibility'(event, tpl) {
tpl.visibility.set(this);
tpl.visibilityMenuIsOpen.set(false);
},
'click .js-change-visibility'(event, tpl) {
tpl.visibilityMenuIsOpen.set(!tpl.visibilityMenuIsOpen.get());
},
'click .js-import': Popup.open('boardImportBoard'),
async 'submit'(event, tpl) {
await createBoardSubmit(tpl, event);
},
'click .js-import-board': Popup.open('chooseBoardSource'),
'click .js-board-template': Popup.open('searchElement'),
'click .js-toggle-add-template-container'() {
$('#add-template-container').toggleClass('is-checked');
},
};
}
// createBoard (non-popup version)
Template.createBoard.onCreated(function () {
setupCreateBoardState(this);
});
Template.createBoard.helpers(createBoardHelpers());
Template.createBoard.events(createBoardEvents());
// createBoardPopup
Template.createBoardPopup.onCreated(function () {
setupCreateBoardState(this);
});
Template.createBoardPopup.helpers(createBoardHelpers());
Template.createBoardPopup.events(createBoardEvents());
// createTemplateContainerPopup
Template.createTemplateContainerPopup.onCreated(function () {
setupCreateBoardState(this);
});
Template.createTemplateContainerPopup.onRendered(function () {
// Always pre-check the template container checkbox for this popup
$('#add-template-container').addClass('is-checked');
});
Template.createTemplateContainerPopup.helpers(createBoardHelpers());
Template.createTemplateContainerPopup.events(createBoardEvents());
// headerBarCreateBoardPopup
Template.headerBarCreateBoardPopup.onCreated(function () {
setupCreateBoardState(this);
});
Template.headerBarCreateBoardPopup.helpers(createBoardHelpers());
Template.headerBarCreateBoardPopup.events({
'click .js-select-visibility'(event, tpl) {
tpl.visibility.set(this);
tpl.visibilityMenuIsOpen.set(false);
onCreated() {
this.visibilityMenuIsOpen = new ReactiveVar(false);
this.visibility = new ReactiveVar('private');
this.boardId = new ReactiveVar('');
Meteor.subscribe('tableVisibilityModeSettings');
},
'click .js-change-visibility'(event, tpl) {
tpl.visibilityMenuIsOpen.set(!tpl.visibilityMenuIsOpen.get());
notAllowPrivateVisibilityOnly(){
return !TableVisibilityModeSettings.findOne('tableVisibilityMode-allowPrivateOnly').booleanValue;
},
'click .js-import': Popup.open('boardImportBoard'),
async submit(event, tpl) {
await createBoardSubmit(tpl, event);
// Immediately star boards created with the headerbar popup.
await ReactiveCache.getCurrentUser().toggleBoardStar(tpl.boardId.get());
visibilityCheck() {
return this.currentData() === this.visibility.get();
},
'click .js-import-board': Popup.open('chooseBoardSource'),
'click .js-board-template': Popup.open('searchElement'),
'click .js-toggle-add-template-container'() {
setVisibility(visibility) {
this.visibility.set(visibility);
this.visibilityMenuIsOpen.set(false);
},
toggleVisibilityMenu() {
this.visibilityMenuIsOpen.set(!this.visibilityMenuIsOpen.get());
},
toggleAddTemplateContainer() {
$('#add-template-container').toggleClass('is-checked');
},
});
Template.boardVisibilityList.helpers({
notAllowPrivateVisibilityOnly() {
return !TableVisibilityModeSettings.findOne('tableVisibilityMode-allowPrivateOnly')?.booleanValue;
onSubmit(event) {
event.preventDefault();
const title = this.find('.js-new-board-title').value;
const addTemplateContainer = $('#add-template-container.is-checked').length > 0;
if (addTemplateContainer) {
//const templateContainerId = Meteor.call('setCreateTemplateContainer');
//Utils.goBoardId(templateContainerId);
//alert('niinku template ' + Meteor.call('setCreateTemplateContainer'));
this.boardId.set(
Boards.insert({
// title: TAPi18n.__('templates'),
title: title,
permission: 'private',
type: 'template-container',
}),
);
// Insert the card templates swimlane
Swimlanes.insert({
// title: TAPi18n.__('card-templates-swimlane'),
title: 'Card Templates',
boardId: this.boardId.get(),
sort: 1,
type: 'template-container',
}),
// Insert the list templates swimlane
Swimlanes.insert(
{
// title: TAPi18n.__('list-templates-swimlane'),
title: 'List Templates',
boardId: this.boardId.get(),
sort: 2,
type: 'template-container',
},
);
// Insert the board templates swimlane
Swimlanes.insert(
{
//title: TAPi18n.__('board-templates-swimlane'),
title: 'Board Templates',
boardId: this.boardId.get(),
sort: 3,
type: 'template-container',
},
);
Utils.goBoardId(this.boardId.get());
} else {
const visibility = this.visibility.get();
this.boardId.set(
Boards.insert({
title,
permission: visibility,
}),
);
Swimlanes.insert({
title: 'Default',
boardId: this.boardId.get(),
});
Utils.goBoardId(this.boardId.get());
}
},
});
Template.boardChangeVisibilityPopup.onCreated(function () {
Meteor.subscribe('tableVisibilityModeSettings');
});
events() {
return [
{
'click .js-select-visibility'() {
this.setVisibility(this.currentData());
},
'click .js-change-visibility': this.toggleVisibilityMenu,
'click .js-import': Popup.open('boardImportBoard'),
submit: this.onSubmit,
'click .js-import-board': Popup.open('chooseBoardSource'),
'click .js-board-template': Popup.open('searchElement'),
'click .js-toggle-add-template-container': this.toggleAddTemplateContainer,
},
];
},
}).register('createBoardPopup');
Template.boardChangeVisibilityPopup.helpers({
(class HeaderBarCreateBoard extends CreateBoard {
onSubmit(event) {
super.onSubmit(event);
// Immediately star boards crated with the headerbar popup.
ReactiveCache.getCurrentUser().toggleBoardStar(this.boardId.get());
}
}.register('headerBarCreateBoardPopup'));
BlazeComponent.extendComponent({
notAllowPrivateVisibilityOnly(){
return !TableVisibilityModeSettings.findOne('tableVisibilityMode-allowPrivateOnly')?.booleanValue;
return !TableVisibilityModeSettings.findOne('tableVisibilityMode-allowPrivateOnly').booleanValue;
},
visibilityCheck() {
const currentBoard = Utils.getCurrentBoard();
return this === currentBoard.permission;
return this.currentData() === currentBoard.permission;
},
});
Template.boardChangeVisibilityPopup.events({
'click .js-select-visibility'() {
selectBoardVisibility() {
const currentBoard = Utils.getCurrentBoard();
const visibility = this;
if (typeof visibility === 'string') {
currentBoard.setVisibility(visibility);
Popup.back();
}
const visibility = this.currentData();
currentBoard.setVisibility(visibility);
Popup.back();
},
});
Template.boardChangeWatchPopup.helpers({
events() {
return [
{
'click .js-select-visibility': this.selectBoardVisibility,
},
];
},
}).register('boardChangeVisibilityPopup');
BlazeComponent.extendComponent({
watchLevel() {
const currentBoard = Utils.getCurrentBoard();
return currentBoard.getWatchLevel(Meteor.userId());
},
watchCheck() {
const currentBoard = Utils.getCurrentBoard();
return this === currentBoard.getWatchLevel(Meteor.userId());
return this.currentData() === this.watchLevel();
},
});
Template.boardChangeWatchPopup.events({
'click .js-select-watch'() {
const level = this;
if (typeof level === 'string') {
Meteor.call(
'watch',
'board',
Session.get('currentBoard'),
level,
(err, ret) => {
if (!err && ret) Popup.back();
events() {
return [
{
'click .js-select-watch'() {
const level = this.currentData();
Meteor.call(
'watch',
'board',
Session.get('currentBoard'),
level,
(err, ret) => {
if (!err && ret) Popup.back();
},
);
},
);
}
},
];
},
});
}).register('boardChangeWatchPopup');
/*
// BlazeComponent.extendComponent was removed - this code is unused.
// Original listsortPopup component:
// {
BlazeComponent.extendComponent({
onCreated() {
//this.sortBy = new ReactiveVar();
////this.sortDirection = new ReactiveVar();
@@ -512,40 +405,46 @@ Template.boardChangeWatchPopup.events({
},
];
},
// }.register('listsortPopup');
}).register('listsortPopup');
*/
Template.cardsSortPopup.events({
'click .js-sort-due'() {
const sortBy = {
dueAt: 1,
};
Session.set('sortBy', sortBy);
sortCardsBy.set(TAPi18n.__('due-date'));
Popup.back();
BlazeComponent.extendComponent({
events() {
return [
{
'click .js-sort-due'() {
const sortBy = {
dueAt: 1,
};
Session.set('sortBy', sortBy);
sortCardsBy.set(TAPi18n.__('due-date'));
Popup.back();
},
'click .js-sort-title'() {
const sortBy = {
title: 1,
};
Session.set('sortBy', sortBy);
sortCardsBy.set(TAPi18n.__('title'));
Popup.back();
},
'click .js-sort-created-asc'() {
const sortBy = {
createdAt: 1,
};
Session.set('sortBy', sortBy);
sortCardsBy.set(TAPi18n.__('date-created-newest-first'));
Popup.back();
},
'click .js-sort-created-desc'() {
const sortBy = {
createdAt: -1,
};
Session.set('sortBy', sortBy);
sortCardsBy.set(TAPi18n.__('date-created-oldest-first'));
Popup.back();
},
},
];
},
'click .js-sort-title'() {
const sortBy = {
title: 1,
};
Session.set('sortBy', sortBy);
sortCardsBy.set(TAPi18n.__('title'));
Popup.back();
},
'click .js-sort-created-asc'() {
const sortBy = {
createdAt: 1,
};
Session.set('sortBy', sortBy);
sortCardsBy.set(TAPi18n.__('date-created-newest-first'));
Popup.back();
},
'click .js-sort-created-desc'() {
const sortBy = {
createdAt: -1,
};
Session.set('sortBy', sortBy);
sortCardsBy.set(TAPi18n.__('date-created-oldest-first'));
Popup.back();
},
});
}).register('cardsSortPopup');
File diff suppressed because it is too large. Load diff
+143 -248
View File
@@ -1,259 +1,154 @@
template(name="boardList")
.wrapper
.board-list-header
ul.AllBoardTeamsOrgs
li.AllBoardTeams
if userHasTeams
select.js-AllBoardTeams#jsAllBoardTeams("multiple")
option(value="-1") {{_ 'teams'}} :
each teamsDatas
option(value="{{teamId}}") {{_ teamDisplayName}}
.boards-layout
// Left menu
.boards-left-menu
ul.menu
li(class="menu-item {{#if isSelectedMenu 'starred'}}active{{/if}}")
a.js-select-menu(data-type="starred")
span.menu-label
span.emoji-icon
i.fa.fa-star
| {{_ 'allboards.starred'}}
span.menu-count {{menuItemCount 'starred'}}
li(class="menu-item {{#if isSelectedMenu 'templates'}}active{{/if}}")
a.js-select-menu(data-type="templates")
span.menu-label
span.emoji-icon
i.fa.fa-clipboard
| {{_ 'allboards.templates'}}
span.menu-count {{menuItemCount 'templates'}}
li(class="menu-item {{#if isSelectedMenu 'remaining'}}active{{/if}}")
a.js-select-menu(data-type="remaining")
span.menu-label
span.emoji-icon
i.fa.fa-folder
| {{_ 'allboards.remaining'}}
span.menu-count {{menuItemCount 'remaining'}}
.workspaces-header
span
span.emoji-icon
i.fa.fa-folder-open
| {{_ 'allboards.workspaces'}}
a.js-add-workspace(title="{{_ 'allboards.add-workspace'}}") +
// Workspaces tree
+workspaceTree(nodes=workspacesTree selectedWorkspaceId=selectedWorkspaceId)
li.AllBoardOrgs
if userHasOrgs
select.js-AllBoardOrgs#jsAllBoardOrgs("multiple")
option(value="-1") {{_ 'organizations'}} :
each orgsDatas
option(value="{{orgId}}") {{orgDisplayName}}
// Existing filter by orgs/teams (kept)
ul.AllBoardTeamsOrgs
li.AllBoardTeams
if userHasTeams
select.js-AllBoardTeams#jsAllBoardTeams("multiple")
option(value="-1") {{_ 'teams'}} :
each teamsDatas
option(value="{{teamId}}") {{_ teamDisplayName}}
//li.AllBoardTemplates
// if userHasTemplates
// select.js-AllBoardTemplates#jsAllBoardTemplates("multiple")
// option(value="-1") {{_ 'templates'}} :
// each templatesDatas
// option(value="{{templateId}}") {{_ templateDisplayName}}
li.AllBoardOrgs
if userHasOrgs
select.js-AllBoardOrgs#jsAllBoardOrgs("multiple")
option(value="-1") {{_ 'organizations'}} :
each orgsDatas
option(value="{{orgId}}") {{orgDisplayName}}
li.AllBoardBtns
div.AllBoardButtonsContainer
if userHasOrgsOrTeams
i.fa.fa-filter
input#filterBtn(type="button" value="{{_ 'filter'}}")
input#resetBtn(type="button" value="{{_ 'filter-clear'}}")
li.AllBoardBtns
div.AllBoardButtonsContainer
if userHasOrgsOrTeams
span.emoji-icon
i.fa.fa-search
input#filterBtn(type="button" value="{{_ 'filter'}}")
button#resetBtn.filter-reset-btn
span.reset-icon
span.emoji-icon
i.fa.fa-times-thin
span {{_ 'filter-clear'}}
// Right boards grid
.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'}}
.path-right
if canModifyBoards
if hasBoardsSelected
button.js-archive-selected-boards.board-header-btn
span.emoji-icon
i.fa.fa-archive
span {{_ 'archive-board'}}
button.js-duplicate-selected-boards.board-header-btn
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
i.fa.fa-check-square-o
if BoardMultiSelection.isActive
a.board-header-btn-close.js-multiselection-reset(title="{{_ 'filter-clear'}}")
span.emoji-icon
i.fa.fa-times
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'}}")
span.emoji-icon
i.fa.fa-plus
| &nbsp;{{_ 'add-template-container'}}
ul.board-list.clearfix.js-boards
li.js-add-board
a.board-list-item.label(title="{{_ 'add-board'}}")
| {{_ 'add-board'}}
each boards
li(class="{{_id}}" class="{{#if isStarred}}starred{{/if}}" class=colorClass).js-board
if isInvited
.board-list-item
span.details
span.board-list-item-name= title
i.fa.js-star-board(
class="fa-star{{#if isStarred}} is-star-active{{else}}-o{{/if}}"
title="{{_ 'star-board-title'}}")
p.board-list-item-desc {{_ 'just-invited'}}
button.js-accept-invite.primary {{_ 'accept'}}
button.js-decline-invite {{_ 'decline'}}
else
if $eq type "template-container"
a.js-open-board.template-container.board-list-item(href="{{pathFor 'board' id=_id slug=slug}}")
span.details
span.board-list-item-name(title="{{_ 'template-container'}}")
+viewer
= title
i.fa.js-star-board(
class="fa-star{{#if isStarred}} is-star-active{{else}}-o{{/if}}"
title="{{_ 'star-board-title'}}")
p.board-list-item-desc
+viewer
= description
if hasSpentTimeCards
i.fa.js-has-spenttime-cards(
class="fa-circle{{#if hasOvertimeCards}} has-overtime-card-active{{else}} no-overtime-card-active{{/if}}"
title="{{#if hasOvertimeCards}}{{_ 'has-overtime-cards'}}{{else}}{{_ 'has-spenttime-cards'}}{{/if}}")
if isTouchScreenOrShowDesktopDragHandles
i.fa.board-handle(
class="fa-arrows"
title="{{_ 'drag-board'}}")
else
if isSandstorm
i.fa.js-clone-board(
class="fa-clone"
title="{{_ 'duplicate-board'}}")
i.fa.js-archive-board(
class="fa-archive"
title="{{_ 'archive-board'}}")
else if isAdministrable
i.fa.js-clone-board(
class="fa-clone"
title="{{_ 'duplicate-board'}}")
i.fa.js-archive-board(
class="fa-archive"
title="{{_ 'archive-board'}}")
else if currentUser.isAdmin
i.fa.js-clone-board(
class="fa-clone"
title="{{_ 'duplicate-board'}}")
i.fa.js-archive-board(
class="fa-archive"
title="{{_ 'archive-board'}}")
else
a.board-list-item.label(title="{{_ 'add-board'}}")
span.emoji-icon
i.fa.fa-plus
| &nbsp;{{_ 'add-board'}}
each boards
li.js-board(class="{{_id}} {{#if isStarred}}starred{{/if}} {{colorClass}} {{#if BoardMultiSelection.isSelected _id}}is-checked{{/if}}", draggable="true")
if isInvited
.board-list-item
if BoardMultiSelection.isActive
.materialCheckBox.multi-selection-checkbox.js-toggle-board-multi-selection(
class="{{#if BoardMultiSelection.isSelected _id}}is-checked{{/if}}")
span.details
span.board-list-item-name= title
span.js-star-board(
class="{{#if isStarred}}is-star-active{{else}}is-not-star-active{{/if}}"
title="{{_ 'star-board-title'}}")
span.emoji-icon
| {{#if isStarred}}⭐{{else}}☆{{/if}}
p.board-list-item-desc {{_ 'just-invited'}}
button.js-accept-invite.primary {{_ 'accept'}}
button.js-decline-invite {{_ 'decline'}}
else
if $eq type "template-container"
.template-container.board-list-item
if BoardMultiSelection.isActive
.materialCheckBox.multi-selection-checkbox.js-toggle-board-multi-selection(
class="{{#if BoardMultiSelection.isSelected _id}}is-checked{{/if}}")
span.board-handle.nodragscroll(title="{{boardWorkspaceDragHint}}")
span.emoji-icon
i.fa.fa-arrows
a.js-open-board(href="{{pathFor 'board' id=_id slug=slug}}")
span.details
span.board-list-item-name(title="{{_ 'template-container'}}")
+viewer
= title
p.board-list-item-desc
+viewer
= description
if hasSpentTimeCards
span.js-has-spenttime-cards(
class="{{#if hasOvertimeCards}}has-overtime-card-active{{else}}no-overtime-card-active{{/if}}"
title="{{#if hasOvertimeCards}}{{_ 'has-overtime-cards'}}{{else}}{{_ 'has-spenttime-cards'}}{{/if}}")
span.emoji-icon
i.fa.fa-clock-o
span.js-star-board(
class="{{#if isStarred}}is-star-active{{else}}is-not-star-active{{/if}}"
title="{{_ 'star-board-title'}}")
span.emoji-icon
i.fa(class="fa-star{{#unless isStarred}}-o{{/unless}}")
else
.board-list-item
if BoardMultiSelection.isActive
.materialCheckBox.multi-selection-checkbox.js-toggle-board-multi-selection(
class="{{#if BoardMultiSelection.isSelected _id}}is-checked{{/if}}")
span.board-handle.nodragscroll(title="{{boardWorkspaceDragHint}}")
span.emoji-icon
i.fa.fa-arrows
a.js-open-board(href="{{pathFor 'board' id=_id slug=slug}}")
span.details
span.board-list-item-name(title="{{boardOpenAndMoveHint}}")
+viewer
= title
unless currentSetting.hideBoardMemberList
if allowsBoardMemberList
.minicard-members
each member in boardMembers _id
a.name
+userAvatar(userId=member noRemove=true)
unless currentSetting.hideCardCounterList
if allowsCardCounterList
.minicard-lists.flex.flex-wrap
each list in boardLists _id
.item
| {{ list }}
p.board-list-item-desc
+viewer
= description
if hasSpentTimeCards
span.js-has-spenttime-cards(
class="{{#if hasOvertimeCards}}has-overtime-card-active{{else}}no-overtime-card-active{{/if}}"
title="{{#if hasOvertimeCards}}{{_ 'has-overtime-cards'}}{{else}}{{_ 'has-spenttime-cards'}}{{/if}}")
span.emoji-icon
i.fa.fa-clock-o
a.js-star-board(
class="{{#if isStarred}}is-star-active{{else}}is-not-star-active{{/if}}"
title="{{_ 'star-board-title'}}")
span.emoji-icon
i.fa(class="fa-star{{#unless isStarred}}-o{{/unless}}")
a.js-open-board.board-list-item(href="{{pathFor 'board' id=_id slug=slug}}")
span.details
span.board-list-item-name(title="{{_ 'board-drag-drop-reorder-or-click-open'}}")
+viewer
= title
unless currentSetting.hideBoardMemberList
if allowsBoardMemberList
.minicard-members
each member in boardMembers _id
a.name
+userAvatar(userId=member noRemove=true)
unless currentSetting.hideCardCounterList
if allowsCardCounterList
.minicard-lists.flex.flex-wrap
each list in boardLists _id
.item
| {{ list }}
i.fa.js-star-board(
class="fa-star{{#if isStarred}} is-star-active{{else}}-o{{/if}}"
title="{{_ 'star-board-title'}}")
p.board-list-item-desc
+viewer
= description
if hasSpentTimeCards
i.fa.js-has-spenttime-cards(
class="fa-circle{{#if hasOvertimeCards}} has-overtime-card-active{{else}} no-overtime-card-active{{/if}}"
title="{{#if hasOvertimeCards}}{{_ 'has-overtime-cards'}}{{else}}{{_ 'has-spenttime-cards'}}{{/if}}")
if isTouchScreenOrShowDesktopDragHandles
i.fa.board-handle(
class="fa-arrows"
title="{{_ 'drag-board'}}")
else
if isSandstorm
i.fa.js-clone-board(
class="fa-clone"
title="{{_ 'duplicate-board'}}")
i.fa.js-archive-board(
class="fa-archive"
title="{{_ 'archive-board'}}")
else if isAdministrable
i.fa.js-clone-board(
class="fa-clone"
title="{{_ 'duplicate-board'}}")
i.fa.js-archive-board(
class="fa-archive"
title="{{_ 'archive-board'}}")
else if currentUser.isAdmin
i.fa.js-clone-board(
class="fa-clone"
title="{{_ 'duplicate-board'}}")
i.fa.js-archive-board(
class="fa-archive"
title="{{_ 'archive-board'}}")
template(name="boardListHeaderBar")
h1 {{_ title }}
//.board-header-btns.right
//
a.board-header-btn.js-open-archived-board
//
i.fa.fa-archive
//
span {{_ 'archives'}}
//
a.board-header-btn(href="{{pathFor 'board' id=templatesBoardId slug=templatesBoardSlug}}")
//
i.fa.fa-clone
//
span {{_ 'templates'}}
// Recursive template for workspaces tree
template(name="workspaceTree")
if nodes
ul.workspace-tree.js-workspace-tree
each nodes
li.workspace-node(class="{{#if $eq id selectedWorkspaceId}}active{{/if}}" data-workspace-id="{{id}}" draggable="true")
.workspace-node-content
span.workspace-drag-handle
span.emoji-icon
i.fa.fa-arrows
a.js-select-workspace.js-select-space(data-id="{{id}}")
span.workspace-icon
if icon
span.workspace-icon-value(style="{{#if iconColor}}color: {{iconColor}};{{/if}}")
+viewer
= icon
else
span.emoji-icon(style="{{#if iconColor}}color: {{iconColor}};{{/if}}")
i.fa.fa-folder
span.workspace-name= name
a.js-open-workspace-menu(data-id="{{id}}" title="{{_ 'allboards.workspace-menu'}}")
span.emoji-icon
i.fa.fa-bars
span.workspace-count {{workspaceCount id}}
if children
+workspaceTree(nodes=children selectedWorkspaceId=selectedWorkspaceId)
template(name="workspaceActionsPopup")
form.workspace-actions-form.js-workspace-actions-form(data-id="{{id}}")
label
| {{_ 'allboards.edit-workspace-name'}}
input.js-workspace-name(type="text" value="{{workspaceName}}" required)
label
| {{_ 'allboards.edit-workspace-icon'}}
input.js-workspace-icon(type="text" value="{{workspaceIcon}}")
input.primary.wide(type="submit" value="{{_ 'save'}}")
hr
form.workspace-subspace-form.js-workspace-subspace-form(data-id="{{id}}")
label
| {{_ 'allboards.add-subworkspace-prompt'}}
input.js-subworkspace-name(type="text")
button.primary.wide(type="submit") {{_ 'allboards.add-subworkspace'}}
hr
.workspace-delete-block
span.js-workspace-delete-error(style="color: #c00; display: block; margin: 4px 0;")
button.js-delete-workspace.negate.wide(type="button" data-id="{{id}}") {{_ 'delete'}}
// a.board-header-btn.js-open-archived-board
// i.fa.fa-archive
// span {{_ 'archives'}}
// a.board-header-btn(href="{{pathFor 'board' id=templatesBoardId slug=templatesBoardSlug}}")
// i.fa.fa-clone
// span {{_ 'templates'}}
File diff suppressed because it is too large. Load diff
-43
View File
@@ -1,43 +0,0 @@
.calendar-view .fc {
--fc-button-text-color: #333;
--fc-button-bg-color: #f5f5f5;
--fc-button-border-color: rgba(0, 0, 0, 0.2);
--fc-button-hover-bg-color: #e6e6e6;
--fc-button-hover-border-color: rgba(0, 0, 0, 0.25);
--fc-button-active-bg-color: #d9d9d9;
--fc-button-active-border-color: rgba(0, 0, 0, 0.3);
}
.calendar-view .fc .fc-button-primary {
text-shadow: 0 1px 1px rgba(255, 255, 255, 0.75);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2),
0 1px 2px rgba(0, 0, 0, 0.05);
background-image: linear-gradient(to bottom, #fff 0%, #e6e6e6 100%);
}
.calendar-view .fc .fc-button-primary:focus,
.calendar-view .fc .fc-button-primary:not(:disabled).fc-button-active,
.calendar-view .fc .fc-button-primary:not(:disabled):active {
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.15),
0 1px 2px rgba(0, 0, 0, 0.05);
}
.calendar-create-close {
min-height: auto !important;
min-width: auto !important;
width: 32px;
height: 32px;
padding: 0 !important;
border: 0 !important;
background: transparent !important;
box-shadow: none !important;
color: #666 !important;
opacity: 0.8;
}
.calendar-create-close:hover,
.calendar-create-close:focus {
background: transparent !important;
color: #111 !important;
opacity: 1;
}
+1 -2
View File
@@ -3,7 +3,6 @@ template(name="miniboard")
class="minicard-{{colorClass}}")
.minicard-title
.handle
span.drag-handle(title="{{_ 'dragBoard'}}")
i.fa.fa-arrows
.fa.fa-arrows
+viewer
= title
@@ -1,263 +0,0 @@
/* Original Positions View Styles */
.original-positions-view {
margin: 10px 0;
padding: 15px;
background-color: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 6px;
}
.original-positions-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 15px;
}
.original-positions-header .btn {
display: flex;
align-items: center;
gap: 5px;
}
.original-positions-content {
background-color: white;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 15px;
}
.original-positions-loading {
text-align: center;
padding: 20px;
color: #6c757d;
font-style: italic;
}
.original-positions-loading i {
margin-right: 8px;
}
.original-positions-filters {
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 1px solid #dee2e6;
}
.original-positions-filters .btn-group {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.original-positions-filters .btn {
display: flex;
align-items: center;
gap: 5px;
white-space: nowrap;
}
.original-positions-list {
max-height: 400px;
overflow-y: auto;
}
.original-position-item {
background-color: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 4px;
margin-bottom: 10px;
padding: 12px;
transition: all 0.2s ease;
}
.original-position-item:hover {
background-color: #e9ecef;
border-color: #ced4da;
}
.original-position-item:last-child {
margin-bottom: 0;
}
.original-position-item-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
font-weight: 600;
color: #495057;
}
.original-position-item-header i {
color: #6c757d;
width: 16px;
text-align: center;
}
.entity-type {
background-color: #007bff;
color: white;
padding: 2px 6px;
border-radius: 3px;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
}
.entity-name {
color: #212529;
font-weight: 600;
}
.entity-id {
color: #6c757d;
font-size: 11px;
font-family: monospace;
}
.original-position-item-details {
margin-left: 24px;
}
.original-position-description {
color: #495057;
margin-bottom: 6px;
font-size: 13px;
}
.original-title {
color: #6c757d;
font-size: 12px;
margin-bottom: 6px;
padding: 4px 6px;
background-color: #e9ecef;
border-radius: 3px;
}
.original-title strong {
color: #495057;
}
.original-position-date {
color: #6c757d;
font-size: 11px;
}
.no-original-positions {
text-align: center;
padding: 40px 20px;
color: #6c757d;
font-style: italic;
}
.no-original-positions i {
font-size: 24px;
margin-bottom: 10px;
display: block;
color: #adb5bd;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.original-positions-view {
margin: 5px 0;
padding: 10px;
}
.original-positions-header {
flex-direction: column;
align-items: stretch;
gap: 8px;
}
.original-positions-header .btn {
justify-content: center;
}
.original-positions-filters .btn-group {
justify-content: center;
}
.original-position-item-header {
flex-wrap: wrap;
gap: 6px;
}
.entity-name {
flex: 1;
min-width: 0;
word-break: break-word;
}
.original-position-item-details {
margin-left: 0;
margin-top: 8px;
}
}
/* Dark theme support */
@media (prefers-color-scheme: dark) {
.original-positions-view {
background-color: #2d3748;
border-color: #4a5568;
color: #e2e8f0;
}
.original-positions-content {
background-color: #1a202c;
border-color: #4a5568;
}
.original-position-item {
background-color: #2d3748;
border-color: #4a5568;
color: #e2e8f0;
}
.original-position-item:hover {
background-color: #4a5568;
border-color: #718096;
}
.original-position-item-header {
color: #e2e8f0;
}
.original-position-item-header i {
color: #a0aec0;
}
.entity-name {
color: #e2e8f0;
}
.entity-id {
color: #a0aec0;
}
.original-position-description {
color: #e2e8f0;
}
.original-title {
background-color: #4a5568;
color: #a0aec0;
}
.original-title strong {
color: #e2e8f0;
}
.original-position-date {
color: #a0aec0;
}
.no-original-positions {
color: #a0aec0;
}
.no-original-positions i {
color: #718096;
}
}
@@ -1,57 +0,0 @@
template(name="originalPositionsView")
.original-positions-view
.original-positions-header
button.btn.btn-sm.btn-outline-secondary.js-toggle-original-positions
i.fa.fa-history
if isShowingOriginalPositions
| Hide Original Positions
else
| Show Original Positions
if isShowingOriginalPositions
button.btn.btn-sm.btn-outline-primary.js-refresh-history
i.fa.fa-refresh
| Refresh
if isShowingOriginalPositions
.original-positions-content
if isLoading
.original-positions-loading
i.fa.fa-spinner.fa-spin
| Loading original positions...
else
.original-positions-filters
.btn-group.btn-group-sm(role="group")
button.btn.js-filter-type(type="button" class="{{#if isFilterType 'all'}}btn-primary{{else}}btn-outline-secondary{{/if}}" data-filter-type="all")
| All
button.btn.js-filter-type(type="button" class="{{#if isFilterType 'swimlane'}}btn-primary{{else}}btn-outline-secondary{{/if}}" data-filter-type="swimlane")
i.fa.fa-bars
| Swimlanes
button.btn.js-filter-type(type="button" class="{{#if isFilterType 'list'}}btn-primary{{else}}btn-outline-secondary{{/if}}" data-filter-type="list")
i.fa.fa-columns
| Lists
button.btn.js-filter-type(type="button" class="{{#if isFilterType 'card'}}btn-primary{{else}}btn-outline-secondary{{/if}}" data-filter-type="card")
i.fa.fa-sticky-note
| Cards
.original-positions-list
each getFilteredHistory
.original-position-item
.original-position-item-header
i.fa(class="{{getEntityTypeIcon entityType}}")
span.entity-type {{getEntityTypeLabel entityType}}
span.entity-name {{getEntityDisplayName this}}
span.entity-id ({{entityId}})
.original-position-item-details
.original-position-description
| {{getEntityOriginalPositionDescription this}}
if originalTitle
.original-title
strong Original title:
| {{originalTitle}}
.original-position-date
small.text-muted Created: {{formatDate createdAt}}
else
.no-original-positions
i.fa.fa-info-circle
| No original position data available for this board.
@@ -1,134 +0,0 @@
import { ReactiveVar } from 'meteor/reactive-var';
import { Meteor } from 'meteor/meteor';
/**
* Component to display original positions for all entities on a board
*/
Template.originalPositionsView.onCreated(function () {
this.showOriginalPositions = new ReactiveVar(false);
this.boardHistory = new ReactiveVar([]);
this.isLoading = new ReactiveVar(false);
this.filterType = new ReactiveVar('all'); // 'all', 'swimlane', 'list', 'card'
const tpl = this;
this.loadBoardHistory = function () {
const boardId = Session.get('currentBoard');
if (!boardId) return;
tpl.isLoading.set(true);
Meteor.call('positionHistory.getBoardHistory', boardId, (error, result) => {
tpl.isLoading.set(false);
if (error) {
console.error('Error loading board history:', error);
tpl.boardHistory.set([]);
} else {
tpl.boardHistory.set(result);
}
});
};
});
Template.originalPositionsView.onRendered(function () {
this.loadBoardHistory();
});
Template.originalPositionsView.helpers({
isShowingOriginalPositions() {
return Template.instance().showOriginalPositions.get();
},
isLoading() {
return Template.instance().isLoading.get();
},
getBoardHistory() {
return Template.instance().boardHistory.get();
},
getFilteredHistory() {
const tpl = Template.instance();
const history = tpl.boardHistory.get();
const filterType = tpl.filterType.get();
if (filterType === 'all') {
return history;
}
return history.filter(item => item.entityType === filterType);
},
isFilterType(type) {
return Template.instance().filterType.get() === type;
},
getEntityDisplayName(entity) {
const position = entity.originalPosition || {};
return position.title || `Entity ${entity.entityId}`;
},
getEntityOriginalPositionDescription(entity) {
const position = entity.originalPosition || {};
let description = `Position: ${position.sort || 0}`;
if (entity.entityType === 'list' && entity.originalSwimlaneId) {
description += ` in swimlane ${entity.originalSwimlaneId}`;
} else if (entity.entityType === 'card') {
if (entity.originalSwimlaneId) {
description += ` in swimlane ${entity.originalSwimlaneId}`;
}
if (entity.originalListId) {
description += ` in list ${entity.originalListId}`;
}
}
return description;
},
getEntityTypeIcon(entityType) {
switch (entityType) {
case 'swimlane':
return 'fa-bars';
case 'list':
return 'fa-columns';
case 'card':
return 'fa-sticky-note';
default:
return 'fa-question';
}
},
getEntityTypeLabel(entityType) {
switch (entityType) {
case 'swimlane':
return 'Swimlane';
case 'list':
return 'List';
case 'card':
return 'Card';
default:
return 'Unknown';
}
},
formatDate(date) {
return new Date(date).toLocaleString();
},
});
Template.originalPositionsView.events({
'click .js-toggle-original-positions'(evt, tpl) {
tpl.showOriginalPositions.set(!tpl.showOriginalPositions.get());
},
'click .js-refresh-history'(evt, tpl) {
tpl.loadBoardHistory();
},
'click .js-filter-type'(evt, tpl) {
const type = evt.currentTarget.dataset.filterType;
tpl.filterType.set(type);
},
});
+2 -174
View File
@@ -55,12 +55,6 @@
flex-direction: row;
align-items: center;
}
.attachment-actions a {
margin-left: 16px;
}
.attachment-actions a:first-child {
margin-left: 0;
}
.add-attachment {
display: flex;
align-items: center;
@@ -112,144 +106,9 @@
color: white;
cursor: pointer;
font-size: 4em;
top: 0;
right: 8px;
position: absolute;
right: 50px;
top: 16px;
}
/* Upload progress indicators for drag-and-drop uploads */
.minicard-upload-progress,
.card-details-upload-progress {
background: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 4px;
padding: 12px;
margin: 8px 0;
font-size: 14px;
}
.upload-progress-header {
display: flex;
align-items: center;
margin-bottom: 8px;
font-weight: bold;
color: #495057;
}
.upload-progress-header i {
margin-right: 8px;
color: #007bff;
}
.upload-progress-item {
display: flex;
flex-direction: column;
margin-bottom: 8px;
padding: 8px;
background: white;
border-radius: 3px;
border: 1px solid #dee2e6;
}
.upload-progress-item.upload-error {
border-color: #dc3545;
background: #f8d7da;
}
.upload-progress-filename {
font-weight: 500;
margin-bottom: 4px;
color: #495057;
word-break: break-all;
}
.upload-progress-bar {
width: 100%;
height: 6px;
background: #e9ecef;
border-radius: 3px;
overflow: hidden;
margin-bottom: 4px;
}
.upload-progress-fill {
height: 100%;
background: linear-gradient(90deg, #007bff, #0056b3);
transition: width 0.3s ease;
border-radius: 3px;
}
.upload-progress-item.upload-error .upload-progress-fill {
background: #dc3545;
}
.upload-progress-error,
.upload-progress-success {
display: flex;
align-items: center;
font-size: 12px;
font-weight: 500;
}
.upload-progress-error {
color: #dc3545;
}
.upload-progress-success {
color: #28a745;
}
.upload-progress-error i,
.upload-progress-success i {
margin-right: 4px;
}
/* Minicard specific styles */
.minicard-upload-progress {
margin: 4px 0;
padding: 8px;
font-size: 12px;
}
.minicard-upload-progress .upload-progress-item {
padding: 6px;
margin-bottom: 6px;
}
.minicard-upload-progress .upload-progress-filename {
font-size: 11px;
}
/* Card details specific styles */
.card-details-upload-progress {
margin: 12px 0;
padding: 16px;
}
.card-details-upload-progress .upload-progress-header {
font-size: 16px;
margin-bottom: 12px;
}
.card-details-upload-progress .upload-progress-item {
padding: 12px;
margin-bottom: 10px;
}
.card-details-upload-progress .upload-progress-filename {
font-size: 14px;
}
/* Drag over state for minicards */
.minicard.is-dragging-over {
border: 2px dashed #007bff !important;
background: rgba(0, 123, 255, 0.1) !important;
}
/* Drag over state for card details */
.js-card-details.is-dragging-over {
border: 2px dashed #007bff !important;
background: rgba(0, 123, 255, 0.05) !important;
}
.attachment-arrow {
font-size: 4em;
@@ -258,20 +117,6 @@
align-self: center;
margin: 0 20px;
}
#prev-attachment {
font-size: 4em;
color: white;
cursor: pointer;
align-self: center;
margin-left: 70px;
}
#next-attachment {
font-size: 4em;
color: white;
cursor: pointer;
align-self: center;
margin-right: 70px;
}
#viewer-content {
display: flex;
justify-content: center;
@@ -285,13 +130,6 @@
max-width: 100%;
max-height: 100%;
}
#video-viewer {
max-width: 100%;
max-height: 100%;
}
#audio-viewer {
max-width: 100%;
}
#pdf-viewer {
width: 40vw;
height: 100%;
@@ -326,19 +164,9 @@
}
#prev-attachment {
left: 0;
position: absolute;
bottom: 2.2em;
font-size: 1.6em;
padding: 16px;
margin-left: 0;
}
#next-attachment {
right: 0;
position: absolute;
bottom: 2.2em;
font-size: 1.6em;
padding: 16px;
margin-right: 0;
}
#pdf-viewer {
width: 100%;
+35 -25
View File
@@ -34,11 +34,10 @@ template(name="attachmentViewer")
#viewer-overlay.hidden
#viewer-top-bar
span#attachment-name
a#viewer-close
i.fa.fa-times-thin
a#viewer-close.fa.fa-times-thin
#viewer-container
i.fa.fa-caret-left#prev-attachment
i.fa.fa-chevron-left.attachment-arrow#prev-attachment
#viewer-content
img#image-viewer.hidden
video#video-viewer.hidden(controls="true")
@@ -46,7 +45,7 @@ template(name="attachmentViewer")
object#pdf-viewer.hidden(type="application/pdf")
span.pdf-preview-error {{_ 'preview-pdf-not-supported' }}
object#txt-viewer.hidden(type="text/plain")
i.fa.fa-caret-right#next-attachment
i.fa.fa-chevron-right.attachment-arrow#next-attachment
template(name="attachmentGallery")
@@ -54,11 +53,11 @@ template(name="attachmentGallery")
if canModifyCard
a.attachment-item.add-attachment.js-add-attachment
i.fa.fa-plus
i.fa.fa-plus.icon
each attachments
.attachment-item(class="{{#if isAttachmentMigrating _id}}migrating{{/if}}")
.attachment-item
.attachment-thumbnail-container.open-preview(data-attachment-id="{{_id}}" data-card-id="{{ meta.cardId }}")
if link
if(isImage)
@@ -86,36 +85,30 @@ template(name="attachmentGallery")
b
= 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'}}")
i.fa.fa-arrow-down
a.js-download(href="{{link}}?download=true", download="{{name}}")
i.fa.fa-download.icon(title="{{_ 'download'}}")
if currentUser.isBoardMember
unless currentUser.isCommentOnly
unless currentUser.isWorker
a.js-rename(title="{{_ 'rename'}}")
i.fa.fa-pencil-square-o
a.js-confirm-delete(title="{{_ 'delete'}}")
i.fa.fa-trash
a.js-open-attachment-menu(data-attachment-link="{{link}}", title="{{_ 'attachmentActionsPopup-title'}}")
i.fa.fa-bars
// Migration spinner overlay
if isAttachmentMigrating _id
.attachment-migration-overlay
.migration-spinner
i.fa.fa-cog.fa-spin
.migration-text {{_ 'migrating-attachment'}}
a.js-rename
i.fa.fa-pencil-square-o.icon(title="{{_ 'rename'}}")
a.js-confirm-delete
i.fa.fa-trash.icon(title="{{_ 'delete'}}")
a.fa.fa-navicon.icon.js-open-attachment-menu(data-attachment-link="{{link}}" title="{{_ 'attachmentActionsPopup-title'}}")
template(name="attachmentActionsPopup")
ul.pop-over-list
li
if isImage
a(class="{{#if isCover}}js-remove-cover{{else}}js-add-cover{{/if}}")
i.fa.fa-book
i.fa.fa-picture-o
| {{#if isCover}}{{_ 'remove-cover'}}{{else}}{{_ 'add-cover'}}{{/if}}
if isCover
| {{_ 'remove-cover'}}
else
| {{_ 'add-cover'}}
if currentUser.isBoardAdmin
if isImage
a(class="{{#if isBackgroundImage}}js-remove-background-image{{else}}js-add-background-image{{/if}}")
@@ -125,6 +118,23 @@ template(name="attachmentActionsPopup")
else
| {{_ 'add-background-image'}}
if $neq versions.original.storage "fs"
a.js-move-storage-fs
i.fa.fa-arrow-right
| {{_ 'attachment-move-storage-fs'}}
if $neq versions.original.storage "gridfs"
if versions.original.storage
a.js-move-storage-gridfs
i.fa.fa-arrow-right
| {{_ 'attachment-move-storage-gridfs'}}
if $neq versions.original.storage "s3"
if versions.original.storage
a.js-move-storage-s3
i.fa.fa-arrow-right
| {{_ 'attachment-move-storage-s3'}}
template(name="attachmentRenamePopup")
input.js-edit-attachment-name(type='text' autofocus value="{{getNameWithoutExtension}}" dir="auto")
.edit-controls.clearfix
+125 -251
View File
@@ -1,16 +1,9 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { ObjectId } from 'bson';
import { ObjectID } from 'bson';
import DOMPurify from 'dompurify';
import { sanitizeHTML, sanitizeText } from '/imports/lib/secureDOMPurify';
import uploadProgressManager from '../../lib/uploadProgressManager';
import { attachmentMigrationManager } from '/client/lib/attachmentMigrationManager';
import Attachments from '/models/attachments';
import { Utils } from '/client/lib/utils';
import { formatDateTime } from '/imports/lib/dateUtils';
import { EscapeActions } from '/client/lib/escapeActions';
const { filesize } = require('filesize');
import prettyMilliseconds from 'pretty-ms';
const filesize = require('filesize');
const prettyMilliseconds = require('pretty-ms');
// We store current card ID and the ID of currently opened attachment in a
// global var. This is used so that we know what's the next attachment to open
@@ -23,7 +16,7 @@ let touchStartCoords = null;
let touchEndCoords = null;
// Stores link to the attachment for which attachment actions popup was opened
let attachmentActionsLink = null;
attachmentActionsLink = null;
Template.attachmentGallery.events({
'click .open-preview'(event) {
@@ -44,12 +37,8 @@ Template.attachmentGallery.events({
attachmentActionsLink = event.currentTarget.getAttribute("data-attachment-link");
},
'click .js-rename': Popup.open('attachmentRename'),
'click .js-confirm-delete': Popup.afterConfirm('attachmentDelete', async function() {
const card = this.meta && this.meta.cardId ? ReactiveCache.getCard(this.meta.cardId) : null;
if (card && card.coverId === this._id) {
await card.unsetCover();
}
await Attachments.removeAsync(this._id);
'click .js-confirm-delete': Popup.afterConfirm('attachmentDelete', function() {
Attachments.remove(this._id);
Popup.back();
}),
});
@@ -89,10 +78,6 @@ function attachmentCanBeOpened(attachment) {
);
}
function getAttachmentUrl(attachment) {
return attachment && typeof attachment.link === 'function' ? attachment.link() : '';
}
function openAttachmentViewer(attachmentId) {
const attachment = ReactiveCache.getAttachment(attachmentId);
@@ -109,18 +94,18 @@ function openAttachmentViewer(attachmentId) {
*/
switch(true){
case (attachment.isImage):
$("#image-viewer").attr("src", getAttachmentUrl(attachment));
$("#image-viewer").attr("src", attachment.link());
$("#image-viewer").removeClass("hidden");
break;
case (attachment.isPDF):
$("#pdf-viewer").attr("data", getAttachmentUrl(attachment));
$("#pdf-viewer").attr("data", attachment.link());
$("#pdf-viewer").removeClass("hidden");
break;
case (attachment.isVideo):
// We have to create a new <source> DOM element and append it to the video
// element, otherwise the video won't load
let videoSource = document.createElement('source');
videoSource.setAttribute('src', getAttachmentUrl(attachment));
videoSource.setAttribute('src', attachment.link());
$("#video-viewer").append(videoSource);
$("#video-viewer").removeClass("hidden");
@@ -129,14 +114,14 @@ function openAttachmentViewer(attachmentId) {
// We have to create a new <source> DOM element and append it to the audio
// element, otherwise the audio won't load
let audioSource = document.createElement('source');
audioSource.setAttribute('src', getAttachmentUrl(attachment));
audioSource.setAttribute('src', attachment.link());
$("#audio-viewer").append(audioSource);
$("#audio-viewer").removeClass("hidden");
break;
case (attachment.isText):
case (attachment.isJSON):
$("#txt-viewer").attr("data", getAttachmentUrl(attachment));
$("#txt-viewer").attr("data", attachment.link());
$("#txt-viewer").removeClass("hidden");
break;
}
@@ -208,6 +193,8 @@ function processTouch(){
xDist = touchEndCoords.x - touchStartCoords.x;
yDist = touchEndCoords.y - touchStartCoords.y;
console.log("xDist: " + xDist);
// Left swipe
if (Math.abs(xDist) > Math.abs(yDist) && xDist < 0) {
openNextAttachment();
@@ -227,12 +214,14 @@ function processTouch(){
Template.attachmentViewer.events({
'touchstart #viewer-container'(event) {
console.log("touchstart")
touchStartCoords = {
x: event.changedTouches[0].screenX,
y: event.changedTouches[0].screenY
}
},
'touchend #viewer-container'(event) {
console.log("touchend")
touchEndCoords = {
x: event.changedTouches[0].screenX,
y: event.changedTouches[0].screenY
@@ -271,24 +260,6 @@ Template.attachmentViewer.events({
});
Template.attachmentGallery.helpers({
attachments() {
const card = Template.currentData();
if (!card) return [];
const cardId = typeof card.getRealId === 'function' ? card.getRealId() : card._id;
if (!cardId) return [];
const filesCursor = Attachments.find(
{ 'meta.cardId': cardId },
{ sort: { uploadedAt: -1 } },
);
// Call fetch() on the underlying Mongo cursor to establish a reactive
// dependency directly inside this Blaze computation. Without this,
// .each() (which calls cursor.map()) would not register reactivity and
// newly-uploaded attachments would not appear until a page reload.
if (filesCursor && filesCursor.cursor) {
filesCursor.cursor.fetch();
}
return filesCursor.each();
},
isBoardAdmin() {
return ReactiveCache.getCurrentUser().isBoardAdmin();
},
@@ -297,27 +268,7 @@ Template.attachmentGallery.helpers({
return ret;
},
sanitize(value) {
return sanitizeHTML(value);
},
uploaderName() {
const uploaderId = this.userId;
if (!uploaderId) return '';
const uploader = ReactiveCache.getUser(uploaderId);
if (!uploader) return '';
return uploader.profile && uploader.profile.fullname
? uploader.profile.fullname
: uploader.username || '';
},
uploadedAt() {
if (this.uploadedAtOstrio) {
return formatDateTime(this.uploadedAtOstrio);
}
// Fall back to ObjectId timestamp (first 4 bytes = Unix seconds)
try {
const ts = parseInt(this._id.substring(0, 8), 16) * 1000;
if (!isNaN(ts)) return formatDateTime(new Date(ts));
} catch (_) {}
return '';
return DOMPurify.sanitize(value);
},
});
@@ -340,19 +291,49 @@ Template.cardAttachmentsPopup.helpers({
});
Template.cardAttachmentsPopup.events({
async 'change .js-attach-file'(event, templateInstance) {
event.stopPropagation();
'change .js-attach-file'(event, templateInstance) {
const card = this;
const files = event.currentTarget.files;
if (files) {
let uploads = [];
const uploaders = await handleFileUpload(card, files);
for (const file of files) {
const fileId = new ObjectID().toString();
let fileName = DOMPurify.sanitize(file.name);
uploaders.forEach(uploader => {
// If sanitized filename is not same as original filename,
// it could be XSS that is already fixed with sanitize,
// or just normal mistake, so it is not a problem.
// That is why here is no warning.
if (fileName !== file.name) {
// If filename is empty, only in that case add some filename
if (fileName.length === 0) {
fileName = 'Empty-filename-after-sanitize.txt';
}
}
const config = {
file: file,
fileId: fileId,
fileName: fileName,
meta: Utils.getCommonAttachmentMetaFrom(card),
chunkSize: 'dynamic',
};
config.meta.fileId = fileId;
const uploader = Attachments.insert(
config,
false,
);
uploader.on('start', function() {
uploads.push(this);
templateInstance.uploads.set(uploads);
});
uploader.on('uploaded', (error, fileRef) => {
if (!error) {
if (fileRef.isImage) {
card.setCover(fileRef._id);
}
}
});
uploader.on('end', (error, fileRef) => {
uploads = uploads.filter(_upload => _upload.config.fileId != fileRef._id);
templateInstance.uploads.set(uploads);
@@ -360,14 +341,11 @@ Template.cardAttachmentsPopup.events({
Popup.back();
}
});
});
uploader.start();
}
}
},
'click .js-computer-upload'(event, templateInstance) {
// Prevent the click fired when the OS file-picker dialog closes from
// triggering EscapeActions and closing the popup before the upload starts.
// Same pattern as swimlanes.js after drag operations.
EscapeActions.preventNextClick();
templateInstance.find('.js-attach-file').click();
event.preventDefault();
},
@@ -378,110 +356,6 @@ const MAX_IMAGE_PIXEL = Utils.MAX_IMAGE_PIXEL;
const COMPRESS_RATIO = Utils.IMAGE_COMPRESS_RATIO;
let pastedResults = null;
// Shared upload logic for drag-and-drop functionality
export async function handleFileUpload(card, files) {
if (!files || files.length === 0) {
return [];
}
// Check if board allows attachments
const board = card.board();
if (!board || !board.allowsAttachments) {
if (process.env.DEBUG === 'true') {
console.warn('Attachments not allowed on this board');
}
return [];
}
// Check if user can modify the card
if (!Utils.canModifyCard()) {
if (process.env.DEBUG === 'true') {
console.warn('User does not have permission to modify this card');
}
return [];
}
const uploads = [];
for (const file of files) {
// Basic file validation
if (!file || !file.name) {
if (process.env.DEBUG === 'true') {
console.warn('Invalid file object');
}
continue;
}
const fileId = new ObjectId().toString();
let fileName = sanitizeText(file.name);
// If sanitized filename is not same as original filename,
// it could be XSS that is already fixed with sanitize,
// or just normal mistake, so it is not a problem.
// That is why here is no warning.
if (fileName !== file.name) {
// If filename is empty, only in that case add some filename
if (fileName.length === 0) {
fileName = 'Empty-filename-after-sanitize.txt';
}
}
const config = {
file: file,
fileId: fileId,
fileName: fileName,
meta: Utils.getCommonAttachmentMetaFrom(card),
chunkSize: 'dynamic',
// Use HTTP transport instead of DDP so file chunks go over a dedicated
// fetch POST rather than flooding the WebSocket/DDP channel. DDP
// upload causes repeated DDP reconnects in Safari, showing the
// "Loading, please wait" offline banner and stalling progress at ~95%.
transport: 'http',
};
config.meta.fileId = fileId;
try {
const uploader = await Attachments.insertAsync(
config,
false,
);
// Add to progress manager for tracking
const uploadId = uploadProgressManager.addUpload(card._id, uploader, file);
uploader.on('uploaded', (error, fileRef) => {
if (!error) {
if (fileRef.isImage) {
card.setCover(fileRef._id);
if (process.env.DEBUG === 'true') {
console.log(`Set cover image for card ${card._id}: ${fileRef.name}`);
}
}
} else {
if (process.env.DEBUG === 'true') {
console.error('Upload error:', error);
}
}
});
uploader.on('error', (error) => {
if (process.env.DEBUG === 'true') {
console.error('Upload error:', error);
}
});
uploads.push(uploader);
uploader.start();
} catch (error) {
if (process.env.DEBUG === 'true') {
console.error('Failed to create uploader:', error);
}
}
}
return uploads;
}
Template.previewClipboardImagePopup.onRendered(() => {
// we can paste image from clipboard
const handle = results => {
@@ -516,22 +390,21 @@ Template.previewClipboardImagePopup.onRendered(() => {
});
Template.previewClipboardImagePopup.events({
async 'click .js-upload-pasted-image'() {
'click .js-upload-pasted-image'() {
const card = this;
if (pastedResults && pastedResults.file) {
const file = pastedResults.file;
window.oPasted = pastedResults;
const fileId = new ObjectId().toString();
const fileId = new ObjectID().toString();
const config = {
file,
fileId: fileId,
meta: Utils.getCommonAttachmentMetaFrom(card),
fileName: file.name || file.type.replace('image/', 'clipboard.'),
chunkSize: 'dynamic',
transport: 'http',
};
config.meta.fileId = fileId;
const uploader = await Attachments.insertAsync(
const uploader = Attachments.insert(
config,
false,
);
@@ -552,9 +425,9 @@ Template.previewClipboardImagePopup.events({
},
});
Template.attachmentActionsPopup.helpers({
BlazeComponent.extendComponent({
isCover() {
const ret = ReactiveCache.getCard(this.meta.cardId).coverId == this._id;
const ret = ReactiveCache.getCard(this.data().meta.cardId).coverId == this.data()._id;
return ret;
},
isBackgroundImage() {
@@ -562,74 +435,75 @@ Template.attachmentActionsPopup.helpers({
//return currentBoard.backgroundImageURL === $(".attachment-thumbnail-img").attr("src");
return false;
},
});
events() {
return [
{
'click .js-add-cover'() {
ReactiveCache.getCard(this.data().meta.cardId).setCover(this.data()._id);
Popup.back();
},
'click .js-remove-cover'() {
ReactiveCache.getCard(this.data().meta.cardId).unsetCover();
Popup.back();
},
'click .js-add-background-image'() {
const currentBoard = Utils.getCurrentBoard();
currentBoard.setBackgroundImageURL(attachmentActionsLink);
Utils.setBackgroundImage(attachmentActionsLink);
Popup.back();
event.preventDefault();
},
'click .js-remove-background-image'() {
const currentBoard = Utils.getCurrentBoard();
currentBoard.setBackgroundImageURL("");
Utils.setBackgroundImage("");
Popup.back();
Utils.reload();
event.preventDefault();
},
'click .js-move-storage-fs'() {
Meteor.call('moveAttachmentToStorage', this.data()._id, "fs");
Popup.back();
},
'click .js-move-storage-gridfs'() {
Meteor.call('moveAttachmentToStorage', this.data()._id, "gridfs");
Popup.back();
},
'click .js-move-storage-s3'() {
Meteor.call('moveAttachmentToStorage', this.data()._id, "s3");
Popup.back();
},
}
]
}
}).register('attachmentActionsPopup');
Template.attachmentActionsPopup.events({
'click .js-add-cover'() {
ReactiveCache.getCard(this.meta.cardId).setCover(this._id);
Popup.back();
},
'click .js-remove-cover'() {
ReactiveCache.getCard(this.meta.cardId).unsetCover();
Popup.back();
},
'click .js-add-background-image'(event) {
const currentBoard = Utils.getCurrentBoard();
currentBoard.setBackgroundImageURL(attachmentActionsLink);
Utils.setBackgroundImage(attachmentActionsLink);
Popup.back();
event.preventDefault();
},
'click .js-remove-background-image'(event) {
const currentBoard = Utils.getCurrentBoard();
currentBoard.setBackgroundImageURL("");
Utils.setBackgroundImage("");
Popup.back();
Utils.reload();
event.preventDefault();
},
});
Template.attachmentRenamePopup.helpers({
BlazeComponent.extendComponent({
getNameWithoutExtension() {
const ret = this.name.replace(new RegExp("\\." + this.extension + "$"), "");
const ret = this.data().name.replace(new RegExp("\." + this.data().extension + "$"), "");
return ret;
},
});
Template.attachmentRenamePopup.events({
'keydown input.js-edit-attachment-name'(evt, tpl) {
// enter = save
if (evt.keyCode === 13) {
tpl.find('button[type=submit]').click();
}
},
'click button.js-submit-edit-attachment-name'(event, tpl) {
// save button pressed
event.preventDefault();
const name = tpl.$('.js-edit-attachment-name')[0]
.value
.trim() + this.extensionWithDot;
if (name === sanitizeText(name)) {
Meteor.call('renameAttachment', this._id, name);
}
Popup.back();
},
});
// Template helpers for attachment migration status
Template.registerHelper('attachmentMigrationStatus', function(attachmentId) {
return attachmentMigrationManager.getAttachmentMigrationStatus(attachmentId);
});
Template.registerHelper('isAttachmentMigrating', function(attachmentId) {
return attachmentMigrationManager.isAttachmentBeingMigrated(attachmentId);
});
Template.registerHelper('attachmentMigrationProgress', function() {
return attachmentMigrationManager.attachmentMigrationProgress.get();
});
Template.registerHelper('attachmentMigrationStatusText', function() {
return attachmentMigrationManager.attachmentMigrationStatus.get();
});
events() {
return [
{
'keydown input.js-edit-attachment-name'(evt) {
// enter = save
if (evt.keyCode === 13) {
this.find('button[type=submit]').click();
}
},
'click button.js-submit-edit-attachment-name'(event) {
// save button pressed
event.preventDefault();
const name = this.$('.js-edit-attachment-name')[0]
.value
.trim() + this.data().extensionWithDot;
if (name === DOMPurify.sanitize(name)) {
Meteor.call('renameAttachment', this.data()._id, name);
}
Popup.back();
},
}
]
}
}).register('attachmentRenamePopup');
+7 -22
View File
@@ -22,7 +22,7 @@ template(name="cardCustomField-text")
= value
.edit-controls.clearfix
button.primary(type="submit") {{_ 'save'}}
a.js-close-inlined-form
a.fa.fa-times-thin.js-close-inlined-form
else
a.js-open-inlined-form
if value
@@ -41,7 +41,7 @@ template(name="cardCustomField-number")
input(type="number" value=data.value)
.edit-controls.clearfix
button.primary(type="submit") {{_ 'save'}}
a.js-close-inlined-form
a.fa.fa-times-thin.js-close-inlined-form
else
a.js-open-inlined-form
if value
@@ -56,9 +56,9 @@ template(name="cardCustomField-checkbox")
.js-checklist-item.checklist-item(class="{{#if data.value }}is-checked{{/if}}")
if canModifyCard
.check-box-container
.check-box.materialCheckBox(class="{{#if data.value}}is-checked{{/if}}")
.check-box.materialCheckBox(class="{{#if data.value }}is-checked{{/if}}")
else
.materialCheckBox(class="{{#if data.value}}is-checked{{/if}}")
.materialCheckBox(class="{{#if data.value }}is-checked{{/if}}")
template(name="cardCustomField-currency")
if canModifyCard
@@ -66,7 +66,7 @@ template(name="cardCustomField-currency")
input(type="text" value=data.value autofocus)
.edit-controls.clearfix
button.primary(type="submit") {{_ 'save'}}
a.js-close-inlined-form
a.fa.fa-times-thin.js-close-inlined-form
else
a.js-open-inlined-form
if value
@@ -98,21 +98,6 @@ template(name="cardCustomField-date")
b
| {{showWeek}}
template(name="cardCustomField-datePopup")
.datepicker-container
form.edit-date
.fields
.left
label(for="date") {{_ 'date'}}
input.js-date-field#date(type="date" name="date" value=showDate autofocus)
.right
label(for="time") {{_ 'time'}}
input.js-time-field#time(type="time" name="time" value=showTime)
if error.get
.warning {{_ error.get}}
button.primary.wide.left.js-submit-date(type="submit") {{_ 'save'}}
button.js-delete-date.negate.wide.right.js-delete-date {{_ 'delete'}}
template(name="cardCustomField-dropdown")
if canModifyCard
+inlinedForm(classNames="js-card-customfield-dropdown")
@@ -128,7 +113,7 @@ template(name="cardCustomField-dropdown")
= name
.edit-controls.clearfix
button.primary(type="submit") {{_ 'save'}}
a.js-close-inlined-form
a.fa.fa-times-thin.js-close-inlined-form
else
a.js-open-inlined-form
if value
@@ -149,7 +134,7 @@ template(name="cardCustomField-stringtemplate")
input.js-card-customfield-stringtemplate-item.last(type="text" value="" placeholder="{{_ 'custom-field-stringtemplate-item-placeholder'}}" autofocus)
.edit-controls.clearfix
button.primary(type="submit") {{_ 'save'}}
a.js-close-inlined-form
a.fa.fa-times-thin.js-close-inlined-form
else
a.js-open-inlined-form
if value
+242 -250
View File
@@ -1,41 +1,12 @@
import moment from 'moment/min/moment-with-locales';
import { TAPi18n } from '/imports/i18n';
import {
setupDatePicker,
datePickerRendered,
datePickerHelpers,
datePickerEvents,
} from '/client/lib/datepicker';
import { ReactiveCache } from '/imports/reactiveCache';
import {
formatDateTime,
formatDate,
formatDateByUserPreference,
formatTime,
getISOWeek,
isValidDate,
isBefore,
isAfter,
isSame,
add,
subtract,
startOf,
endOf,
format,
parseDate,
now,
createDate,
fromNow,
calendar
} from '/imports/lib/dateUtils';
import { DatePicker } from '/client/lib/datepicker';
import Cards from '/models/cards';
import { CustomFieldStringTemplate } from '/client/lib/customFields'
import { getCurrentCardFromContext } from '/client/lib/currentCard';
import { EscapeActions } from '/client/lib/escapeActions';
import { getSidebarInstance } from '/client/features/sidebar/service';
Template.cardCustomFieldsPopup.helpers({
hasCustomField() {
const card = getCurrentCardFromContext();
if (!card) return false;
const card = Utils.getCurrentCard();
const customFieldId = this._id;
return card.customFieldIndex(customFieldId) > -1;
},
@@ -43,294 +14,315 @@ Template.cardCustomFieldsPopup.helpers({
Template.cardCustomFieldsPopup.events({
'click .js-select-field'(event) {
const card = getCurrentCardFromContext();
if (!card) return;
const card = Utils.getCurrentCard();
const customFieldId = this._id;
card.toggleCustomField(customFieldId);
event.preventDefault();
},
'click .js-settings'(event) {
EscapeActions.executeUpTo('detailsPane');
const sidebar = getSidebarInstance();
if (sidebar) {
sidebar.setView('customFields');
}
Sidebar.setView('customFields');
event.preventDefault();
},
});
// cardCustomField
Template.cardCustomField.helpers({
const CardCustomField = BlazeComponent.extendComponent({
getTemplate() {
return `cardCustomField-${this.definition.type}`;
return `cardCustomField-${this.data().definition.type}`;
},
onCreated() {
const self = this;
self.card = Utils.getCurrentCard();
self.customFieldId = this.data()._id;
},
});
Template.cardCustomField.onCreated(function () {
this.card = getCurrentCardFromContext();
this.customFieldId = Template.currentData()._id;
});
CardCustomField.register('cardCustomField');
// cardCustomField-text
Template['cardCustomField-text'].onCreated(function () {
this.card = getCurrentCardFromContext();
this.customFieldId = Template.currentData()._id;
});
(class extends CardCustomField {
onCreated() {
super.onCreated();
}
Template['cardCustomField-text'].events({
'submit .js-card-customfield-text'(event, tpl) {
event.preventDefault();
const value = tpl.currentComponent ? tpl.currentComponent().getValue() : tpl.$('textarea').val();
tpl.card.setCustomField(tpl.customFieldId, value);
},
});
events() {
return [
{
'submit .js-card-customfield-text'(event) {
event.preventDefault();
const value = this.currentComponent().getValue();
this.card.setCustomField(this.customFieldId, value);
},
},
];
}
}.register('cardCustomField-text'));
// cardCustomField-number
Template['cardCustomField-number'].onCreated(function () {
this.card = getCurrentCardFromContext();
this.customFieldId = Template.currentData()._id;
});
(class extends CardCustomField {
onCreated() {
super.onCreated();
}
Template['cardCustomField-number'].events({
'submit .js-card-customfield-number'(event, tpl) {
event.preventDefault();
const value = parseInt(tpl.find('input').value, 10);
tpl.card.setCustomField(tpl.customFieldId, value);
},
});
events() {
return [
{
'submit .js-card-customfield-number'(event) {
event.preventDefault();
const value = parseInt(this.find('input').value, 10);
this.card.setCustomField(this.customFieldId, value);
},
},
];
}
}.register('cardCustomField-number'));
// cardCustomField-checkbox
Template['cardCustomField-checkbox'].onCreated(function () {
this.card = getCurrentCardFromContext();
this.customFieldId = Template.currentData()._id;
});
(class extends CardCustomField {
onCreated() {
super.onCreated();
}
Template['cardCustomField-checkbox'].events({
'click .js-checklist-item .check-box-container'(event, tpl) {
tpl.card.setCustomField(tpl.customFieldId, !Template.currentData().value);
},
});
toggleItem() {
this.card.setCustomField(this.customFieldId, !this.data().value);
}
events() {
return [
{
'click .js-checklist-item .check-box-container': this.toggleItem,
},
];
}
}.register('cardCustomField-checkbox'));
// cardCustomField-currency
Template['cardCustomField-currency'].onCreated(function () {
this.card = getCurrentCardFromContext();
this.customFieldId = Template.currentData()._id;
this.currencyCode = Template.currentData().definition.settings.currencyCode;
});
(class extends CardCustomField {
onCreated() {
super.onCreated();
this.currencyCode = this.data().definition.settings.currencyCode;
}
Template['cardCustomField-currency'].helpers({
formattedValue() {
const locale = TAPi18n.getLanguage();
const tpl = Template.instance();
return new Intl.NumberFormat(locale, {
style: 'currency',
currency: tpl.currencyCode,
}).format(this.value);
},
});
currency: this.currencyCode,
}).format(this.data().value);
}
Template['cardCustomField-currency'].events({
'submit .js-card-customfield-currency'(event, tpl) {
event.preventDefault();
// To allow input separated by comma, the comma is replaced by a period.
const value = Number(tpl.find('input').value.replace(/,/i, '.'), 10);
tpl.card.setCustomField(tpl.customFieldId, value);
},
});
events() {
return [
{
'submit .js-card-customfield-currency'(event) {
event.preventDefault();
// To allow input separated by comma, the comma is replaced by a period.
const value = Number(this.find('input').value.replace(/,/i, '.'), 10);
this.card.setCustomField(this.customFieldId, value);
},
},
];
}
}.register('cardCustomField-currency'));
// cardCustomField-date
Template['cardCustomField-date'].onCreated(function () {
this.card = getCurrentCardFromContext();
this.customFieldId = Template.currentData()._id;
const self = this;
self.date = ReactiveVar();
self.now = ReactiveVar(now());
window.setInterval(() => {
self.now.set(now());
}, 60000);
(class extends CardCustomField {
onCreated() {
super.onCreated();
const self = this;
self.date = ReactiveVar();
self.now = ReactiveVar(moment());
window.setInterval(() => {
self.now.set(moment());
}, 60000);
self.autorun(() => {
self.date.set(new Date(Template.currentData().value));
});
});
self.autorun(() => {
self.date.set(moment(self.data().value));
});
}
Template['cardCustomField-date'].helpers({
showWeek() {
return getISOWeek(Template.instance().date.get()).toString();
},
return this.date.get().week().toString();
}
showWeekOfYear() {
const user = ReactiveCache.getCurrentUser();
if (!user) {
return false;
}
return user.isShowWeekOfYear();
},
return ReactiveCache.getCurrentUser().isShowWeekOfYear();
}
showDate() {
const currentUser = ReactiveCache.getCurrentUser();
const dateFormat = currentUser ? currentUser.getDateFormat() : (window.localStorage.getItem('dateFormat') || 'YYYY-MM-DD');
return formatDateByUserPreference(Template.instance().date.get(), dateFormat, true);
},
// this will start working once mquandalle:moment
// is updated to at least moment.js 2.10.5
// until then, the date is displayed in the "L" format
return this.date.get().calendar(null, {
sameElse: 'llll',
});
}
showISODate() {
return Template.instance().date.get().toISOString();
},
return this.date.get().toISOString();
}
classes() {
const tpl = Template.instance();
if (
isBefore(tpl.date.get(), tpl.now.get(), 'minute') &&
isBefore(tpl.now.get(), this.value, 'minute')
this.date.get().isBefore(this.now.get(), 'minute') &&
this.now.get().isBefore(this.data().value)
) {
return 'current';
}
return '';
},
showTitle() {
return `${TAPi18n.__('card-start-on')} ${Template.instance().date.get().toLocaleString()}`;
},
});
}
Template['cardCustomField-date'].events({
'click .js-edit-date': Popup.open('cardCustomField-date'),
});
showTitle() {
return `${TAPi18n.__('card-start-on')} ${this.date.get().format('LLLL')}`;
}
events() {
return [
{
'click .js-edit-date': Popup.open('cardCustomField-date'),
},
];
}
}.register('cardCustomField-date'));
// cardCustomField-datePopup
Template['cardCustomField-datePopup'].onCreated(function () {
const data = Template.currentData();
setupDatePicker(this, {
initialDate: data.value ? data.value : undefined,
});
// Override card and store customFieldId for store/delete callbacks
this.datePicker.card = getCurrentCardFromContext();
this.customFieldId = data._id;
});
(class extends DatePicker {
onCreated() {
super.onCreated();
const self = this;
self.card = Utils.getCurrentCard();
self.customFieldId = this.data()._id;
this.data().value && this.date.set(moment(this.data().value));
}
Template['cardCustomField-datePopup'].onRendered(function () {
datePickerRendered(this);
});
_storeDate(date) {
this.card.setCustomField(this.customFieldId, date);
}
Template['cardCustomField-datePopup'].helpers(datePickerHelpers());
Template['cardCustomField-datePopup'].events(datePickerEvents({
storeDate(date) {
this.datePicker.card.setCustomField(this.customFieldId, date);
},
deleteDate() {
this.datePicker.card.setCustomField(this.customFieldId, '');
},
}));
_deleteDate() {
this.card.setCustomField(this.customFieldId, '');
}
}.register('cardCustomField-datePopup'));
// cardCustomField-dropdown
Template['cardCustomField-dropdown'].onCreated(function () {
this.card = getCurrentCardFromContext();
this.customFieldId = Template.currentData()._id;
this._items = Template.currentData().definition.settings.dropdownItems;
this.items = this._items.slice(0);
this.items.unshift({
_id: '',
name: TAPi18n.__('custom-field-dropdown-none'),
});
});
(class extends CardCustomField {
onCreated() {
super.onCreated();
this._items = this.data().definition.settings.dropdownItems;
this.items = this._items.slice(0);
this.items.unshift({
_id: '',
name: TAPi18n.__('custom-field-dropdown-none'),
});
}
Template['cardCustomField-dropdown'].helpers({
items() {
return Template.instance().items;
},
selectedItem() {
const tpl = Template.instance();
const selected = tpl._items.find(item => {
return item._id === this.value;
const selected = this._items.find(item => {
return item._id === this.data().value;
});
return selected
? selected.name
: TAPi18n.__('custom-field-dropdown-unknown');
},
});
}
Template['cardCustomField-dropdown'].events({
'submit .js-card-customfield-dropdown'(event, tpl) {
event.preventDefault();
const value = tpl.find('select').value;
tpl.card.setCustomField(tpl.customFieldId, value);
},
});
events() {
return [
{
'submit .js-card-customfield-dropdown'(event) {
event.preventDefault();
const value = this.find('select').value;
this.card.setCustomField(this.customFieldId, value);
},
},
];
}
}.register('cardCustomField-dropdown'));
// cardCustomField-stringtemplate
Template['cardCustomField-stringtemplate'].onCreated(function () {
this.card = getCurrentCardFromContext();
this.customFieldId = Template.currentData()._id;
this.customField = new CustomFieldStringTemplate(Template.currentData().definition);
this.stringtemplateItems = new ReactiveVar(Template.currentData().value ?? []);
});
class CardCustomFieldStringTemplate extends CardCustomField {
onCreated() {
super.onCreated();
this.customField = new CustomFieldStringTemplate(this.data().definition);
this.stringtemplateItems = new ReactiveVar(this.data().value ?? []);
}
Template['cardCustomField-stringtemplate'].helpers({
formattedValue() {
const tpl = Template.instance();
const ret = tpl.customField.getFormattedValue(this.value);
const ret = this.customField.getFormattedValue(this.data().value);
return ret;
},
stringtemplateItems() {
return Template.instance().stringtemplateItems.get();
},
});
}
Template['cardCustomField-stringtemplate'].events({
'submit .js-card-customfield-stringtemplate'(event, tpl) {
event.preventDefault();
const items = tpl.stringtemplateItems.get();
tpl.card.setCustomField(tpl.customFieldId, items);
},
getItems() {
return Array.from(this.findAll('input'))
.map(input => input.value)
.filter(value => !!value.trim());
}
'keydown .js-card-customfield-stringtemplate-item'(event, tpl) {
if (event.keyCode === 13) {
event.preventDefault();
events() {
return [
{
'submit .js-card-customfield-stringtemplate'(event) {
event.preventDefault();
const items = this.stringtemplateItems.get();
this.card.setCustomField(this.customFieldId, items);
},
if (event.target.value.trim() || event.metaKey || event.ctrlKey) {
const inputLast = tpl.find('input.last');
'keydown .js-card-customfield-stringtemplate-item'(event) {
if (event.keyCode === 13) {
event.preventDefault();
let items = Array.from(tpl.findAll('input'))
.map(input => input.value)
.filter(value => !!value.trim());
if (event.target.value.trim() || event.metaKey || event.ctrlKey) {
const inputLast = this.find('input.last');
if (event.target === inputLast) {
inputLast.value = '';
} else if (event.target.nextSibling === inputLast) {
inputLast.focus();
} else {
event.target.blur();
let items = this.getItems();
const idx = Array.from(tpl.findAll('input')).indexOf(
event.target,
);
items.splice(idx + 1, 0, '');
if (event.target === inputLast) {
inputLast.value = '';
} else if (event.target.nextSibling === inputLast) {
inputLast.focus();
} else {
event.target.blur();
Tracker.afterFlush(() => {
const element = tpl.findAll('input')[idx + 1];
element.focus();
element.value = '';
});
}
const idx = Array.from(this.findAll('input')).indexOf(
event.target,
);
items.splice(idx + 1, 0, '');
tpl.stringtemplateItems.set(items);
}
if (event.metaKey || event.ctrlKey) {
tpl.find('button[type=submit]').click();
}
}
},
Tracker.afterFlush(() => {
const element = this.findAll('input')[idx + 1];
element.focus();
element.value = '';
});
}
'blur .js-card-customfield-stringtemplate-item'(event, tpl) {
if (
!event.target.value.trim() ||
event.target === tpl.find('input.last')
) {
const items = Array.from(tpl.findAll('input'))
.map(input => input.value)
.filter(value => !!value.trim());
tpl.stringtemplateItems.set(items);
tpl.find('input.last').value = '';
}
},
this.stringtemplateItems.set(items);
}
if (event.metaKey || event.ctrlKey) {
this.find('button[type=submit]').click();
}
}
},
'click .js-close-inlined-form'(event, tpl) {
tpl.stringtemplateItems.set(Template.currentData().value ?? []);
},
});
'blur .js-card-customfield-stringtemplate-item'(event) {
if (
!event.target.value.trim() ||
event.target === this.find('input.last')
) {
const items = this.getItems();
this.stringtemplateItems.set(items);
this.find('input.last').value = '';
}
},
'click .js-close-inlined-form'(event) {
this.stringtemplateItems.set(this.data().value ?? []);
},
},
];
}
}
CardCustomFieldStringTemplate.register('cardCustomField-stringtemplate');
+26 -103
View File
@@ -8,134 +8,57 @@
.card-date.is-active {
background-color: #b3b3b3;
}
/* Date status colors - red = overdue, amber = due soon, no shade = not due */
.card-date.overdue {
background-color: #ff4444; /* Red for overdue */
.card-date.current,
.card-date.almost-due,
.card-date.due,
.card-date.long-overdue {
color: #fff;
}
.card-date.overdue:hover,
.card-date.overdue.is-active {
background-color: #cc3333;
}
.card-date.due-soon {
background-color: #ffaa00; /* Amber for due soon */
color: #000;
}
.card-date.due-soon:hover,
.card-date.due-soon.is-active {
background-color: #e69900;
}
.card-date.not-due {
/* No special background - uses default date type colors */
}
.card-date.current {
background-color: #5ba639; /* Green for current/active */
color: #fff;
background-color: #5ba639;
}
.card-date.current:hover,
.card-date.current.is-active {
background-color: #46802c;
}
.card-date.completed {
background-color: #90ee90; /* Light green for completed */
color: #000;
.card-date.almost-due {
background-color: #edc909;
}
.card-date.completed:hover,
.card-date.completed.is-active {
background-color: #7dd87d;
.card-date.almost-due:hover,
.card-date.almost-due.is-active {
background-color: #bc9f07;
}
.card-date.completed-early {
background-color: #4caf50; /* Green for completed early */
color: #fff;
.card-date.due {
background-color: #fa3f00;
}
.card-date.completed-early:hover,
.card-date.completed-early.is-active {
background-color: #45a049;
.card-date.due:hover,
.card-date.due.is-active {
background-color: #c73200;
}
.card-date.completed-late {
background-color: #ff9800; /* Orange for completed late */
color: #fff;
.card-date.long-overdue {
background-color: #fd5d47;
}
.card-date.completed-late:hover,
.card-date.completed-late.is-active {
background-color: #f57c00;
}
.card-date.completed-on-time {
background-color: #2196f3; /* Blue for completed on time */
color: #fff;
}
.card-date.completed-on-time:hover,
.card-date.completed-on-time.is-active {
background-color: #1976d2;
}
/* Date type specific colors */
.card-date.received-date {
background-color: #dbdbdb; /* Light grey for received */
}
.card-date.received-date:hover,
.card-date.received-date.is-active {
background-color: #b3b3b3;
}
.card-date.start-date {
background-color: #90ee90; /* Light green for start */
color: #000; /* Black text for start */
}
.card-date.start-date:hover,
.card-date.start-date.is-active {
background-color: #7dd87d;
}
.card-date.due-date {
background-color: #ffd700; /* Yellow for due */
color: #000; /* Black text for due */
}
.card-date.due-date:hover,
.card-date.due-date.is-active {
background-color: #e6c200;
}
.card-date.end-date {
background-color: #ffb3b3; /* Light red for end */
color: #000; /* Black text for end */
}
.card-date.end-date:hover,
.card-date.end-date.is-active {
background-color: #ff9999;
.card-date.long-overdue:hover,
.card-date.long-overdue.is-active {
background-color: #fd3e24;
}
.card-date.end-date time::before {
content: "🏁"; /* Finish flag - represents end/completion */
content: "\f253";
}
.card-date.due-date time::before {
content: "⏰"; /* Alarm clock - represents due/deadline */
content: "\f090";
}
.card-date.start-date time::before {
content: "🚀"; /* Rocket - represents start/launch */
content: "\f251";
}
.card-date.received-date time::before {
content: "📥"; /* Inbox tray - represents received/incoming */
}
/* Generic date badge and custom field date */
.card-date:not(.received-date):not(.start-date):not(.due-date):not(.end-date) time::before {
/*content: "📅"; // Calendar - represents generic date */
content: "\f08b";
}
.card-date time::before {
font: normal normal normal 14px/1 FontAwesome;
font-size: inherit;
-webkit-font-smoothing: antialiased;
margin-right: 0.3em;
display: inline-block;
}
.customfield-date {
display: block;
-266
View File
@@ -14,70 +14,6 @@ template(name="dateBadge")
b
| {{showWeek}}
template(name="cardReceivedDate")
if canModifyCard
a.js-edit-date.card-date(title="{{showTitle}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
b
| {{showWeek}}
else
a.card-date(title="{{showTitle}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
b
| {{showWeek}}
template(name="cardStartDate")
if canModifyCard
a.js-edit-date.card-date(title="{{showTitle}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
b
| {{showWeek}}
else
a.card-date(title="{{showTitle}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
b
| {{showWeek}}
template(name="cardDueDate")
if canModifyCard
a.js-edit-date.card-date(title="{{showTitle}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
b
| {{showWeek}}
else
a.card-date(title="{{showTitle}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
b
| {{showWeek}}
template(name="cardEndDate")
if canModifyCard
a.js-edit-date.card-date(title="{{showTitle}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
b
| {{showWeek}}
else
a.card-date(title="{{showTitle}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
b
| {{showWeek}}
template(name="dateCustomField")
a(title="{{showTitle}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
@@ -85,205 +21,3 @@ template(name="dateCustomField")
if showWeekOfYear
b
| {{showWeek}}
template(name="cardCustomFieldDate")
a(title="{{showTitle}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
b
| {{showWeek}}
template(name="voteEndDate")
if canModifyCard
a.js-edit-date.card-date(title="{{showTitle}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
b
| {{showWeek}}
else
a.card-date(title="{{showTitle}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
b
| {{showWeek}}
template(name="pokerEndDate")
if canModifyCard
a.js-edit-date.card-date(title="{{showTitle}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
b
| {{showWeek}}
else
a.card-date(title="{{showTitle}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
b
| {{showWeek}}
template(name="minicardReceivedDate")
if canModifyCard
a.js-edit-date.card-date.received-date(title="{{_ 'card-received'}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
b
| {{showWeek}}
else
a.card-date.received-date(title="{{_ 'card-received'}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
b
| {{showWeek}}
template(name="minicardStartDate")
if canModifyCard
a.js-edit-date.card-date.start-date(title="{{_ 'card-start'}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
b
| {{showWeek}}
else
a.card-date.start-date(title="{{_ 'card-start'}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
b
| {{showWeek}}
template(name="minicardDueDate")
if canModifyCard
a.js-edit-date.card-date.due-date(title="{{_ 'card-due'}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
b
| {{showWeek}}
else
a.card-date.due-date(title="{{_ 'card-due'}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
b
| {{showWeek}}
template(name="minicardEndDate")
if canModifyCard
a.js-edit-date.card-date.end-date(title="{{_ 'card-end'}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
b
| {{showWeek}}
else
a.card-date.end-date(title="{{_ 'card-end'}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
b
| {{showWeek}}
template(name="minicardCustomFieldDate")
a(title="{{_ 'date'}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
b
| {{showWeek}}
template(name="editCardReceivedDatePopup")
.datepicker-container
form.edit-date
.fields
.left
label(for="date") {{_ 'date'}}
input.js-date-field#date(type="date" name="date" value=showDate autofocus)
.right
label(for="time") {{_ 'time'}}
input.js-time-field#time(type="time" name="time" value=showTime)
if error.get
.warning {{_ error.get}}
button.primary.wide.left.js-submit-date(type="submit") {{_ 'save'}}
button.js-delete-date.negate.wide.right.js-delete-date {{_ 'delete'}}
template(name="editCardStartDatePopup")
.datepicker-container
form.edit-date
.fields
.left
label(for="date") {{_ 'date'}}
input.js-date-field#date(type="date" name="date" value=showDate autofocus)
.right
label(for="time") {{_ 'time'}}
input.js-time-field#time(type="time" name="time" value=showTime)
if error.get
.warning {{_ error.get}}
button.primary.wide.left.js-submit-date(type="submit") {{_ 'save'}}
button.js-delete-date.negate.wide.right.js-delete-date {{_ 'delete'}}
template(name="editCardDueDatePopup")
.datepicker-container
form.edit-date
.fields
.left
label(for="date") {{_ 'date'}}
input.js-date-field#date(type="date" name="date" value=showDate autofocus)
.right
label(for="time") {{_ 'time'}}
input.js-time-field#time(type="time" name="time" value=showTime)
if error.get
.warning {{_ error.get}}
button.primary.wide.left.js-submit-date(type="submit") {{_ 'save'}}
button.js-delete-date.negate.wide.right.js-delete-date {{_ 'delete'}}
template(name="editCardEndDatePopup")
.datepicker-container
form.edit-date
.fields
.left
label(for="date") {{_ 'date'}}
input.js-date-field#date(type="date" name="date" value=showDate autofocus)
.right
label(for="time") {{_ 'time'}}
input.js-time-field#time(type="time" name="time" value=showTime)
if error.get
.warning {{_ error.get}}
button.primary.wide.left.js-submit-date(type="submit") {{_ 'save'}}
button.js-delete-date.negate.wide.right.js-delete-date {{_ 'delete'}}
template(name="editVoteEndDatePopup")
.datepicker-container
form.edit-date
.fields
.left
label(for="date") {{_ 'date'}}
input.js-date-field#date(type="date" name="date" value=showDate autofocus)
.right
label(for="time") {{_ 'time'}}
input.js-time-field#time(type="time" name="time" value=showTime)
if error.get
.warning {{_ error.get}}
button.primary.wide.left.js-submit-date(type="submit") {{_ 'save'}}
button.js-delete-date.negate.wide.right.js-delete-date {{_ 'delete'}}
template(name="editPokerEndDatePopup")
.datepicker-container
form.edit-date
.fields
.left
label(for="date") {{_ 'date'}}
input.js-date-field#date(type="date" name="date" value=showDate autofocus)
.right
label(for="time") {{_ 'time'}}
input.js-time-field#time(type="time" name="time" value=showTime)
if error.get
.warning {{_ error.get}}
button.primary.wide.left.js-submit-date(type="submit") {{_ 'save'}}
button.js-delete-date.negate.wide.right.js-delete-date {{_ 'delete'}}
+330 -573
View File
@@ -1,642 +1,399 @@
import moment from 'moment/min/moment-with-locales';
import { TAPi18n } from '/imports/i18n';
import { ReactiveCache } from '/imports/reactiveCache';
import {
setupDatePicker,
datePickerRendered,
datePickerHelpers,
datePickerEvents,
} from '/client/lib/datepicker';
import {
formatDateTime,
formatDate,
formatDateByUserPreference,
formatTime,
getISOWeek,
isValidDate,
isBefore,
isAfter,
isSame,
add,
subtract,
startOf,
endOf,
format,
parseDate,
now,
createDate,
fromNow,
calendar,
diff
} from '/imports/lib/dateUtils';
// --- DatePicker popups (edit date forms) ---
import { DatePicker } from '/client/lib/datepicker';
// editCardReceivedDatePopup
Template.editCardReceivedDatePopup.onCreated(function () {
const card = Template.currentData();
setupDatePicker(this, {
defaultTime: formatDateTime(now()),
initialDate: card.getReceived() ? card.getReceived() : undefined,
});
});
(class extends DatePicker {
onCreated() {
super.onCreated(moment().format('YYYY-MM-DD HH:mm'));
this.data().getReceived() &&
this.date.set(moment(this.data().getReceived()));
}
Template.editCardReceivedDatePopup.onRendered(function () {
datePickerRendered(this);
});
_storeDate(date) {
this.card.setReceived(moment(date).format('YYYY-MM-DD HH:mm'));
}
Template.editCardReceivedDatePopup.helpers(datePickerHelpers());
Template.editCardReceivedDatePopup.events(datePickerEvents({
storeDate(date) {
this.datePicker.card.setReceived(date);
},
deleteDate() {
this.datePicker.card.unsetReceived();
},
}));
_deleteDate() {
this.card.unsetReceived();
}
}.register('editCardReceivedDatePopup'));
// editCardStartDatePopup
Template.editCardStartDatePopup.onCreated(function () {
const card = Template.currentData();
setupDatePicker(this, {
defaultTime: formatDateTime(now()),
initialDate: card.getStart() ? card.getStart() : undefined,
});
});
(class extends DatePicker {
onCreated() {
super.onCreated(moment().format('YYYY-MM-DD HH:mm'));
this.data().getStart() && this.date.set(moment(this.data().getStart()));
}
Template.editCardStartDatePopup.onRendered(function () {
datePickerRendered(this);
});
onRendered() {
super.onRendered();
if (moment.isDate(this.card.getReceived())) {
this.$('.js-datepicker').datepicker(
'setStartDate',
this.card.getReceived(),
);
}
}
Template.editCardStartDatePopup.helpers(datePickerHelpers());
_storeDate(date) {
this.card.setStart(moment(date).format('YYYY-MM-DD HH:mm'));
}
Template.editCardStartDatePopup.events(datePickerEvents({
storeDate(date) {
this.datePicker.card.setStart(date);
},
deleteDate() {
this.datePicker.card.unsetStart();
},
}));
_deleteDate() {
this.card.unsetStart();
}
}.register('editCardStartDatePopup'));
// editCardDueDatePopup
Template.editCardDueDatePopup.onCreated(function () {
const card = Template.currentData();
setupDatePicker(this, {
defaultTime: '1970-01-01 17:00:00',
initialDate: card.getDue() ? card.getDue() : undefined,
});
});
(class extends DatePicker {
onCreated() {
super.onCreated('1970-01-01 17:00:00');
this.data().getDue() && this.date.set(moment(this.data().getDue()));
}
Template.editCardDueDatePopup.onRendered(function () {
datePickerRendered(this);
});
onRendered() {
super.onRendered();
if (moment.isDate(this.card.getStart())) {
this.$('.js-datepicker').datepicker('setStartDate', this.card.getStart());
}
}
Template.editCardDueDatePopup.helpers(datePickerHelpers());
_storeDate(date) {
this.card.setDue(moment(date).format('YYYY-MM-DD HH:mm'));
}
Template.editCardDueDatePopup.events(datePickerEvents({
storeDate(date) {
this.datePicker.card.setDue(date);
},
deleteDate() {
this.datePicker.card.unsetDue();
},
}));
_deleteDate() {
this.card.unsetDue();
}
}.register('editCardDueDatePopup'));
// editCardEndDatePopup
Template.editCardEndDatePopup.onCreated(function () {
const card = Template.currentData();
setupDatePicker(this, {
defaultTime: formatDateTime(now()),
initialDate: card.getEnd() ? card.getEnd() : undefined,
});
});
(class extends DatePicker {
onCreated() {
super.onCreated(moment().format('YYYY-MM-DD HH:mm'));
this.data().getEnd() && this.date.set(moment(this.data().getEnd()));
}
Template.editCardEndDatePopup.onRendered(function () {
datePickerRendered(this);
});
Template.editCardEndDatePopup.helpers(datePickerHelpers());
Template.editCardEndDatePopup.events(datePickerEvents({
storeDate(date) {
this.datePicker.card.setEnd(date);
},
deleteDate() {
this.datePicker.card.unsetEnd();
},
}));
// --- Card date badge display helpers ---
// Shared onCreated logic for card date badge templates
function cardDateOnCreated(tpl) {
tpl.date = new ReactiveVar();
tpl.now = new ReactiveVar(now());
window.setInterval(() => {
tpl.now.set(now());
}, 60000);
}
// Shared helpers for card date badge templates
function cardDateHelpers(extraHelpers) {
const base = {
showWeek() {
return getISOWeek(Template.instance().date.get()).toString();
},
showWeekOfYear() {
const user = ReactiveCache.getCurrentUser();
if (!user) return window.localStorage.getItem('showWeekOfYear') === 'true';
return user.isShowWeekOfYear();
},
showDate() {
const currentUser = ReactiveCache.getCurrentUser();
const dateFormat = currentUser ? currentUser.getDateFormat() : (window.localStorage.getItem('dateFormat') || 'YYYY-MM-DD');
return formatDateByUserPreference(Template.instance().date.get(), dateFormat, true);
},
showISODate() {
return Template.instance().date.get().toISOString();
},
};
return Object.assign(base, extraHelpers);
}
// cardReceivedDate
Template.cardReceivedDate.onCreated(function () {
cardDateOnCreated(this);
const self = this;
self.autorun(() => {
self.date.set(new Date(Template.currentData().getReceived()));
});
});
Template.cardReceivedDate.helpers(cardDateHelpers({
classes() {
const tpl = Template.instance();
let classes = 'received-date ';
const data = Template.currentData();
const dueAt = data.getDue();
const endAt = data.getEnd();
const startAt = data.getStart();
const theDate = tpl.date.get();
if (
(startAt && isAfter(theDate, startAt)) ||
(endAt && isAfter(theDate, endAt)) ||
(dueAt && isAfter(theDate, dueAt))
) {
classes += 'overdue';
} else {
classes += 'not-due';
onRendered() {
super.onRendered();
if (moment.isDate(this.card.getStart())) {
this.$('.js-datepicker').datepicker('setStartDate', this.card.getStart());
}
return classes;
}
_storeDate(date) {
this.card.setEnd(moment(date).format('YYYY-MM-DD HH:mm'));
}
_deleteDate() {
this.card.unsetEnd();
}
}.register('editCardEndDatePopup'));
// Display received, start, due & end dates
const CardDate = BlazeComponent.extendComponent({
template() {
return 'dateBadge';
},
showTitle() {
const tpl = Template.instance();
const currentUser = ReactiveCache.getCurrentUser();
const dateFormat = currentUser ? currentUser.getDateFormat() : (window.localStorage.getItem('dateFormat') || 'YYYY-MM-DD');
const formattedDate = formatDateByUserPreference(tpl.date.get(), dateFormat, true);
return `${TAPi18n.__('card-received-on')} ${formattedDate}`;
onCreated() {
const self = this;
self.date = ReactiveVar();
self.now = ReactiveVar(moment());
window.setInterval(() => {
self.now.set(moment());
}, 60000);
},
}));
Template.cardReceivedDate.events({
'click .js-edit-date': Popup.open('editCardReceivedDate'),
});
// cardStartDate
Template.cardStartDate.onCreated(function () {
cardDateOnCreated(this);
const self = this;
self.autorun(() => {
self.date.set(new Date(Template.currentData().getStart()));
});
});
Template.cardStartDate.helpers(cardDateHelpers({
classes() {
const tpl = Template.instance();
let classes = 'start-date ';
const data = Template.currentData();
const dueAt = data.getDue();
const endAt = data.getEnd();
const theDate = tpl.date.get();
const nowVal = tpl.now.get();
if ((endAt && isAfter(theDate, endAt)) || (dueAt && isAfter(theDate, dueAt))) {
classes += 'overdue';
} else if (isAfter(theDate, nowVal)) {
classes += 'not-due';
} else {
classes += 'current';
}
return classes;
showWeek() {
return this.date.get().week().toString();
},
showTitle() {
const tpl = Template.instance();
const currentUser = ReactiveCache.getCurrentUser();
const dateFormat = currentUser ? currentUser.getDateFormat() : (window.localStorage.getItem('dateFormat') || 'YYYY-MM-DD');
const formattedDate = formatDateByUserPreference(tpl.date.get(), dateFormat, true);
return `${TAPi18n.__('card-start-on')} ${formattedDate}`;
showWeekOfYear() {
return ReactiveCache.getCurrentUser().isShowWeekOfYear();
},
}));
Template.cardStartDate.events({
'click .js-edit-date': Popup.open('editCardStartDate'),
});
// cardDueDate
Template.cardDueDate.onCreated(function () {
cardDateOnCreated(this);
const self = this;
self.autorun(() => {
self.date.set(new Date(Template.currentData().getDue()));
});
});
Template.cardDueDate.helpers(cardDateHelpers({
classes() {
const tpl = Template.instance();
let classes = 'due-date ';
const data = Template.currentData();
const endAt = data.getEnd();
const theDate = tpl.date.get();
const nowVal = tpl.now.get();
if (endAt && isBefore(endAt, theDate)) {
classes += 'completed-early';
} else if (endAt) {
classes += 'completed';
} else {
const daysDiff = diff(theDate, nowVal, 'days');
if (daysDiff < 0) {
classes += 'overdue';
} else if (daysDiff <= 1) {
classes += 'due-soon';
} else {
classes += 'not-due';
}
}
return classes;
},
showTitle() {
const tpl = Template.instance();
const currentUser = ReactiveCache.getCurrentUser();
const dateFormat = currentUser ? currentUser.getDateFormat() : (window.localStorage.getItem('dateFormat') || 'YYYY-MM-DD');
const formattedDate = formatDateByUserPreference(tpl.date.get(), dateFormat, true);
return `${TAPi18n.__('card-due-on')} ${formattedDate}`;
},
}));
Template.cardDueDate.events({
'click .js-edit-date': Popup.open('editCardDueDate'),
});
// cardEndDate
Template.cardEndDate.onCreated(function () {
cardDateOnCreated(this);
const self = this;
self.autorun(() => {
self.date.set(new Date(Template.currentData().getEnd()));
});
});
Template.cardEndDate.helpers(cardDateHelpers({
classes() {
const tpl = Template.instance();
let classes = 'end-date ';
const data = Template.currentData();
const dueAt = data.getDue();
const theDate = tpl.date.get();
if (!dueAt) {
classes += 'completed';
} else if (isBefore(theDate, dueAt)) {
classes += 'completed-early';
} else if (isAfter(theDate, dueAt)) {
classes += 'completed-late';
} else {
classes += 'completed-on-time';
}
return classes;
},
showTitle() {
const tpl = Template.instance();
return `${TAPi18n.__('card-end-on')} ${format(tpl.date.get(), 'LLLL')}`;
},
}));
Template.cardEndDate.events({
'click .js-edit-date': Popup.open('editCardEndDate'),
});
// cardCustomFieldDate
Template.cardCustomFieldDate.onCreated(function () {
cardDateOnCreated(this);
const self = this;
self.autorun(() => {
self.date.set(new Date(Template.currentData().value));
});
});
Template.cardCustomFieldDate.helpers(cardDateHelpers({
showDate() {
const tpl = Template.instance();
// this will start working once mquandalle:moment
// is updated to at least moment.js 2.10.5
// until then, the date is displayed in the "L" format
return tpl.date.get().calendar(null, {
return this.date.get().calendar(null, {
sameElse: 'llll',
});
},
showTitle() {
const tpl = Template.instance();
const currentUser = ReactiveCache.getCurrentUser();
const dateFormat = currentUser ? currentUser.getDateFormat() : (window.localStorage.getItem('dateFormat') || 'YYYY-MM-DD');
const formattedDate = formatDateByUserPreference(tpl.date.get(), dateFormat, true);
return `${formattedDate}`;
},
classes() {
return 'customfield-date';
},
}));
// --- Minicard date templates ---
// minicardReceivedDate
Template.minicardReceivedDate.onCreated(function () {
cardDateOnCreated(this);
const self = this;
self.autorun(() => {
self.date.set(new Date(Template.currentData().getReceived()));
});
showISODate() {
return this.date.get().toISOString();
},
});
Template.minicardReceivedDate.helpers(cardDateHelpers({
class CardReceivedDate extends CardDate {
onCreated() {
super.onCreated();
const self = this;
self.autorun(() => {
self.date.set(moment(self.data().getReceived()));
});
}
classes() {
const tpl = Template.instance();
let classes = 'received-date ';
const data = Template.currentData();
const dueAt = data.getDue();
const endAt = data.getEnd();
const startAt = data.getStart();
const theDate = tpl.date.get();
const dueAt = this.data().getDue();
const endAt = this.data().getEnd();
const startAt = this.data().getStart();
const theDate = this.date.get();
// if dueAt, endAt and startAt exist & are > receivedAt, receivedAt doesn't need to be flagged
if (
(startAt && isAfter(theDate, startAt)) ||
(endAt && isAfter(theDate, endAt)) ||
(dueAt && isAfter(theDate, dueAt))
) {
classes += 'overdue';
} else {
classes += 'not-due';
}
(startAt && theDate.isAfter(startAt)) ||
(endAt && theDate.isAfter(endAt)) ||
(dueAt && theDate.isAfter(dueAt))
)
classes += 'long-overdue';
else classes += 'current';
return classes;
},
}
showTitle() {
const tpl = Template.instance();
const currentUser = ReactiveCache.getCurrentUser();
const dateFormat = currentUser ? currentUser.getDateFormat() : (window.localStorage.getItem('dateFormat') || 'YYYY-MM-DD');
const formattedDate = formatDateByUserPreference(tpl.date.get(), dateFormat, true);
return `${TAPi18n.__('card-received-on')} ${formattedDate}`;
},
showDate() {
const currentUser = ReactiveCache.getCurrentUser();
const dateFormat = currentUser ? currentUser.getDateFormat() : (window.localStorage.getItem('dateFormat') || 'YYYY-MM-DD');
return formatDateByUserPreference(Template.instance().date.get(), dateFormat, true);
},
}));
return `${TAPi18n.__('card-received-on')} ${this.date
.get()
.format('LLLL')}`;
}
Template.minicardReceivedDate.events({
'click .js-edit-date': Popup.open('editCardReceivedDate'),
});
events() {
return super.events().concat({
'click .js-edit-date': Popup.open('editCardReceivedDate'),
});
}
}
CardReceivedDate.register('cardReceivedDate');
// minicardStartDate
Template.minicardStartDate.onCreated(function () {
cardDateOnCreated(this);
const self = this;
self.autorun(() => {
self.date.set(new Date(Template.currentData().getStart()));
});
});
class CardStartDate extends CardDate {
onCreated() {
super.onCreated();
const self = this;
self.autorun(() => {
self.date.set(moment(self.data().getStart()));
});
}
Template.minicardStartDate.helpers(cardDateHelpers({
classes() {
const tpl = Template.instance();
let classes = 'start-date ';
const data = Template.currentData();
const dueAt = data.getDue();
const endAt = data.getEnd();
const theDate = tpl.date.get();
const nowVal = tpl.now.get();
if ((endAt && isAfter(theDate, endAt)) || (dueAt && isAfter(theDate, dueAt))) {
classes += 'overdue';
} else if (isAfter(theDate, nowVal)) {
classes += 'not-due';
} else {
classes += 'current';
}
let classes = 'start-date' + ' ';
const dueAt = this.data().getDue();
const endAt = this.data().getEnd();
const theDate = this.date.get();
const now = this.now.get();
// if dueAt or endAt exist & are > startAt, startAt doesn't need to be flagged
if ((endAt && theDate.isAfter(endAt)) || (dueAt && theDate.isAfter(dueAt)))
classes += 'long-overdue';
else if (theDate.isAfter(now)) classes += '';
else classes += 'current';
return classes;
},
}
showTitle() {
const tpl = Template.instance();
const currentUser = ReactiveCache.getCurrentUser();
const dateFormat = currentUser ? currentUser.getDateFormat() : (window.localStorage.getItem('dateFormat') || 'YYYY-MM-DD');
const formattedDate = formatDateByUserPreference(tpl.date.get(), dateFormat, true);
return `${TAPi18n.__('card-start-on')} ${formattedDate}`;
},
showDate() {
const currentUser = ReactiveCache.getCurrentUser();
const dateFormat = currentUser ? currentUser.getDateFormat() : (window.localStorage.getItem('dateFormat') || 'YYYY-MM-DD');
return formatDateByUserPreference(Template.instance().date.get(), dateFormat, true);
},
}));
return `${TAPi18n.__('card-start-on')} ${this.date.get().format('LLLL')}`;
}
Template.minicardStartDate.events({
'click .js-edit-date': Popup.open('editCardStartDate'),
});
events() {
return super.events().concat({
'click .js-edit-date': Popup.open('editCardStartDate'),
});
}
}
CardStartDate.register('cardStartDate');
// minicardDueDate
Template.minicardDueDate.onCreated(function () {
cardDateOnCreated(this);
const self = this;
self.autorun(() => {
self.date.set(new Date(Template.currentData().getDue()));
});
});
class CardDueDate extends CardDate {
onCreated() {
super.onCreated();
const self = this;
self.autorun(() => {
self.date.set(moment(self.data().getDue()));
});
}
Template.minicardDueDate.helpers(cardDateHelpers({
classes() {
const tpl = Template.instance();
let classes = 'due-date ';
const data = Template.currentData();
const endAt = data.getEnd();
const theDate = tpl.date.get();
const nowVal = tpl.now.get();
if (endAt && isBefore(endAt, theDate)) {
classes += 'completed-early';
} else if (endAt) {
classes += 'completed';
} else {
const daysDiff = diff(theDate, nowVal, 'days');
if (daysDiff < 0) {
classes += 'overdue';
} else if (daysDiff <= 1) {
classes += 'due-soon';
} else {
classes += 'not-due';
}
}
let classes = 'due-date' + ' ';
const endAt = this.data().getEnd();
const theDate = this.date.get();
const now = this.now.get();
// if the due date is after the end date, green - done early
if (endAt && theDate.isAfter(endAt)) classes += 'current';
// if there is an end date, don't need to flag the due date
else if (endAt) classes += '';
else if (now.diff(theDate, 'days') >= 2) classes += 'long-overdue';
else if (now.diff(theDate, 'minute') >= 0) classes += 'due';
else if (now.diff(theDate, 'days') >= -1) classes += 'almost-due';
return classes;
},
}
showTitle() {
const tpl = Template.instance();
const currentUser = ReactiveCache.getCurrentUser();
const dateFormat = currentUser ? currentUser.getDateFormat() : (window.localStorage.getItem('dateFormat') || 'YYYY-MM-DD');
const formattedDate = formatDateByUserPreference(tpl.date.get(), dateFormat, true);
return `${TAPi18n.__('card-due-on')} ${formattedDate}`;
},
showDate() {
const currentUser = ReactiveCache.getCurrentUser();
const dateFormat = currentUser ? currentUser.getDateFormat() : (window.localStorage.getItem('dateFormat') || 'YYYY-MM-DD');
return formatDateByUserPreference(Template.instance().date.get(), dateFormat, true);
},
}));
return `${TAPi18n.__('card-due-on')} ${this.date.get().format('LLLL')}`;
}
Template.minicardDueDate.events({
'click .js-edit-date': Popup.open('editCardDueDate'),
});
events() {
return super.events().concat({
'click .js-edit-date': Popup.open('editCardDueDate'),
});
}
}
CardDueDate.register('cardDueDate');
// minicardEndDate
Template.minicardEndDate.onCreated(function () {
cardDateOnCreated(this);
const self = this;
self.autorun(() => {
self.date.set(new Date(Template.currentData().getEnd()));
});
});
class CardEndDate extends CardDate {
onCreated() {
super.onCreated();
const self = this;
self.autorun(() => {
self.date.set(moment(self.data().getEnd()));
});
}
Template.minicardEndDate.helpers(cardDateHelpers({
classes() {
const tpl = Template.instance();
let classes = 'end-date ';
const data = Template.currentData();
const dueAt = data.getDue();
const theDate = tpl.date.get();
if (!dueAt) {
classes += 'completed';
} else if (isBefore(theDate, dueAt)) {
classes += 'completed-early';
} else if (isAfter(theDate, dueAt)) {
classes += 'completed-late';
} else {
classes += 'completed-on-time';
}
let classes = 'end-date' + ' ';
const dueAt = this.data().getDue();
const theDate = this.date.get();
if (!dueAt) classes += '';
else if (theDate.isBefore(dueAt)) classes += 'current';
else if (theDate.isAfter(dueAt)) classes += 'due';
return classes;
},
}
showTitle() {
const tpl = Template.instance();
return `${TAPi18n.__('card-end-on')} ${format(tpl.date.get(), 'LLLL')}`;
},
return `${TAPi18n.__('card-end-on')} ${this.date.get().format('LLLL')}`;
}
events() {
return super.events().concat({
'click .js-edit-date': Popup.open('editCardEndDate'),
});
}
}
CardEndDate.register('cardEndDate');
class CardCustomFieldDate extends CardDate {
template() {
return 'dateCustomField';
}
onCreated() {
super.onCreated();
const self = this;
self.autorun(() => {
self.date.set(moment(self.data().value));
});
}
showWeek() {
return this.date.get().week().toString();
}
showWeekOfYear() {
return ReactiveCache.getCurrentUser().isShowWeekOfYear();
}
showDate() {
const currentUser = ReactiveCache.getCurrentUser();
const dateFormat = currentUser ? currentUser.getDateFormat() : (window.localStorage.getItem('dateFormat') || 'YYYY-MM-DD');
return formatDateByUserPreference(Template.instance().date.get(), dateFormat, true);
},
}));
// this will start working once mquandalle:moment
// is updated to at least moment.js 2.10.5
// until then, the date is displayed in the "L" format
return this.date.get().calendar(null, {
sameElse: 'llll',
});
}
Template.minicardEndDate.events({
'click .js-edit-date': Popup.open('editCardEndDate'),
});
// minicardCustomFieldDate
Template.minicardCustomFieldDate.onCreated(function () {
cardDateOnCreated(this);
const self = this;
self.autorun(() => {
self.date.set(new Date(Template.currentData().value));
});
});
Template.minicardCustomFieldDate.helpers(cardDateHelpers({
showDate() {
const currentUser = ReactiveCache.getCurrentUser();
const dateFormat = currentUser ? currentUser.getDateFormat() : (window.localStorage.getItem('dateFormat') || 'YYYY-MM-DD');
return formatDateByUserPreference(Template.instance().date.get(), dateFormat, true);
},
showTitle() {
const tpl = Template.instance();
const currentUser = ReactiveCache.getCurrentUser();
const dateFormat = currentUser ? currentUser.getDateFormat() : (window.localStorage.getItem('dateFormat') || 'YYYY-MM-DD');
const formattedDate = formatDateByUserPreference(tpl.date.get(), dateFormat, true);
return `${formattedDate}`;
},
return `${this.date.get().format('LLLL')}`;
}
classes() {
return 'customfield-date';
},
}));
}
// --- Vote and Poker end date badge templates ---
events() {
return [];
}
}
CardCustomFieldDate.register('cardCustomFieldDate');
// voteEndDate
Template.voteEndDate.onCreated(function () {
cardDateOnCreated(this);
const self = this;
self.autorun(() => {
self.date.set(new Date(Template.currentData().getVoteEnd()));
});
});
Template.voteEndDate.helpers(cardDateHelpers({
classes() {
return 'end-date ';
},
(class extends CardReceivedDate {
showDate() {
const currentUser = ReactiveCache.getCurrentUser();
const dateFormat = currentUser ? currentUser.getDateFormat() : (window.localStorage.getItem('dateFormat') || 'YYYY-MM-DD');
return formatDateByUserPreference(Template.instance().date.get(), dateFormat, true);
},
showTitle() {
const tpl = Template.instance();
return `${TAPi18n.__('card-end-on')} ${tpl.date.get().toLocaleString()}`;
},
}));
return this.date.get().format('L');
}
}.register('minicardReceivedDate'));
Template.voteEndDate.events({
'click .js-edit-date': Popup.open('editVoteEndDate'),
});
// pokerEndDate
Template.pokerEndDate.onCreated(function () {
cardDateOnCreated(this);
const self = this;
self.autorun(() => {
self.date.set(new Date(Template.currentData().getPokerEnd()));
});
});
Template.pokerEndDate.helpers(cardDateHelpers({
classes() {
return 'end-date ';
},
(class extends CardStartDate {
showDate() {
const currentUser = ReactiveCache.getCurrentUser();
const dateFormat = currentUser ? currentUser.getDateFormat() : (window.localStorage.getItem('dateFormat') || 'YYYY-MM-DD');
return formatDateByUserPreference(Template.instance().date.get(), dateFormat, true);
},
showTitle() {
const tpl = Template.instance();
return `${TAPi18n.__('card-end-on')} ${format(tpl.date.get(), 'LLLL')}`;
},
}));
return this.date.get().format('YYYY-MM-DD HH:mm');
}
}.register('minicardStartDate'));
Template.pokerEndDate.events({
'click .js-edit-date': Popup.open('editPokerEndDate'),
});
(class extends CardDueDate {
showDate() {
return this.date.get().format('YYYY-MM-DD HH:mm');
}
}.register('minicardDueDate'));
(class extends CardEndDate {
showDate() {
return this.date.get().format('YYYY-MM-DD HH:mm');
}
}.register('minicardEndDate'));
(class extends CardCustomFieldDate {
showDate() {
return this.date.get().format('L');
}
}.register('minicardCustomFieldDate'));
class VoteEndDate extends CardDate {
onCreated() {
super.onCreated();
const self = this;
self.autorun(() => {
self.date.set(moment(self.data().getVoteEnd()));
});
}
classes() {
const classes = 'end-date' + ' ';
return classes;
}
showDate() {
return this.date.get().format('L LT');
}
showTitle() {
return `${TAPi18n.__('card-end-on')} ${this.date.get().format('LLLL')}`;
}
events() {
return super.events().concat({
'click .js-edit-date': Popup.open('editVoteEndDate'),
});
}
}
VoteEndDate.register('voteEndDate');
class PokerEndDate extends CardDate {
onCreated() {
super.onCreated();
const self = this;
self.autorun(() => {
self.date.set(moment(self.data().getPokerEnd()));
});
}
classes() {
const classes = 'end-date' + ' ';
return classes;
}
showDate() {
return this.date.get().format('l LT');
}
showTitle() {
return `${TAPi18n.__('card-end-on')} ${this.date.get().format('LLLL')}`;
}
events() {
return super.events().concat({
'click .js-edit-date': Popup.open('editPokerEndDate'),
});
}
}
PokerEndDate.register('pokerEndDate');
+28 -20
View File
@@ -1,29 +1,37 @@
const descriptionFormIsOpen = new ReactiveVar(false);
Template.descriptionForm.onDestroyed(function () {
descriptionFormIsOpen.set(false);
$('.note-popover').hide();
});
BlazeComponent.extendComponent({
onDestroyed() {
descriptionFormIsOpen.set(false);
$('.note-popover').hide();
},
Template.descriptionForm.helpers({
descriptionFormIsOpen() {
return descriptionFormIsOpen.get();
},
});
Template.descriptionForm.events({
async 'submit .js-card-description'(event, tpl) {
event.preventDefault();
const description = tpl.currentComponent ? tpl.currentComponent().getValue() : tpl.$('textarea').val();
await this.setDescription(description);
getInput() {
return this.$('.js-new-description-input');
},
// Pressing Ctrl+Enter should submit the form
'keydown form textarea'(evt, tpl) {
if (evt.keyCode === 13 && (evt.metaKey || evt.ctrlKey)) {
const submitButton = tpl.find('button[type=submit]');
if (submitButton) {
submitButton.click();
}
}
events() {
return [
{
'submit .js-card-description'(event) {
event.preventDefault();
const description = this.currentComponent().getValue();
this.data().setDescription(description);
},
// Pressing Ctrl+Enter should submit the form
'keydown form textarea'(evt) {
if (evt.keyCode === 13 && (evt.metaKey || evt.ctrlKey)) {
const submitButton = this.find('button[type=submit]');
if (submitButton) {
submitButton.click();
}
}
},
},
];
},
});
}).register('descriptionForm');
+36 -494
View File
@@ -1,46 +1,16 @@
/* Date Format Selector */
.card-details-item-date-format {
margin-bottom: 12px;
}
.card-details-item-date-format .card-details-item-title {
font-size: 15px;
font-weight: bold;
margin-bottom: 6px;
color: #333;
letter-spacing: 0.03em;
}
.card-details-item-date-format .js-date-format-selector {
width: 100%;
padding: 9px 10px;
border: 1px solid #ddd;
border-radius: 5px;
background-color: #fff;
font-size: 15px;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
}
.card-details-item-date-format .js-date-format-selector:focus {
outline: none;
border-color: #007cba;
box-shadow: 0 0 0 2px rgba(0, 124, 186, 0.2);
}
.assignee {
border-radius: 3px;
display: block;
position: relative;
float: left;
height: clamp(24px, 3.5vw, 36px);
width: clamp(24px, 3.5vw, 36px);
margin: 0.3vh;
height: 30px;
width: 30px;
margin: .3vh;
cursor: pointer;
user-select: none;
z-index: 1;
text-decoration: none;
border-radius: 50%;
box-shadow: 0 1px 2px 0 rgba(0,0,0,0.04);
}
.assignee .avatar {
overflow: hidden;
@@ -53,18 +23,12 @@
background-color: #dbdbdb;
color: #444;
position: absolute;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
}
.assignee .avatar.avatar-image {
object-fit: cover;
object-position: center;
height: 100%;
width: 100%;
display: block;
}
.assignee .assignee-presence-status {
background-color: #b3b3b3;
@@ -75,6 +39,7 @@
position: absolute;
right: -1px;
bottom: -1px;
border: 1px solid #fff;
z-index: 15;
}
.assignee .assignee-presence-status.active {
@@ -98,7 +63,6 @@
align-items: center;
justify-content: center;
box-shadow: 0 0 0 2px #bfbfbf inset;
transition: box-shadow 0.12s;
}
.assignee.add-assignee:hover,
.assignee.add-assignee.is-active {
@@ -106,137 +70,47 @@
}
.copied-tooltip {
display: none;
padding: 0 1.3vw;
padding: 0px 10px;
background-color: rgba(0,0,0,0.875);
color: #fff;
border-radius: 0.7vw;
font-size: 0.98em;
border-radius: 5px;
}
.card-details {
padding: 0;
flex-shrink: 0;
flex-basis: min(600px, 80vw);
flex-basis: 600px;
will-change: flex-basis;
overflow-y: auto;
overflow-y: scroll;
overflow-x: hidden;
background: #f7f7f7;
border-radius: 0 0 0.4vw 0.4vw;
border-radius: bottom 3px;
z-index: 30;
animation: flexGrowIn 0.1s;
box-shadow: 0 0 0.9vh 0 #b3b3b3;
transition: flex-basis 0.1s, box-shadow 0.15s;
box-shadow: 0 0 7px 0 #b3b3b3;
transition: flex-basis 0.1s;
box-sizing: border-box;
}
/* Desktop mode: position card below board header */
body.desktop-mode .card-details:not(.card-details-popup) {
position: fixed;
width: auto;
max-width: 800px;
flex-basis: auto;
border-radius: 8px;
z-index: 2001;
}
/* Default position for first card or when dragged */
body.desktop-mode .card-details:not(.card-details-popup):not([style*="left"]):not([style*="top"]) {
top: 8px;
left: 20px;
right: 20px;
bottom: 20px;
}
/* Stagger positions for multiple cards using nth-of-type */
body.desktop-mode .card-details:not(.card-details-popup):nth-of-type(1) {
top: 8px;
left: 20px;
}
body.desktop-mode .card-details:not(.card-details-popup):nth-of-type(2) {
top: 38px;
left: 50px;
}
body.desktop-mode .card-details:not(.card-details-popup):nth-of-type(3) {
top: 68px;
left: 80px;
}
body.desktop-mode .card-details:not(.card-details-popup):nth-of-type(4) {
top: 98px;
left: 110px;
}
body.desktop-mode .card-details:not(.card-details-popup):nth-of-type(5) {
top: 128px;
left: 140px;
}
/* For expanded cards, set dimensions */
body.desktop-mode .card-details:not(.card-details-popup):not(.card-details-collapsed) {
right: 20px;
bottom: 20px;
}
/* Collapsed card state - hide content and set height to title row only */
.card-details.card-details-collapsed .card-details-canvas > *:not(.card-details-header) {
display: none !important;
}
.card-details.card-details-collapsed {
height: auto !important;
bottom: auto !important;
overflow: visible;
}
body.desktop-mode .card-details.card-details-collapsed {
bottom: auto !important;
}
.card-details .mCustomScrollBox {
padding-left: 0;
}
.card-details .card-details-canvas {
width: auto;
padding: 0 2.5vw;
padding: 0 20px;
}
.card-details .card-details-header {
margin: 0 -20px 5px;
padding: 8px 20px;
padding: 7px 20px;
background: #ededed;
border-bottom: 1px solid #dbdbdb;
position: sticky;
top: 0px;
z-index: 500;
display: flow-root;
min-height: 44px;
}
.card-details .card-details-header .card-number {
color: #b3b3b3;
display: inline-block;
margin-right: 6px;
}
/* Collapse toggle triangle */
.card-details .card-details-header .card-collapse-toggle {
float: left;
font-size: 20px;
padding: 7px 10px;
margin-left: -10px;
margin-right: 5px;
cursor: pointer;
user-select: none;
color: #000;
vertical-align: middle;
line-height: 1.2;
}
.card-details .card-details-header .card-drag-handle {
font-size: 20px;
padding: 8px 10px;
margin-right: 10px;
cursor: move;
user-select: none;
display: inline-block;
float: right;
vertical-align: middle;
line-height: 1.2;
}
.card-details .card-details-header .close-card-details,
.card-details .card-details-header .maximize-card-details,
.card-details .card-details-header .minimize-card-details,
@@ -254,19 +128,11 @@ body.desktop-mode .card-details.card-details-collapsed {
font-size: 24px;
padding: 5px 10px 5px 10px;
margin-right: -8px;
cursor: pointer;
user-select: none;
vertical-align: middle;
line-height: 1.2;
transition: color 0.13s;
}
.card-details .card-details-header .close-card-details-mobile-web,
.card-details .card-details-header .card-mobile-desktop-toggle {
.card-details .card-details-header .close-card-details-mobile-web {
font-size: 24px;
padding: 5px;
margin-right: 5px;
cursor: pointer;
user-select: none;
margin-right: 40px;
}
.card-details .card-details-header .card-copy-button {
font-size: 17px;
@@ -281,44 +147,12 @@ body.desktop-mode .card-details.card-details-collapsed {
.card-details .card-details-header .card-details-menu {
font-size: 17px;
padding: 10px;
vertical-align: middle;
line-height: 1.2;
}
.card-details .card-details-header .card-details-menu-mobile-web {
font-size: 17px;
padding: 10px;
margin-right: 30px;
}
.card-details .card-details-header .card-mobile-desktop-toggle,
.card-details .card-details-header .card-zoom-in,
.card-details .card-details-header .card-zoom-out {
font-size: 24px;
padding: 5px 10px 5px 10px;
margin-right: 5px;
cursor: pointer;
user-select: none;
float: right;
}
/* Unify all card text to match title size */
.card-details {
font-size: 1em;
}
.card-details p,
.card-details span,
.card-details div,
.card-details a,
.card-details label,
.card-details input,
.card-details textarea,
.card-details select,
.card-details button,
.card-details .card-details-item-title,
.card-details .card-label,
.card-details .viewer {
font-size: inherit;
line-height: 1.5;
}
.card-details .card-details-header .card-details-watch {
font-size: 17px;
padding-left: 7px;
@@ -326,13 +160,9 @@ body.desktop-mode .card-details.card-details-collapsed {
}
.card-details .card-details-header .card-details-title {
font-weight: bold;
font-size: 1.35em;
font-size: 1.33em;
margin: 7px 0 0;
padding: 0;
display: inline-block;
vertical-align: middle;
line-height: 1.3;
letter-spacing: 0.01em;
}
.card-details .card-details-header .linked-card-location {
font-style: italic;
@@ -347,10 +177,10 @@ body.desktop-mode .card-details.card-details-collapsed {
margin-bottom: 10px;
}
.card-details .card-details-header form.inlined-form .copied-tooltip {
padding: 0 10px;
padding: 0px 10px;
}
.card-details .card-details-header .card-details-list {
font-size: 0.9em;
font-size: 0.85em;
margin-bottom: 3px;
}
.card-details .card-details-header .card-details-list a.card-details-list-title {
@@ -360,7 +190,7 @@ body.desktop-mode .card-details.card-details-collapsed {
display: inline-block;
background: #e6e6e6;
border-radius: 3px;
padding: 0 5px;
padding: 0px 5px;
}
.card-details .card-details-header .copied-tooltip {
margin-right: 10px;
@@ -371,13 +201,11 @@ body.desktop-mode .card-details.card-details-collapsed {
}
.card-details .card-description textarea {
min-height: 100px;
resize: vertical;
}
.card-details .card-details-items {
display: flex;
flex-wrap: wrap;
margin: 15px 0;
gap: 0.5em;
}
.card-details .card-details-items .card-details-item {
margin-right: 0.5em;
@@ -428,28 +256,15 @@ body.desktop-mode .card-details.card-details-collapsed {
position: fixed;
resize: both;
}
/* Override for mobile mode even on larger screens */
body.mobile-mode .card-details {
width: 100vw !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
height: 100vh !important;
max-height: 100vh !important;
resize: none !important;
}
.card-details-maximized {
padding: 0;
flex-shrink: 0;
flex-basis: calc(100% - 20px);
will-change: flex-basis;
overflow-y: auto;
overflow-x: auto;
overflow-y: scroll;
overflow-x: scroll;
background: #f7f7f7;
border-radius: 0 0 3px 3px;
border-radius: bottom 3px;
z-index: 100;
animation: flexGrowIn 0.1s;
box-shadow: 0 0 7px 0 #b3b3b3;
@@ -492,52 +307,19 @@ input[type="submit"].attachment-add-link-submit {
}
@media screen and (max-width: 800px) {
.card-details {
width: 100% !important;
padding: 0 !important;
margin: 0 !important;
width: calc(100% - 1px);
padding: 0px 20px 0px 20px;
margin: 0px;
transition: none;
overflow-y: auto;
overflow-x: hidden;
-webkit-overflow-scrolling: touch;
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
z-index: 100 !important;
height: 100vh !important;
max-height: 100vh !important;
border-radius: 0 !important;
box-shadow: none !important;
}
/* Ensure card details are above everything on mobile */
body.mobile-mode .card-details {
z-index: 100 !important;
width: 100vw !important;
left: 0 !important;
right: 0 !important;
overflow-y: revert;
overflow-x: revert;
}
.card-details .card-details-canvas {
width: 100%;
padding-left: 0px;
padding: 0 15px;
}
.card-details .card-details-header .close-card-details {
margin-right: 0px;
display: block !important;
}
.card-details .card-details-header .close-card-details-mobile-web {
display: block !important;
margin-right: 5px !important;
}
.card-details .card-details-header .card-mobile-desktop-toggle {
display: block !important;
margin-right: 5px !important;
}
.card-details .card-details-header .card-mobile-desktop-toggle {
display: block !important;
margin-right: 5px !important;
}
.card-details .card-details-header .card-details-menu {
margin-right: 40px;
@@ -563,62 +345,6 @@ input[type="submit"].attachment-add-link-submit {
.pop-over > .content-wrapper > .popup-container-depth-0 .card-details-header {
margin: 0;
}
/* iPhone mobile: enlarge header buttons and increase spacing */
body.mobile-mode.iphone-device .card-details .card-details-header {
padding-right: 16px;
}
body.mobile-mode.iphone-device .card-details .card-details-header .close-card-details,
body.mobile-mode.iphone-device .card-details .card-details-header .maximize-card-details,
body.mobile-mode.iphone-device .card-details .card-details-header .minimize-card-details,
body.mobile-mode.iphone-device .card-details .card-details-header .card-details-menu-mobile-web,
body.mobile-mode.iphone-device .card-details .card-details-header .card-copy-mobile-button,
body.mobile-mode.iphone-device .card-details .card-details-header .card-mobile-desktop-toggle,
body.mobile-mode.iphone-device .card-details .card-details-header .card-zoom-in,
body.mobile-mode.iphone-device .card-details .card-details-header .card-zoom-out {
font-size: 2em !important; /* 2x bigger */
padding: 0.3em !important;
margin-right: 0.75em !important; /* 2x space compared to default */
margin-left: 0 !important;
}
/* Avoid clipping of the close button on the right edge */
body.mobile-mode.iphone-device .card-details .card-details-header .close-card-details {
margin-right: 0.75em !important;
}
/* Enlarge the header title too */
body.mobile-mode.iphone-device .card-details .card-details-header .card-details-title {
font-size: 1.2em !important;
font-weight: bold;
}
}
/* Mobile mode styles - apply when body has mobile-mode class regardless of screen size */
body.mobile-mode .card-details {
width: 100vw !important;
padding: 0px !important;
margin: 0px !important;
position: fixed !important;
top: 0 !important;
left: 0 !important;
right: 0 !important;
bottom: 0 !important;
z-index: 100 !important;
height: 100vh !important;
max-height: 100vh !important;
border-radius: 0 !important;
box-shadow: none !important;
overflow-y: auto !important;
overflow-x: hidden !important;
-webkit-overflow-scrolling: touch;
}
body.mobile-mode .card-details .card-details-canvas {
width: 100% !important;
padding: 0 15px !important;
}
body.mobile-mode .card-details .card-details-header .close-card-details,
body.mobile-mode .card-details .card-details-header .close-card-details-mobile-web {
display: block !important;
}
.card-details-white {
background: #fff !important;
@@ -727,15 +453,13 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
.vote-title {
display: flex;
justify-content: space-between;
align-items: center;
}
.vote-title .js-edit-date {
align-self: flex-start;
margin-left: 6px;
align-self: baseline;
margin-left: 5px;
}
.vote-result {
display: flex;
gap: 6px;
}
.js-show-positive-votes {
cursor: pointer;
@@ -746,33 +470,29 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
.poker-title {
display: flex;
justify-content: space-between;
align-items: center;
}
.poker-title .js-edit-date {
align-self: flex-start;
margin-left: 6px;
align-self: baseline;
margin-left: 5px;
}
.poker-result {
display: flex;
flex-wrap: wrap;
gap: 7px;
flex-flow: row wrap;
}
.js-show-positive-poker-votes {
cursor: pointer;
}
.poker-deck {
display: grid;
grid-auto-flow: row;
flex-direction: column;
text-align: center;
gap: 6px;
}
.poker-card-result {
width: 34px;
width: 32px;
font-size: 1em;
font-weight: bold;
padding: 4px 2px;
padding: 4px 2px 4px 2px;
cursor: default;
border-radius: 3px;
}
.winner {
font-weight: bold;
@@ -783,7 +503,6 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
}
.responsive-table {
overflow-x: auto;
width: 100%;
}
.poker-table {
display: table;
@@ -846,15 +565,11 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
margin: auto;
margin-right: 10px;
width: 100px;
border-radius: 2px;
padding: 3px 6px;
}
.estimation-add button {
display: inline-block;
float: right;
margin: auto;
border-radius: 2px;
padding: 3px 10px;
}
.poker-card {
width: 48px;
@@ -873,7 +588,6 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
text-align: center;
position: relative;
cursor: pointer;
transition: box-shadow 0.12s;
}
.poker-card .inner {
display: table-cell;
@@ -882,175 +596,3 @@ body.mobile-mode .card-details .card-details-header .close-card-details-mobile-w
overflow: hidden;
background-color: #cecece;
}
/* Drag and drop file upload visual feedback */
.js-card-details.is-dragging-over {
border: 2px dashed #0079bf;
background-color: #e3f2fd !important;
transform: scale(1.01);
transition: all 0.2s ease;
}
/* Sticker picker + card-detail stickers + due-complete toggle */
.card-stickers-picker {
max-width: 320px;
}
.card-sticker-select {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
margin: 2px;
border-radius: 3px;
font-size: 18px;
color: #4d4d4d;
cursor: pointer;
position: relative;
}
.card-sticker-select:hover {
background: #e0e0e0;
}
.card-sticker-select.active {
background: #d6ecd2;
color: #3cb500;
}
.card-sticker-select .card-sticker-check {
position: absolute;
bottom: 1px;
right: 2px;
font-size: 10px;
color: #3cb500;
}
.card-stickers .card-sticker {
display: inline-flex;
align-items: center;
margin: 0 8px 4px 0;
font-size: 18px;
}
.card-sticker .card-sticker-remove {
margin-left: 3px;
font-size: 11px;
color: #999;
cursor: pointer;
}
.card-sticker .card-sticker-remove:hover {
color: #eb4646;
}
.card-due-complete-toggle {
display: inline-flex;
align-items: center;
margin-left: 8px;
vertical-align: middle;
}
/* Trello-style "mark complete" toggle, left of the card title */
.card-details-complete-toggle {
float: left;
display: flex;
align-items: center;
/* Title is 1.35em with 1.3 line-height (~28px box) and margin-top 7px; push
the 13px checkbox down so it lands at the vertical middle of the title. */
margin: 14px 10px 0 0;
cursor: pointer;
}
/* Card locations: multiple locations, each editable with a remove button */
.card-details-item-location .card-locations {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 6px;
}
.card-details-item-location .card-location {
display: flex;
align-items: flex-start;
justify-content: space-between;
background: #fff;
border: 1px solid #e3e3e3;
border-radius: 4px;
padding: 6px 8px;
}
.card-details-item-location .card-location-info {
flex: 1;
}
.card-details-item-location .js-edit-location {
cursor: pointer;
}
.card-details-item-location .card-location-name {
font-weight: bold;
}
.card-details-item-location .card-location-address {
color: #4d4d4d;
}
.card-details-item-location .card-location-remove {
margin-left: 8px;
color: #8c8c8c;
cursor: pointer;
}
.card-details-item-location .card-location-remove:hover {
color: #eb4646;
}
/* Sticker icon highlight styles (used instead of colour): taco pack => underline,
pete pack => round (ring around the icon). Icon keeps its normal colour. */
.card-sticker i.sticker-underline {
border-bottom: 2px solid currentColor;
padding-bottom: 1px;
line-height: 1;
}
.card-sticker i.sticker-round {
border: 1px solid currentColor;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.6em;
height: 1.6em;
line-height: 1;
box-sizing: border-box;
}
/* "Open map links at" provider selector in the location popup */
.js-card-location-form .map-provider-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.js-card-location-form .map-provider-row .js-map-provider {
flex: 0 0 auto;
width: auto;
}
/* Opened card (card details popup) docks to the very top of the window and
overlays the global + board header bars instead of opening below them. The
base .pop-over is position:absolute with a small margin/top offset, which left
the card's top behind the fixed header bars; fix it to the viewport top with a
z-index above every header (headers top out around z-index 1000-2000). */
.pop-over[data-popup="cardDetailsPopup"] {
position: fixed !important;
top: 0 !important;
margin-top: 0 !important;
z-index: 100000 !important;
}
/* Sticker highlight styles inside the sticker picker (mascot = underline,
computer = round), matching how they render on the card. */
.card-sticker-select i.sticker-underline {
border-bottom: 2px solid currentColor;
padding-bottom: 1px;
line-height: 1;
}
.card-sticker-select i.sticker-round {
border: 1px solid currentColor;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.6em;
height: 1.6em;
line-height: 1;
box-sizing: border-box;
}
+166 -475
View File
@@ -5,75 +5,49 @@ template(name="cardDetails")
+attachmentViewer
section.card-details.js-card-details.nodragscroll(class='{{#if cardMaximized}}card-details-maximized{{/if}}' class='{{#if isPopup}}card-details-popup{{/if}}' class='{{#unless isVerticalScrollbars}}no-scrollbars{{/unless}}' class='{{#if cardCollapsed}}card-details-collapsed{{/if}}'): .card-details-canvas
section.card-details.js-card-details.nodragscroll(class='{{#if cardMaximized}}card-details-maximized{{/if}}' class='{{#if isPopup}}card-details-popup{{/if}}' class='{{#unless isVerticalScrollbars}}no-scrollbars{{/unless}}'): .card-details-canvas
.card-details-header(class='{{#if colorClass}}card-details-{{colorClass}}{{/if}}')
+inlinedForm(classNames="js-card-details-title")
+editCardTitleForm
else
unless isMiniScreen
span.card-collapse-toggle.js-card-collapse-toggle(title="{{_ 'collapse-card'}}")
if cardCollapsed
i.fa.fa-caret-right
else
i.fa.fa-caret-down
a.close-card-details.js-close-card-details(title="{{_ 'close-card'}}")
i.fa.fa-times-thin
if cardMaximized
a.fa.fa-window-minimize.minimize-card-details.js-minimize-card-details(title="{{_ 'minimize-card'}}")
else
a.fa.fa-window-maximize.maximize-card-details.js-maximize-card-details(title="{{_ 'maximize-card'}}")
a.card-details-menu.js-open-card-details-menu(title="{{_ 'cardDetailsActionsPopup-title'}}")
i.fa.fa-bars
a.card-copy-button.js-copy-link(
id="cardURL_copy"
title="{{_ 'copy-card-link-to-clipboard'}}"
href="{{ originRelativeUrl }}"
)
span.emoji-icon
i.fa.fa-link
unless isPopup
a.fa.fa-times-thin.close-card-details.js-close-card-details(title="{{_ 'close-card'}}")
if canModifyCard
if cardMaximized
a.fa.fa-window-minimize.minimize-card-details.js-minimize-card-details(title="{{_ 'minimize-card'}}")
else
a.fa.fa-window-maximize.maximize-card-details.js-maximize-card-details(title="{{_ 'maximize-card'}}")
if canModifyCard
span.card-drag-handle.js-card-drag-handle(title="Drag card")
i.fa.fa-arrows
span.copied-tooltip {{_ 'copied'}}
a.fa.fa-navicon.card-details-menu.js-open-card-details-menu(title="{{_ 'cardDetailsActionsPopup-title'}}")
a.fa.fa-link.card-copy-button.js-copy-link(
id="cardURL_copy"
class="fa-link"
title="{{_ 'copy-card-link-to-clipboard'}}"
href="{{ originRelativeUrl }}"
)
span.copied-tooltip {{_ 'copied'}}
else
a.close-card-details.js-close-card-details(title="{{_ 'close-card'}}")
i.fa.fa-times-thin
a.card-zoom-out.js-card-zoom-out(title="{{_ 'zoom-out'}}")
i.fa.fa-search-minus
a.card-zoom-in.js-card-zoom-in(title="{{_ 'zoom-in'}}")
i.fa.fa-search-plus
a.card-mobile-desktop-toggle.js-card-mobile-desktop-toggle(title="{{_ 'mobile-desktop-toggle'}}")
if mobileMode
i.fa.fa-desktop
else
i.fa.fa-mobile
if cardMaximized
a.fa.fa-window-minimize.minimize-card-details.js-minimize-card-details(title="{{_ 'minimize-card'}}")
else
a.fa.fa-window-maximize.maximize-card-details.js-maximize-card-details(title="{{_ 'maximize-card'}}")
a.card-details-menu-mobile-web.js-open-card-details-menu(title="{{_ 'cardDetailsActionsPopup-title'}}")
i.fa.fa-bars
a.card-copy-mobile-button.js-copy-link(
id="cardURL_copy"
title="{{_ 'copy-card-link-to-clipboard'}}"
href="{{ originRelativeUrl }}"
)
span.emoji-icon
i.fa.fa-link
span.copied-tooltip {{_ 'copied'}}
if canModifyCard
a.card-details-complete-toggle.js-toggle-due-complete(title="{{#if getDueComplete}}{{_ 'card-mark-incomplete'}}{{else}}{{_ 'card-mark-complete'}}{{/if}}")
.materialCheckBox(class="{{#if getDueComplete}}is-checked{{/if}}")
unless isPopup
a.fa.fa-times-thin.close-card-details.js-close-card-details(title="{{_ 'close-card'}}")
if canModifyCard
a.fa.fa-navicon.card-details-menu-mobile-web.js-open-card-details-menu(title="{{_ 'cardDetailsActionsPopup-title'}}")
a.fa.fa-link.card-copy-mobile-button.js-copy-link(
id="cardURL_copy"
class="fa-link"
title="{{_ 'copy-card-link-to-clipboard'}}"
href="{{ originRelativeUrl }}"
)
span.copied-tooltip {{_ 'copied'}}
h2.card-details-title.js-card-title(
class="{{#if canModifyCard}}js-open-inlined-form is-editable{{else}}js-card-title-drag-handle{{/if}}")
class="{{#if canModifyCard}}js-open-inlined-form is-editable{{/if}}")
+viewer
if currentBoard.allowsCardNumber
span.card-number
| ##{getCardNumber} &nbsp;
| ##{getCardNumber}
= getTitle
if isWatching
i.card-details-watch
i.fa.fa-eye
i.card-details-watch.fa.fa-eye
.card-details-path
each parentList
| &nbsp; &gt; &nbsp;
@@ -91,26 +65,6 @@ template(name="cardDetails")
else
p.warning {{_ 'card-archived'}}
// Upload progress indicator for drag-and-drop uploads
if hasActiveUploads
.card-details-upload-progress
.upload-progress-header
i.fa.fa-upload
span {{_ 'uploading-files'}} ({{uploadCount}})
each uploads
.upload-progress-item(class="{{#if $eq status 'error'}}upload-error{{/if}}")
.upload-progress-filename {{file.name}}
.upload-progress-bar
.upload-progress-fill(style="width: {{progress}}%")
if $eq status 'error'
.upload-progress-error
i.fa.fa-exclamation-triangle
span {{_ 'upload-failed'}}
else if $eq status 'completed'
.upload-progress-success
i.fa.fa-check
span {{_ 'upload-completed'}}
.card-details-left
.card-details-items
@@ -129,60 +83,9 @@ template(name="cardDetails")
a.card-label.add-label.js-add-labels(title="{{_ 'card-labels-title'}}")
i.fa.fa-plus
.card-details-item.card-details-item-stickers
h3.card-details-item-title
i.fa.fa-sticky-note-o
| {{_ 'stickers'}}
.card-details-item-content.card-stickers
each stickers
span.card-sticker(title="{{#if name}}{{name}}{{else}}{{icon}}{{/if}}")
i.fa(class="fa-{{icon}} {{#if highlight}}sticker-{{highlight}}{{/if}}")
if canModifyCard
a.card-sticker-remove.js-remove-sticker(data-index="{{@index}}" title="{{_ 'remove-sticker'}}")
i.fa.fa-times
if canModifyCard
unless currentUser.isWorker
a.card-label.add-label.js-add-stickers(title="{{_ 'add-sticker'}}")
i.fa.fa-plus
.card-details-item.card-details-item-location
h3.card-details-item-title
i.fa.fa-map-marker
| {{_ 'location'}}
.card-details-item-content.card-locations
each getLocations
.card-location
.card-location-info(class="{{#if canModifyCard}}js-edit-location{{/if}}" data-location-id="{{_id}}")
if name
.card-location-name= name
if address
.card-location-address= address
if hasCoordinates
a.card-location-map(href=mapUrl target="_blank" rel="noopener noreferrer")
i.fa.fa-map-o
= ' '
| {{_ 'location-open-map'}}
if canModifyCard
a.card-location-remove.js-remove-location(data-location-id="{{_id}}" title="{{_ 'delete'}}")
i.fa.fa-times
if canModifyCard
unless currentUser.isWorker
a.card-label.add-label.js-add-location(title="{{_ 'add-location'}}")
i.fa.fa-plus
if currentBoard.hasAnyAllowsDate
hr
.card-details-item.card-details-item-date-format
h3.card-details-item-title
i.fa.fa-calendar
| {{_ 'date-format'}}
.card-details-item-content
select.js-date-format-selector
option(value="YYYY-MM-DD" selected="{{#if isDateFormat 'YYYY-MM-DD'}}selected{{/if}}") {{_ 'date-format-yyyy-mm-dd'}}
option(value="DD-MM-YYYY" selected="{{#if isDateFormat 'DD-MM-YYYY'}}selected{{/if}}") {{_ 'date-format-dd-mm-yyyy'}}
option(value="MM-DD-YYYY" selected="{{#if isDateFormat 'MM-DD-YYYY'}}selected{{/if}}") {{_ 'date-format-mm-dd-yyyy'}}
if currentBoard.allowsReceivedDate
.card-details-item.card-details-item-received
h3.card-details-item-title
@@ -212,7 +115,7 @@ template(name="cardDetails")
if currentBoard.allowsDueDate
.card-details-item.card-details-item-due
h3.card-details-item-title
i.fa.fa-clock-o
i.fa.fa-sign-in
| {{_ 'card-due'}}
if getDue
+cardDueDate
@@ -325,7 +228,7 @@ template(name="cardDetails")
if currentBoard.allowsCardSortingByNumber
.card-details-item.card-details-sort-order
h3.card-details-item-title
i.fa.fa-sort-numeric-asc
i.fa.fa-sort
| {{_ 'sort'}}
if canModifyCard
+inlinedForm(classNames="js-card-details-sort")
@@ -341,7 +244,7 @@ template(name="cardDetails")
i.fa.fa-list
| {{_ 'list'}}
select.js-select-card-details-lists(disabled="{{#unless canModifyCard}}disabled{{/unless}}")
each currentSwimlaneListsSorted
each currentBoard.lists
option(value="{{_id}}" selected="{{#if isCurrentListId _id}}selected{{/if}}") {{title}}
//.card-details-items
@@ -364,7 +267,7 @@ template(name="cardDetails")
hr
.card-details-item.card-details-item-customfield
h3.card-details-item-title
i.fa.fa-list
i.fa.fa-list-alt
= definition.name
+cardCustomField
@@ -375,7 +278,7 @@ template(name="cardDetails")
else
input.toggle-switch(type="checkbox" id="toggleCustomFieldsGridButton")
label.toggle-label(for="toggleCustomFieldsGridButton")
a.js-custom-fields.card-details-item.custom-fields(title="{{_ 'custom-fields'}}")
a.fa.fa-plus.js-custom-fields.card-details-item.custom-fields(title="{{_ 'custom-fields'}}")
if getVoteQuestion
hr
@@ -394,8 +297,7 @@ template(name="cardDetails")
.card-label.card-label-green {{ voteCountPositive }}
.card-label.card-label-red {{ voteCountNegative }}
unless ($and currentBoard.isPublic voteAllowNonBoardMembers )
.card-label.card-label-gray
| {{ voteCount }} {{_ 'r-of' }} {{ currentBoard.activeMembers.length }}
.card-label.card-label-gray {{ voteCount }} {{_ 'r-of' }} {{ currentBoard.activeMembers.length }}
+viewer
= getVoteQuestion
if showVotingButtons
@@ -405,7 +307,7 @@ template(name="cardDetails")
| {{_ 'vote-for-it'}}
button.card-details-red.js-vote.js-vote-negative(class="{{#if $eq voteState false}}voted{{/if}}")
if $eq voteState false
| 👎
i.fa.fa-thumbs-down
| {{_ 'vote-against'}}
if getPokerQuestion
@@ -421,8 +323,7 @@ template(name="cardDetails")
.poker-result
if expiredPoker
unless ($and currentBoard.isPublic pokerAllowNonBoardMembers )
.card-label.card-label-gray
| {{ pokerCount }} {{_ 'r-of' }} {{ currentBoard.activeMembers.length }}
.card-label.card-label-gray {{ pokerCount }} {{_ 'r-of' }} {{ currentBoard.activeMembers.length }}
if showPlanningPokerButtons
.poker-result
.poker-deck
@@ -614,14 +515,14 @@ template(name="cardDetails")
if currentBoard.allowsDescriptionTitle
hr
h3.card-details-item-title
i.fa.fa-file-text-o
i.fa.fa-align-left
| {{_ 'description'}}
if currentBoard.allowsDescriptionText
+inlinedCardDescription(classNames="card-description js-card-description")
+descriptionForm
.edit-controls.clearfix
button.primary(type="submit") {{_ 'save'}}
a.js-close-inlined-form
a.fa.fa-times-thin.js-close-inlined-form
else
if currentBoard.allowsDescriptionText
a.js-open-inlined-form(title="{{_ 'edit'}}" value=title)
@@ -668,23 +569,22 @@ template(name="cardDetails")
+attachmentGallery
hr
if currentBoard.allowsComments
unless currentUser.isNoComments
.comment-title
h3.card-details-item-title
i.fa.fa-comment-o
| {{_ 'comments'}}
if currentUser.isBoardMember
unless currentUser.isNoComments
unless currentUser.isReadOnly
unless currentUser.isReadAssignedOnly
+commentForm
+comments
if currentBoard.allowsComments
if currentUser.isBoardMember
unless currentUser.isNoComments
+commentForm
+comments
hr
.card-details-right
if currentUser.isBoardAdmin
unless currentUser.isNoComments
.activity-title
h3.card-details-item-title
i.fa.fa-history
@@ -697,7 +597,7 @@ template(name="cardDetails")
input.toggle-switch(type="checkbox" id="toggleShowActivitiesCard")
label.toggle-label(for="toggleShowActivitiesCard")
if currentUser.isBoardAdmin
unless currentUser.isNoComments
if isLoaded.get
if isLinkedCard
+activities(card=this mode="linkedcard")
@@ -707,339 +607,168 @@ template(name="cardDetails")
+activities(card=this mode="card")
template(name="editCardTitleForm")
a(title="{{_ 'copy-text-to-clipboard'}}")
a.fa.fa-copy(title="{{_ 'copy-text-to-clipboard'}}")
span.copied-tooltip {{_ 'copied'}}
textarea.js-edit-card-title(rows='1' autofocus dir="auto")
= getTitle
.edit-controls.clearfix
button.primary.confirm.js-submit-edit-card-title-form(type="submit") {{_ 'save'}}
a.js-close-inlined-form
a.fa.fa-times-thin.js-close-inlined-form
template(name="editCardRequesterForm")
input.js-edit-card-requester(type='text' autofocus value=getRequestedBy dir="auto")
.edit-controls.clearfix
button.primary.confirm.js-submit-edit-card-requester-form(type="submit") {{_ 'save'}}
a.js-close-inlined-form
a.fa.fa-times-thin.js-close-inlined-form
template(name="editCardAssignerForm")
input.js-edit-card-assigner(type='text' autofocus value=getAssignedBy dir="auto")
.edit-controls.clearfix
button.primary.confirm.js-submit-edit-card-assigner-form(type="submit") {{_ 'save'}}
a.js-close-inlined-form
a.fa.fa-times-thin.js-close-inlined-form
template(name="editCardSortOrderForm")
input.js-edit-card-sort(type='text' autofocus value=sort dir="auto")
.edit-controls.clearfix
button.primary.confirm.js-submit-edit-card-sort-form(type="submit") {{_ 'save'}}
a.js-close-inlined-form
a.fa.fa-times-thin.js-close-inlined-form
template(name="cardDetailsActionsPopup")
if currentUser
ul.pop-over-list
li
a.js-toggle-watch-card
if isWatching
i.fa.fa-eye
| {{_ 'unwatch'}}
else
i.fa.fa-eye
| {{_ 'watch'}}
hr
if canModifyCard
unless currentUser.isWorker
ul.pop-over-list
//li: a.js-members {{_ 'card-edit-members'}}
//li: a.js-labels {{_ 'card-edit-labels'}}
//li: a.js-attachments {{_ 'card-edit-attachments'}}
ul.pop-over-list
li
a.js-toggle-watch-card
if isWatching
i.fa.fa-eye
| {{_ 'unwatch'}}
else
i.fa.fa-eye-slash
| {{_ 'watch'}}
hr
if canModifyCard
unless currentUser.isWorker
ul.pop-over-list
//li: a.js-members {{_ 'card-edit-members'}}
//li: a.js-labels {{_ 'card-edit-labels'}}
//li: a.js-attachments {{_ 'card-edit-attachments'}}
li
a.js-start-voting
i.fa.fa-thumbs-up
| {{_ 'card-edit-voting'}}
li
a.js-start-planning-poker
i.fa.fa-thumbs-up
| {{_ 'card-edit-planning-poker'}}
if currentUser.isBoardAdmin
li
a.js-start-voting
i.fa.fa-thumbs-up
| {{_ 'card-edit-voting'}}
li
a.js-start-planning-poker
i.fa.fa-thumbs-up
| {{_ 'card-edit-planning-poker'}}
if currentUser.isBoardAdmin
li
a.js-custom-fields
i.fa.fa-list
| {{_ 'card-edit-custom-fields'}}
//li: a.js-received-date {{_ 'editCardReceivedDatePopup-title'}}
//li: a.js-start-date {{_ 'editCardStartDatePopup-title'}}
//li: a.js-due-date {{_ 'editCardDueDatePopup-title'}}
//li: a.js-end-date {{_ 'editCardEndDatePopup-title'}}
li
a.js-spent-time
i.fa.fa-clock-o
| {{_ 'editCardSpentTimePopup-title'}}
li
a.js-set-card-color
i.fa.fa-paint-brush
| {{_ 'setCardColorPopup-title'}}
li
a.js-toggle-show-list-on-minicard
if showListOnMinicard
i.fa.fa-eye
| {{_ 'hide-list-on-minicard'}}
else
i.fa.fa-eye
| {{_ 'show-list-on-minicard'}}
if canModifyCard
hr
else
unless currentUser.isReadOnly
unless currentUser.isReadAssignedOnly
hr
a.js-custom-fields
i.fa.fa-list-alt
| {{_ 'card-edit-custom-fields'}}
//li: a.js-received-date {{_ 'editCardReceivedDatePopup-title'}}
//li: a.js-start-date {{_ 'editCardStartDatePopup-title'}}
//li: a.js-due-date {{_ 'editCardDueDatePopup-title'}}
//li: a.js-end-date {{_ 'editCardEndDatePopup-title'}}
li
a.js-spent-time
i.fa.fa-clock-o
| {{_ 'editCardSpentTimePopup-title'}}
li
a.js-set-card-color
i.fa.fa-paint-brush
| {{_ 'setCardColorPopup-title'}}
hr
ul.pop-over-list
li
a.js-export-card
i.fa.fa-upload
i.fa.fa-share-alt
| {{_ 'export-card'}}
if currentUser
unless canModifyCard
unless currentUser.isReadOnly
unless currentUser.isReadAssignedOnly
hr
ul.pop-over-list
li
a.js-move-card-to-top
i.fa.fa-arrow-up
| {{_ 'moveCardToTop-title'}}
li
a.js-move-card-to-bottom
i.fa.fa-arrow-down
| {{_ 'moveCardToBottom-title'}}
hr
ul.pop-over-list
if currentUser.isBoardAdmin
li
a.js-move-card
i.fa.fa-arrow-right
| {{_ 'moveCardPopup-title'}}
unless currentUser.isWorker
li
a.js-copy-card
i.fa.fa-clipboard
| {{_ 'copyCardPopup-title'}}
unless currentUser.isWorker
ul.pop-over-list
li
a.js-copy-checklist-cards
i.fa.fa-copy
| {{_ 'copyManyCardsPopup-title'}}
unless archived
hr
ul.pop-over-list
li
a.js-archive
i.fa.fa-archive
| {{_ 'archive-card'}}
hr
ul.pop-over-list
li
a.js-more
span.emoji-icon
i.fa.fa-link
| {{_ 'cardMorePopup-title'}}
if canModifyCard
hr
ul.pop-over-list
li
a.js-move-card-to-top
i.fa.fa-arrow-up
| {{_ 'moveCardToTop-title'}}
li
a.js-move-card-to-bottom
i.fa.fa-arrow-down
| {{_ 'moveCardToBottom-title'}}
hr
ul.pop-over-list
if currentUser.isBoardAdmin
li
a.js-move-card
i.fa.fa-arrow-right
| {{_ 'moveCardPopup-title'}}
unless currentUser.isWorker
li
a.js-copy-card
i.fa.fa-copy
| {{_ 'copyCardPopup-title'}}
unless currentUser.isWorker
ul.pop-over-list
li
a.js-copy-checklist-cards
i.fa.fa-copy
i.fa.fa-copy
| {{_ 'copyManyCardsPopup-title'}}
unless archived
hr
ul.pop-over-list
li
a.js-move-card-to-top
i.fa.fa-arrow-up
| {{_ 'moveCardToTop-title'}}
li
a.js-move-card-to-bottom
i.fa.fa-arrow-down
| {{_ 'moveCardToBottom-title'}}
hr
ul.pop-over-list
if currentUser.isBoardAdmin
li
a.js-move-card
i.fa.fa-arrow-right
| {{_ 'moveCardPopup-title'}}
unless currentUser.isWorker
li
a.js-copy-card
i.fa.fa-clipboard
| {{_ 'copyCardPopup-title'}}
unless currentUser.isWorker
ul.pop-over-list
li
a.js-copy-checklist-cards
i.fa.fa-copy
| {{_ 'copyManyCardsPopup-title'}}
unless archived
hr
ul.pop-over-list
li
a.js-archive
i.fa.fa-archive
| {{_ 'archive-card'}}
hr
ul.pop-over-list
li
a.js-more
span.emoji-icon
i.fa.fa-link
| {{_ 'cardMorePopup-title'}}
a.js-archive
i.fa.fa-arrow-right
i.fa.fa-archive
| {{_ 'archive-card'}}
hr
ul.pop-over-list
li
a.js-more
i.fa.fa-link
| {{_ 'cardMorePopup-title'}}
template(name="exportCardPopup")
ul.pop-over-list
li
a(href="{{exportUrlCardPDF}}", download="{{exportFilenameCardPDF}}")
i.fa.fa-file-pdf-o
a(href="{{exportUrlCardPDF}}",, download="{{exportFilenameCardPDF}}")
i.fa.fa-share-alt
| {{_ 'export-card-pdf'}}
hr
p.quiet {{_ 'export-card-excel-fields'}}
ul.pop-over-list
each excelExportFields
li(class="{{#if checked}}active{{/if}}")
a.js-excel-field-toggle(data-field="{{field}}" href="#")
i.fa.fa-check
| {{_ label}}
ul.pop-over-list
li
a(href="{{exportUrlCardExcel}}", download="{{exportFilenameCardExcel}}")
i.fa.fa-file-excel-o
| {{_ 'download'}}
template(name="moveCardPopup")
label(for='move-card-title') {{_ 'title'}}:
input#move-card-title.full-line(type="text" value="{{getTitle}}" autofocus)
unless currentUser.isWorker
label {{_ 'boards'}}:
select.js-select-boards(autofocus)
each boards
option(value="{{_id}}" selected="{{#if isDialogOptionBoardId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
label {{_ 'swimlanes'}}:
select.js-select-swimlanes
each swimlanes
option(value="{{_id}}" selected="{{#if isDialogOptionSwimlaneId _id}}selected{{/if}}") {{add @index 1}}. {{isTitleDefault title}}
label {{_ 'lists'}}:
select.js-select-lists
each lists
option(value="{{_id}}" selected="{{#if isDialogOptionListId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
label {{_ 'cards'}}:
select.js-select-cards
each cards
option(value="{{_id}}" selected="{{#if isDialogOptionCardId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
div
input(type="radio" name="position" value="above" checked id="position-above" style="display: inline")
label(for="position-above") {{_ 'above-selected-card'}}
div
input(type="radio" name="position" value="below" id="position-below" style="display: inline")
label(for="position-below") {{_ 'below-selected-card'}}
.edit-controls.clearfix
button.primary.confirm.js-done {{_ 'done'}}
+copyAndMoveCard
template(name="copyCardPopup")
label(for='copy-card-title') {{_ 'title'}}:
textarea#copy-card-title.minicard-composer-textarea.js-card-title(autofocus)
= getTitle
unless currentUser.isWorker
label {{_ 'boards'}}:
select.js-select-boards(autofocus)
each boards
option(value="{{_id}}" selected="{{#if isDialogOptionBoardId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
label {{_ 'swimlanes'}}:
select.js-select-swimlanes
each swimlanes
option(value="{{_id}}" selected="{{#if isDialogOptionSwimlaneId _id}}selected{{/if}}") {{add @index 1}}. {{isTitleDefault title}}
label {{_ 'lists'}}:
select.js-select-lists
each lists
option(value="{{_id}}" selected="{{#if isDialogOptionListId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
label {{_ 'cards'}}:
select.js-select-cards
each cards
option(value="{{_id}}" selected="{{#if isDialogOptionCardId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
div
input(type="radio" name="position" value="above" checked id="position-above" style="display: inline")
label(for="position-above") {{_ 'above-selected-card'}}
div
input(type="radio" name="position" value="below" id="position-below" style="display: inline")
label(for="position-below") {{_ 'below-selected-card'}}
.edit-controls.clearfix
button.primary.confirm.js-done {{_ 'done'}}
+copyAndMoveCard
template(name="copyManyCardsPopup")
label(for='copy-checklist-cards-title') {{_ 'copyManyCardsPopup-instructions'}}:
textarea#copy-card-title.minicard-composer-textarea.js-card-title(autofocus)
| {{_ 'copyManyCardsPopup-format'}}
unless currentUser.isWorker
label {{_ 'boards'}}:
select.js-select-boards(autofocus)
each boards
option(value="{{_id}}" selected="{{#if isDialogOptionBoardId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
label {{_ 'swimlanes'}}:
select.js-select-swimlanes
each swimlanes
option(value="{{_id}}" selected="{{#if isDialogOptionSwimlaneId _id}}selected{{/if}}") {{add @index 1}}. {{isTitleDefault title}}
label {{_ 'lists'}}:
select.js-select-lists
each lists
option(value="{{_id}}" selected="{{#if isDialogOptionListId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
label {{_ 'cards'}}:
select.js-select-cards
each cards
option(value="{{_id}}" selected="{{#if isDialogOptionCardId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
div
input(type="radio" name="position" value="above" checked id="position-above" style="display: inline")
label(for="position-above") {{_ 'above-selected-card'}}
div
input(type="radio" name="position" value="below" id="position-below" style="display: inline")
label(for="position-below") {{_ 'below-selected-card'}}
.edit-controls.clearfix
button.primary.confirm.js-done {{_ 'done'}}
+copyAndMoveCard
template(name="convertChecklistItemToCardPopup")
label(for='convert-checklist-item-to-card-title') {{_ 'title'}}:
textarea#copy-card-title.minicard-composer-textarea.js-card-title(autofocus)
= item.title
+copyAndMoveCard
template(name="copyAndMoveCard")
unless currentUser.isWorker
label {{_ 'boards'}}:
select.js-select-boards(autofocus)
each boards
option(value="{{_id}}" selected="{{#if isDialogOptionBoardId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
option(value="{{_id}}" selected="{{#if isDialogOptionBoardId _id}}selected{{/if}}") {{title}}
label {{_ 'swimlanes'}}:
select.js-select-swimlanes
each swimlanes
option(value="{{_id}}" selected="{{#if isDialogOptionSwimlaneId _id}}selected{{/if}}") {{add @index 1}}. {{isTitleDefault title}}
option(value="{{_id}}" selected="{{#if isDialogOptionSwimlaneId _id}}selected{{/if}}") {{title}}
label {{_ 'lists'}}:
select.js-select-lists
each lists
option(value="{{_id}}" selected="{{#if isDialogOptionListId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
label {{_ 'cards'}}:
select.js-select-cards
each cards
option(value="{{_id}}" selected="{{#if isDialogOptionCardId _id}}selected{{/if}}") {{add @index 1}}. {{title}}
div
input(type="radio" name="position" value="above" checked id="position-above" style="display: inline")
label(for="position-above") {{_ 'above-selected-card'}}
div
input(type="radio" name="position" value="below" id="position-below" style="display: inline")
label(for="position-below") {{_ 'below-selected-card'}}
option(value="{{_id}}" selected="{{#if isDialogOptionListId _id}}selected{{/if}}") {{title}}
.edit-controls.clearfix
button.primary.confirm.js-done {{_ 'done'}}
@@ -1050,13 +779,13 @@ template(name="cardMembersPopup")
each members
li.item(class="{{#if isCardMember}}active{{/if}}")
a.name.js-select-member(href="#")
+userAvatar(userId=userId)
+userAvatar(userId=user._id)
span.full-name
= userData.profile.fullname
if userData.username
| (#{userData.username})
= user.profile.fullname
| (<span class="username">{{ user.username }}</span>)
if isCardMember
i.fa.fa-check
template(name="cardAssigneesPopup")
input.card-assignees-filter(type="text" placeholder="{{_ 'search'}}")
unless currentUser.isWorker
@@ -1064,11 +793,10 @@ template(name="cardAssigneesPopup")
each members
li.item(class="{{#if isCardAssignee}}active{{/if}}")
a.name.js-select-assignee(href="#")
+userAvatar(userId=userId)
+userAvatar(userId=user._id)
span.full-name
= userData.profile.fullname
if userData.username
| (#{userData.username})
= user.profile.fullname
| (<span class="username">{{ user.username }}</span>)
if isCardAssignee
i.fa.fa-check
if currentUser.isWorker
@@ -1078,10 +806,10 @@ template(name="cardAssigneesPopup")
+userAvatar(userId=currentUser._id)
span.full-name
= currentUser.profile.fullname
if currentUser.username
| (#{currentUser.username})
| (<span class="username">{{ currentUser.username }}</span>)
if currentUser.isCardAssignee
i.fa.fa-check
template(name="cardAssigneePopup")
.board-assignee-menu
.mini-profile-info
@@ -1104,7 +832,7 @@ template(name="cardMorePopup")
span.clearfix
span {{_ 'link-card'}}
= ' '
i.fa(class="{{#if currentBoard.isPublic}}fa-globe{{else}}fa-lock{{/if}}")
i.fa.colorful(class="{{#if board.isPublic}}fa-globe{{else}}fa-lock{{/if}}")
input.inline-input(type="text" id="cardURL" readonly value="{{ originRelativeUrl }}" autofocus="autofocus")
button.js-copy-card-link-to-clipboard(class="btn" id="clipboard") {{_ 'copy-card-link-to-clipboard'}}
.copied-tooltip {{_ 'copied'}}
@@ -1136,20 +864,19 @@ template(name="cardMorePopup")
option(value="{{_id}}") {{title}}
br
| {{_ 'added'}}
span.date(title=card.createdAt) {{ displayDate createdAt 'LLL' }}
span.date(title=card.createdAt) {{ moment createdAt 'LLL' }}
if currentUser.isBoardAdmin
a.js-delete(title="{{_ 'card-delete-notice'}}") {{_ 'delete'}}
template(name="setCardColorPopup")
form.edit-label
.palette-colors
each colors
unless $eq color 'white'
span.card-label.palette-color.js-palette-color(class="card-details-{{color}}")
if(isSelected color)
i.fa.fa-check
button.primary.confirm.js-submit {{_ 'save'}}
button.js-remove-color.negate.wide.right {{_ 'unset-color'}}
.palette-colors: each colors
unless $eq color 'white'
span.card-label.palette-color.js-palette-color(class="card-details-{{color}}")
if(isSelected color)
i.fa.fa-check
button.primary.confirm.js-submit {{_ 'save'}}
button.js-remove-color.negate.wide.right {{_ 'unset-color'}}
template(name="cardDeletePopup")
p {{_ "card-delete-pop"}}
@@ -1181,7 +908,7 @@ template(name="cardStartVotingPopup")
.materialCheckBox#vote-public(name="vote-public" class="{{#if votePublic}}is-checked{{/if}}")
span {{_ 'vote-public'}}
.check-div.flex
i.fa.fa-clock-o
i.fa.fa-hourglass-end
a.js-end-date
span
| {{_ 'card-end'}}
@@ -1227,7 +954,7 @@ template(name="cardStartPlanningPokerPopup")
.materialCheckBox#poker-allow-non-members(name="poker-allow-non-members" class="{{#if pokerAllowNonBoardMembers}}is-checked{{/if}}")
span {{_ 'allowNonBoardMembers'}}
.check-div.flex
i.fa.fa-clock-o
i.fa.fa-hourglass-end
a.js-end-date
span
| {{_ 'card-end'}}
@@ -1240,39 +967,3 @@ template(name="cardStartPlanningPokerPopup")
if getPokerQuestion
if currentUser.isBoardAdmin
button.js-remove-poker.negate.wide.right {{_ 'delete'}}
template(name="cardLocationsPopup")
form.js-card-location-form
label
| {{_ 'location-detect-from-map'}}
input.js-location-map-link(type="text" placeholder="https://..." autofocus)
button.primary.js-detect-location(type="button") {{_ 'location-detect'}}
if detectMessage
p.quiet.js-detect-message {{detectMessage}}
hr
label
| {{_ 'location-name'}}
input.js-location-name(type="text" value="{{location.name}}")
label
| {{_ 'location-address'}}
input.js-location-address(type="text" value="{{location.address}}")
label
| {{_ 'location-latitude'}}
input.js-location-latitude(type="number" step="any" value="{{location.latitude}}")
label
| {{_ 'location-longitude'}}
input.js-location-longitude(type="number" step="any" value="{{location.longitude}}")
button.primary.confirm.js-submit-location(type="submit") {{_ 'save'}}
if location._id
button.negate.wide.right.js-delete-location(type="button") {{_ 'delete'}}
hr
.map-provider-row
span.map-provider-label {{_ 'location-open-map-at'}}
select.js-map-provider
option(value="openstreetmap" selected="{{#if isMapProvider 'openstreetmap'}}selected{{/if}}") OpenStreetMap
option(value="google" selected="{{#if isMapProvider 'google'}}selected{{/if}}") Google Maps
option(value="bing" selected="{{#if isMapProvider 'bing'}}selected{{/if}}") Bing Maps
option(value="apple" selected="{{#if isMapProvider 'apple'}}selected{{/if}}") Apple Maps
button.primary.js-save-map-provider(type="button") {{_ 'save'}}
if mapSavedMessage
p.quiet.js-map-saved {{mapSavedMessage}}
File diff suppressed because it is too large. Load diff
@@ -1,7 +0,0 @@
template(name="cardStickersPopup")
.card-stickers-picker.clearfix
each stickerIcons
a.card-sticker-select.js-select-sticker(class="{{#if isSelected}}active{{/if}}" title="{{stickerTitle}}")
i.fa(class="fa-{{icon}} {{#if highlight}}sticker-{{highlight}}{{/if}}")
if isSelected
i.fa.fa-check.card-sticker-check
-39
View File
@@ -1,39 +0,0 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { STICKER_PICKER } from '/models/metadata/stickers';
// Picker popup that lets a user add/remove stickers on a card. Each entry is
// { icon, highlight?, name? }: the plain Font Awesome icons plus the mascot
// (underlined) and computer (ringed) highlighted stickers. Opened from the card
// details stickers section with the card as the data context.
Template.cardStickersPopup.onCreated(function () {
const data = Template.currentData();
this.cardId = data && data._id;
});
function pickerCard(tpl) {
return ReactiveCache.getCard(tpl.cardId);
}
Template.cardStickersPopup.helpers({
stickerIcons() {
return STICKER_PICKER;
},
isSelected() {
const card = pickerCard(Template.instance());
return !!(card && card.hasSticker(this.icon, this.highlight));
},
stickerTitle() {
return this.name || this.icon;
},
});
Template.cardStickersPopup.events({
'click .js-select-sticker'(event) {
event.preventDefault();
const card = pickerCard(Template.instance());
if (card) {
card.toggleSticker(this.icon, this.highlight, this.name);
}
},
});
+6 -6
View File
@@ -1,4 +1,4 @@
template(name="editCardSpentTimePopup")
template(name="editCardSpentTime")
.edit-card-time
form.edit-time
.fields
@@ -13,10 +13,10 @@ template(name="editCardSpentTimePopup")
button.primary.wide.left.js-submit-time(type="submit") {{_ 'save'}}
button.js-delete-time.negate.wide.right {{_ 'delete'}}
template(name="cardSpentTime")
template(name="timeBadge")
if canModifyCard
a.js-edit-time.card-time(title="{{_ 'time'}}" class="{{#if getIsOvertime}}card-label-red{{else}}card-label-green{{/if}}")
| ⏱️ {{showTime}}
a.js-edit-time.card-time(title="{{showTitle}}" class="{{#if getIsOvertime}}card-label-red{{else}}card-label-green{{/if}}")
| {{showTime}}
else
a.card-time(title="{{_ 'time'}}" class="{{#if getIsOvertime}}card-label-red{{else}}card-label-green{{/if}}")
| ⏱️ {{showTime}}
a.card-time(title="{{showTitle}}" class="{{#if getIsOvertime}}card-label-red{{else}}card-label-green{{/if}}")
| {{showTime}}
+67 -73
View File
@@ -1,91 +1,85 @@
import { TAPi18n } from '/imports/i18n';
import Cards from '/models/cards';
import { getCurrentCardIdFromContext } from '/client/lib/currentCard';
function getCardId() {
return getCurrentCardIdFromContext();
}
Template.editCardSpentTimePopup.onCreated(function () {
this.error = new ReactiveVar('');
this.card = Cards.findOne(getCardId());
});
Template.editCardSpentTimePopup.helpers({
error() {
return Template.instance().error;
BlazeComponent.extendComponent({
template() {
return 'editCardSpentTime';
},
card() {
return Cards.findOne(getCardId());
onCreated() {
this.error = new ReactiveVar('');
this.card = this.data();
},
getIsOvertime() {
const card = Cards.findOne(getCardId());
return card?.getIsOvertime ? card.getIsOvertime() : false;
},
});
Template.editCardSpentTimePopup.events({
//TODO : need checking this portion
'submit .edit-time'(evt, tpl) {
evt.preventDefault();
const card = Cards.findOne(getCardId());
if (!card) return;
const spentTime = parseFloat(evt.target.time.value);
let isOvertime = false;
if ($('#overtime').attr('class').indexOf('is-checked') >= 0) {
isOvertime = true;
}
if (spentTime >= 0) {
card.setSpentTime(spentTime);
card.setIsOvertime(isOvertime);
Popup.back();
} else {
tpl.error.set('invalid-time');
evt.target.time.focus();
}
},
'click .js-delete-time'(evt) {
evt.preventDefault();
const card = Cards.findOne(getCardId());
if (!card) return;
card.setSpentTime(null);
card.setIsOvertime(false);
Popup.back();
},
'click a.js-toggle-overtime'(evt) {
const card = Cards.findOne(getCardId());
if (!card) return;
card.setIsOvertime(!card.getIsOvertime());
toggleOvertime() {
this.card.setIsOvertime(!this.card.getIsOvertime());
$('#overtime .materialCheckBox').toggleClass('is-checked');
$('#overtime').toggleClass('is-checked');
},
});
storeTime(spentTime, isOvertime) {
this.card.setSpentTime(spentTime);
this.card.setIsOvertime(isOvertime);
},
deleteTime() {
this.card.setSpentTime(null);
this.card.setIsOvertime(false);
},
events() {
return [
{
//TODO : need checking this portion
'submit .edit-time'(evt) {
evt.preventDefault();
Template.cardSpentTime.helpers({
const spentTime = parseFloat(evt.target.time.value);
//const isOvertime = this.card.getIsOvertime();
let isOvertime = false;
if ($('#overtime').attr('class').indexOf('is-checked') >= 0) {
isOvertime = true;
}
if (spentTime >= 0) {
this.storeTime(spentTime, isOvertime);
Popup.back();
} else {
this.error.set('invalid-time');
evt.target.time.focus();
}
},
'click .js-delete-time'(evt) {
evt.preventDefault();
this.deleteTime();
Popup.back();
},
'click a.js-toggle-overtime': this.toggleOvertime,
},
];
},
}).register('editCardSpentTimePopup');
BlazeComponent.extendComponent({
template() {
return 'timeBadge';
},
onCreated() {
const self = this;
self.time = ReactiveVar();
},
showTitle() {
const card = Cards.findOne(this._id) || this;
if (card.getIsOvertime && card.getIsOvertime()) {
if (this.data().getIsOvertime()) {
return `${TAPi18n.__(
'overtime',
)} ${card.getSpentTime()} ${TAPi18n.__('hours')}`;
} else if (card.getSpentTime) {
)} ${this.data().getSpentTime()} ${TAPi18n.__('hours')}`;
} else {
return `${TAPi18n.__(
'card-spent',
)} ${card.getSpentTime()} ${TAPi18n.__('hours')}`;
)} ${this.data().getSpentTime()} ${TAPi18n.__('hours')}`;
}
return '';
},
showTime() {
const card = Cards.findOne(this._id) || this;
return card.getSpentTime ? card.getSpentTime() : '';
return this.data().getSpentTime();
},
getIsOvertime() {
const card = Cards.findOne(this._id) || this;
return card.getIsOvertime ? card.getIsOvertime() : false;
events() {
return [
{
'click .js-edit-time': Popup.open('editCardSpentTime'),
},
];
},
});
Template.cardSpentTime.events({
'click .js-edit-time': Popup.open('editCardSpentTime'),
});
}).register('cardSpentTime');
+2 -4
View File
@@ -37,12 +37,10 @@ textarea.js-edit-checklist-item {
.checklist-progress-bar-container .checklist-progress-bar {
width: 80%;
height: 10px;
background-color: #e0e0e0;
border-radius: 16px;
}
.checklist-progress-bar-container .checklist-progress-bar .checklist-progress {
color: #fff;
background-color: #666;
color: #fff !important;
background-color: #2196f3 !important;
padding: 0.01em 16px;
border-radius: 16px;
height: 100%;
+3 -30
View File
@@ -4,8 +4,7 @@ template(name="checklists")
i.fa.fa-check
| {{_ 'checklists'}}
if canModifyCard
+inlinedForm(autoclose=false classNames="js-add-checklist" cardId = cardId
position="top")
+inlinedForm(autoclose=false classNames="js-add-checklist" cardId = cardId position="top")
+addChecklistItemForm
else
a.add-checklist-top.js-open-inlined-form(title="{{_ 'add-checklist'}}")
@@ -34,7 +33,7 @@ template(name="checklists")
template(name="checklistDetail")
.js-checklist.checklist.nodragscroll
+inlinedForm(classNames="js-edit-checklist-title" checklist = checklist)
+editChecklistForm(checklist = checklist)
+editChecklistItemForm(checklist = checklist)
else
.checklist-title
span
@@ -63,10 +62,6 @@ template(name="checklistDeletePopup")
p {{_ 'confirm-checklist-delete-popup'}}
button.js-confirm.negate.full(type="submit") {{_ 'delete'}}
template(name="checklistItemDeletePopup")
p {{_ 'confirm-checklist-item-delete-popup'}}
button.js-confirm.negate.full(type="submit") {{_ 'delete'}}
template(name="addChecklistItemForm")
a.fa.fa-copy(title="{{_ 'copy-text-to-clipboard'}}")
span.copied-tooltip {{_ 'copied'}}
@@ -85,19 +80,6 @@ template(name="addChecklistItemForm")
label.toggle-label(for="toggleNewlineBecomesNewChecklistItemOriginOrder")
| {{_ 'originOrder'}}
template(name="editChecklistForm")
a.fa.fa-copy(title="{{_ 'copy-text-to-clipboard'}}")
span.copied-tooltip {{_ 'copied'}}
textarea.js-edit-checklist-item(rows='1' autofocus dir="auto")
if $eq type 'item'
= item.title
else
= checklist.title
.edit-controls.clearfix
button.primary.confirm.js-submit-edit-checklist-item-form(type="submit") {{_ 'save'}}
a.fa.fa-times-thin.js-close-inlined-form(title="{{_ 'close-edit-checklist-item'}}")
span(title=createdAt) {{ displayDate createdAt }}
template(name="editChecklistItemForm")
a.fa.fa-copy(title="{{_ 'copy-text-to-clipboard'}}")
span.copied-tooltip {{_ 'copied'}}
@@ -109,7 +91,7 @@ template(name="editChecklistItemForm")
.edit-controls.clearfix
button.primary.confirm.js-submit-edit-checklist-item-form(type="submit") {{_ 'save'}}
a.fa.fa-times-thin.js-close-inlined-form(title="{{_ 'close-edit-checklist-item'}}")
span(title=createdAt) {{ displayDate createdAt }}
span(title=createdAt) {{ moment createdAt }}
if canModifyCard
a.js-delete-checklist-item {{_ "delete"}}...
a.js-convert-checklist-item-to-card
@@ -184,15 +166,6 @@ template(name="checklistActionsPopup")
else
input.toggle-switch(type="checkbox" id="toggleHideAllChecklistItems_{{checklist._id}}")
label.toggle-label(for="toggleHideAllChecklistItems_{{checklist._id}}")
a.js-show-checklist-at-minicard
i.fa.fa-th-list
| {{_ "show-on-minicard"}} ...
.material-toggle-switch(title="{{_ 'show-on-minicard'}}")
if checklist.showChecklistAtMinicard
input.toggle-switch(type="checkbox" id="toggleShowChecklistAtMinicard_{{checklist._id}}" checked="checked")
else
input.toggle-switch(type="checkbox" id="toggleShowChecklistAtMinicard_{{checklist._id}}")
label.toggle-label(for="toggleShowChecklistAtMinicard_{{checklist._id}}")
template(name="copyChecklistPopup")
+copyAndMoveChecklist
+245 -294
View File
@@ -2,15 +2,10 @@ import { ReactiveCache } from '/imports/reactiveCache';
import { TAPi18n } from '/imports/i18n';
import Cards from '/models/cards';
import Boards from '/models/boards';
import ChecklistItems from '/models/checklistItems';
import Checklists from '/models/checklists';
import { BoardSwimlaneListCardDialog } from '/client/lib/dialogWithBoardSwimlaneListCard';
import { EscapeActions } from '/client/lib/escapeActions';
import { Utils } from '/client/lib/utils';
import autosize from 'autosize';
import { DialogWithBoardSwimlaneListCard } from '/client/lib/dialogWithBoardSwimlaneListCard';
// SubsManager removed for Meteor 3 migration
const { calculateIndexData } = Utils;
const subManager = new SubsManager();
const { calculateIndexData, capitalize } = Utils;
function initSorting(items) {
items.sortable({
@@ -50,66 +45,55 @@ function initSorting(items) {
});
}
Template.checklistDetail.onRendered(function () {
const tpl = this;
tpl.itemsDom = this.$('.js-checklist-items');
initSorting(tpl.itemsDom);
tpl.itemsDom.mousedown(function (evt) {
evt.stopPropagation();
});
BlazeComponent.extendComponent({
onRendered() {
const self = this;
self.itemsDom = this.$('.js-checklist-items');
initSorting(self.itemsDom);
self.itemsDom.mousedown(function (evt) {
evt.stopPropagation();
});
function userIsMember() {
return ReactiveCache.getCurrentUser()?.isBoardMember();
}
// Disable sorting if the current user is not a board member
tpl.autorun(() => {
const $itemsDom = $(tpl.itemsDom);
if ($itemsDom.data('uiSortable') || $itemsDom.data('sortable')) {
$(tpl.itemsDom).sortable('option', 'disabled', !userIsMember());
if (Utils.isTouchScreenOrShowDesktopDragHandles()) {
$(tpl.itemsDom).sortable({
handle: 'span.fa.checklistitem-handle',
});
}
function userIsMember() {
return ReactiveCache.getCurrentUser()?.isBoardMember();
}
});
});
Template.checklistDetail.helpers({
// Disable sorting if the current user is not a board member
self.autorun(() => {
const $itemsDom = $(self.itemsDom);
if ($itemsDom.data('uiSortable') || $itemsDom.data('sortable')) {
$(self.itemsDom).sortable('option', 'disabled', !userIsMember());
if (Utils.isTouchScreenOrShowDesktopDragHandles()) {
$(self.itemsDom).sortable({
handle: 'span.fa.checklistitem-handle',
});
}
}
});
},
/** returns the finished percent of the checklist */
finishedPercent() {
const ret = this.checklist.finishedPercent();
const ret = this.data().checklist.finishedPercent();
return ret;
},
});
}).register('checklistDetail');
Template.checklists.helpers({
checklists() {
const card = ReactiveCache.getCard(this.cardId);
if (!card || typeof card.checklists !== 'function') {
return [];
}
const ret = card.checklists();
return ret;
},
});
Template.checklists.events({
'click .js-open-checklist-details-menu': Popup.open('checklistActions'),
'submit .js-add-checklist'(event, tpl) {
BlazeComponent.extendComponent({
addChecklist(event) {
event.preventDefault();
const textarea = tpl.find('textarea.js-add-checklist-item');
const textarea = this.find('textarea.js-add-checklist-item');
const title = textarea.value.trim();
let cardId = Template.currentData().cardId;
let cardId = this.currentData().cardId;
const card = ReactiveCache.getCard(cardId);
//if (card.isLinked()) cardId = card.linkedId;
if (card.isLinkedCard()) {
cardId = card.linkedId;
}
let sortIndex;
let checklistItemIndex;
if (Template.currentData().position === 'top') {
if (this.currentData().position === 'top') {
sortIndex = Utils.calculateIndexData(null, card.firstChecklist()).base;
checklistItemIndex = 0;
} else {
@@ -123,63 +107,35 @@ Template.checklists.events({
title,
sort: sortIndex,
});
tpl.$('.js-close-inlined-form').click();
this.closeAllInlinedForms();
setTimeout(() => {
tpl.$('.add-checklist-item')
this.$('.add-checklist-item')
.eq(checklistItemIndex)
.click();
}, 100);
}
},
'submit .js-edit-checklist-title'(event, tpl) {
addChecklistItem(event) {
event.preventDefault();
const textarea = tpl.find('textarea.js-edit-checklist-item');
const textarea = this.find('textarea.js-add-checklist-item');
const newlineBecomesNewChecklistItem = this.find('input#toggleNewlineBecomesNewChecklistItem');
const newlineBecomesNewChecklistItemOriginOrder = this.find('input#toggleNewlineBecomesNewChecklistItemOriginOrder');
const title = textarea.value.trim();
const formData = Blaze.getData(event.currentTarget) || Blaze.getData(event.target);
const checklist = formData?.checklist;
if (checklist) {
checklist.setTitle(title);
}
},
'submit .js-add-checklist-item'(event, tpl) {
event.preventDefault();
const textarea = tpl.find('textarea.js-add-checklist-item');
if (!textarea) {
return;
}
const newlineBecomesNewChecklistItem = tpl.find('input#toggleNewlineBecomesNewChecklistItem');
const newlineBecomesNewChecklistItemOriginOrder = tpl.find('input#toggleNewlineBecomesNewChecklistItemOriginOrder');
const title = textarea.value.trim();
const currentData = Template.currentData() || {};
let resolvedData = currentData;
let checklist = currentData.checklist;
if (!checklist) {
const form = event.currentTarget?.closest
? event.currentTarget.closest('form')
: $(event.target).closest('form').get(0);
const formData = form ? Blaze.getData(form) : null;
if (formData) {
resolvedData = formData;
}
checklist = formData?.checklist;
}
if (!checklist) {
return;
}
const checklist = this.currentData().checklist;
if (title) {
let checklistItems = [title];
if (newlineBecomesNewChecklistItem?.checked) {
if (newlineBecomesNewChecklistItem.checked) {
checklistItems = title.split('\n').map(_value => _value.trim());
if (resolvedData.position === 'top') {
if (newlineBecomesNewChecklistItemOriginOrder?.checked === false) {
if (this.currentData().position === 'top') {
if (newlineBecomesNewChecklistItemOriginOrder.checked === false) {
checklistItems = checklistItems.reverse();
}
}
}
let addIndex;
let sortIndex;
if (resolvedData.position === 'top') {
if (this.currentData().position === 'top') {
sortIndex = Utils.calculateIndexData(null, checklist.firstItem()).base;
addIndex = -1;
} else {
@@ -200,33 +156,33 @@ Template.checklists.events({
textarea.value = '';
textarea.focus();
},
'submit .js-edit-checklist-item'(event, tpl) {
event.preventDefault();
const textarea = tpl.find('textarea.js-edit-checklist-item');
const title = textarea.value.trim();
const formData = Blaze.getData(event.currentTarget) || Blaze.getData(event.target);
const item = formData?.item;
if (item) {
item.setTitle(title);
}
},
'click .js-convert-checklist-item-to-card': Popup.open('convertChecklistItemToCard'),
'click .js-delete-checklist-item': Popup.afterConfirm('checklistItemDelete', function () {
Popup.back();
const item = this?.item || this;
if (item && item._id) {
deleteItem() {
const checklist = this.currentData().checklist;
const item = this.currentData().item;
if (checklist && item && item._id) {
ChecklistItems.remove(item._id);
}
}),
// add and delete checklist / checklist-item
'click .js-open-inlined-form'(event, tpl) {
tpl.$('.js-close-inlined-form').click();
},
'click #toggleHideFinishedChecklist'(event) {
editChecklist(event) {
event.preventDefault();
Template.currentData().card.toggleHideFinishedChecklist();
const textarea = this.find('textarea.js-edit-checklist-item');
const title = textarea.value.trim();
const checklist = this.currentData().checklist;
checklist.setTitle(title);
},
'keydown textarea.js-add-checklist-item'(event) {
editChecklistItem(event) {
event.preventDefault();
const textarea = this.find('textarea.js-edit-checklist-item');
const title = textarea.value.trim();
const item = this.currentData().item;
item.setTitle(title);
},
pressKey(event) {
//If user press enter key inside a form, submit it
//Unless the user is also holding down the 'shift' key
if (event.keyCode === 13 && !event.shiftKey) {
@@ -235,206 +191,201 @@ Template.checklists.events({
$form.find('button[type=submit]').click();
}
},
});
// NOTE: boardsSwimlanesAndLists template was removed from jade but JS was left behind.
// This is dead code — the template no longer exists in any jade file.
Template.addChecklistItemForm.onRendered(function () {
autosize(this.$('textarea.js-add-checklist-item'));
});
Template.addChecklistItemForm.events({
'click a.fa.fa-copy'(event, tpl) {
const $editor = tpl.$('textarea');
const promise = Utils.copyTextToClipboard($editor[0].value);
const $tooltip = tpl.$('.copied-tooltip');
Utils.showCopied(promise, $tooltip);
},
});
Template.checklistActionsPopup.events({
'click .js-delete-checklist': Popup.afterConfirm('checklistDelete', function () {
Popup.back(2);
const checklist = this.checklist;
if (checklist && checklist._id) {
Checklists.remove(checklist._id);
focusChecklistItem(event) {
// If a new checklist is created, pre-fill the title and select it.
const checklist = this.currentData().checklist;
if (!checklist) {
const textarea = event.target;
textarea.value = capitalize(TAPi18n.__('r-checklist'));
textarea.select();
}
}),
'click .js-move-checklist': Popup.open('moveChecklist'),
'click .js-copy-checklist': Popup.open('copyChecklist'),
'click .js-hide-checked-checklist-items'(event) {
event.preventDefault();
Template.currentData().checklist.toggleHideCheckedChecklistItems();
Popup.back();
},
'click .js-hide-all-checklist-items'(event) {
event.preventDefault();
Template.currentData().checklist.toggleHideAllChecklistItems();
Popup.back();
/** closes all inlined forms (checklist and checklist-item input fields) */
closeAllInlinedForms() {
this.$('.js-close-inlined-form').click();
},
'click .js-show-checklist-at-minicard'(event) {
event.preventDefault();
Template.currentData().checklist.toggleShowChecklistAtMinicard();
Popup.back();
events() {
return [
{
'click .js-open-checklist-details-menu': Popup.open('checklistActions'),
'submit .js-add-checklist': this.addChecklist,
'submit .js-edit-checklist-title': this.editChecklist,
'submit .js-add-checklist-item': this.addChecklistItem,
'submit .js-edit-checklist-item': this.editChecklistItem,
'click .js-convert-checklist-item-to-card': Popup.open('convertChecklistItemToCard'),
'click .js-delete-checklist-item': this.deleteItem,
'focus .js-add-checklist-item': this.focusChecklistItem,
// add and delete checklist / checklist-item
'click .js-open-inlined-form': this.closeAllInlinedForms,
'click #toggleHideFinishedChecklist'(event) {
event.preventDefault();
this.data().card.toggleHideFinishedChecklist();
},
keydown: this.pressKey,
},
];
},
}).register('checklists');
BlazeComponent.extendComponent({
onCreated() {
subManager.subscribe('board', Session.get('currentBoard'), false);
this.selectedBoardId = new ReactiveVar(Session.get('currentBoard'));
},
boards() {
const ret = ReactiveCache.getBoards(
{
archived: false,
'members.userId': Meteor.userId(),
_id: { $ne: ReactiveCache.getCurrentUser().getTemplatesBoardId() },
},
{
sort: { sort: 1 /* boards default sorting */ },
},
);
return ret;
},
swimlanes() {
const board = ReactiveCache.getBoard(this.selectedBoardId.get());
return board.swimlanes();
},
aBoardLists() {
const board = ReactiveCache.getBoard(this.selectedBoardId.get());
return board.lists();
},
events() {
return [
{
'change .js-select-boards'(event) {
this.selectedBoardId.set($(event.currentTarget).val());
subManager.subscribe('board', this.selectedBoardId.get(), false);
},
},
];
},
}).register('boardsSwimlanesAndLists');
Template.checklists.helpers({
checklists() {
const card = ReactiveCache.getCard(this.cardId);
const ret = card.checklists();
return ret;
},
});
Template.editChecklistItemForm.onRendered(function () {
autosize(this.$('textarea.js-edit-checklist-item'));
});
Template.editChecklistItemForm.events({
'click a.fa.fa-copy'(event, tpl) {
const $editor = tpl.$('textarea');
const promise = Utils.copyTextToClipboard($editor[0].value);
const $tooltip = tpl.$('.copied-tooltip');
Utils.showCopied(promise, $tooltip);
BlazeComponent.extendComponent({
onRendered() {
autosize(this.$('textarea.js-add-checklist-item'));
},
});
events() {
return [
{
'click a.fa.fa-copy'(event) {
const $editor = this.$('textarea');
const promise = Utils.copyTextToClipboard($editor[0].value);
const $tooltip = this.$('.copied-tooltip');
Utils.showCopied(promise, $tooltip);
},
}
];
}
}).register('addChecklistItemForm');
BlazeComponent.extendComponent({
events() {
return [
{
'click .js-delete-checklist': Popup.afterConfirm('checklistDelete', function () {
Popup.back(2);
const checklist = this.checklist;
if (checklist && checklist._id) {
Checklists.remove(checklist._id);
}
}),
'click .js-move-checklist': Popup.open('moveChecklist'),
'click .js-copy-checklist': Popup.open('copyChecklist'),
'click .js-hide-checked-checklist-items'(event) {
event.preventDefault();
this.data().checklist.toggleHideCheckedChecklistItems();
Popup.back();
},
'click .js-hide-all-checklist-items'(event) {
event.preventDefault();
this.data().checklist.toggleHideAllChecklistItems();
Popup.back();
},
}
]
}
}).register('checklistActionsPopup');
BlazeComponent.extendComponent({
onRendered() {
autosize(this.$('textarea.js-edit-checklist-item'));
},
events() {
return [
{
'click a.fa.fa-copy'(event) {
const $editor = this.$('textarea');
const promise = Utils.copyTextToClipboard($editor[0].value);
const $tooltip = this.$('.copied-tooltip');
Utils.showCopied(promise, $tooltip);
},
}
];
}
}).register('editChecklistItemForm');
Template.checklistItemDetail.helpers({
});
Template.checklistItemDetail.events({
'click .js-checklist-item .check-box-container'() {
const checklist = Template.currentData().checklist;
const item = Template.currentData().item;
BlazeComponent.extendComponent({
toggleItem() {
const checklist = this.currentData().checklist;
const item = this.currentData().item;
if (checklist && item && item._id) {
item.toggleItem();
}
},
});
/**
* Helper to find the dialog instance from a parent popup template.
* copyAndMoveChecklist is included inside moveChecklistPopup / copyChecklistPopup,
* so we traverse up the view hierarchy to find the parent template's dialog.
*/
function getParentDialog(tpl) {
let view = tpl.view.parentView;
while (view) {
if (view.templateInstance && view.templateInstance() && view.templateInstance().dialog) {
return view.templateInstance().dialog;
}
view = view.parentView;
}
return null;
}
/** Shared helpers for copyAndMoveChecklist sub-template */
Template.copyAndMoveChecklist.helpers({
boards() {
const dialog = getParentDialog(Template.instance());
return dialog ? dialog.boards() : [];
events() {
return [
{
'click .js-checklist-item .check-box-container': this.toggleItem,
},
];
},
swimlanes() {
const dialog = getParentDialog(Template.instance());
return dialog ? dialog.swimlanes() : [];
},
lists() {
const dialog = getParentDialog(Template.instance());
return dialog ? dialog.lists() : [];
},
cards() {
const dialog = getParentDialog(Template.instance());
return dialog ? dialog.cards() : [];
},
isDialogOptionBoardId(boardId) {
const dialog = getParentDialog(Template.instance());
return dialog ? dialog.isDialogOptionBoardId(boardId) : false;
},
isDialogOptionSwimlaneId(swimlaneId) {
const dialog = getParentDialog(Template.instance());
return dialog ? dialog.isDialogOptionSwimlaneId(swimlaneId) : false;
},
isDialogOptionListId(listId) {
const dialog = getParentDialog(Template.instance());
return dialog ? dialog.isDialogOptionListId(listId) : false;
},
isDialogOptionCardId(cardId) {
const dialog = getParentDialog(Template.instance());
return dialog ? dialog.isDialogOptionCardId(cardId) : false;
},
isTitleDefault(title) {
const dialog = getParentDialog(Template.instance());
return dialog ? dialog.isTitleDefault(title) : title;
},
});
/**
* Helper: register standard card dialog events on a checklist popup template.
* Events bubble up from the copyAndMoveChecklist sub-template to the parent popup.
*/
function registerChecklistDialogEvents(templateName) {
Template[templateName].events({
async 'click .js-done'(event, tpl) {
const dialog = tpl.dialog;
const boardSelect = tpl.$('.js-select-boards')[0];
const boardId = boardSelect.options[boardSelect.selectedIndex].value;
const listSelect = tpl.$('.js-select-lists')[0];
const listId = listSelect.options[listSelect.selectedIndex].value;
const swimlaneSelect = tpl.$('.js-select-swimlanes')[0];
const swimlaneId = swimlaneSelect.options[swimlaneSelect.selectedIndex].value;
const cardSelect = tpl.$('.js-select-cards')[0];
const cardId = cardSelect.options.length > 0
? cardSelect.options[cardSelect.selectedIndex].value
: null;
const options = { boardId, swimlaneId, listId, cardId };
try {
await dialog.setDone(cardId, options);
} catch (e) {
console.error('Error in card dialog operation:', e);
}
Popup.back(2);
},
'change .js-select-boards'(event, tpl) {
tpl.dialog.getBoardData($(event.currentTarget).val());
},
'change .js-select-swimlanes'(event, tpl) {
tpl.dialog.selectedSwimlaneId.set($(event.currentTarget).val());
tpl.dialog.setFirstListId();
},
'change .js-select-lists'(event, tpl) {
tpl.dialog.selectedListId.set($(event.currentTarget).val());
tpl.dialog.selectedCardId.set('');
},
'change .js-select-cards'(event, tpl) {
tpl.dialog.selectedCardId.set($(event.currentTarget).val());
},
});
}
}).register('checklistItemDetail');
/** Move Checklist Dialog */
Template.moveChecklistPopup.onCreated(function () {
this.dialog = new BoardSwimlaneListCardDialog(this, {
getDialogOptions() {
return ReactiveCache.getCurrentUser().getMoveChecklistDialogOptions();
},
async setDone(cardId, options) {
ReactiveCache.getCurrentUser().setMoveChecklistDialogOption(this.currentBoardId, options);
await Template.currentData().checklist.move(cardId);
},
});
});
registerChecklistDialogEvents('moveChecklistPopup');
(class extends DialogWithBoardSwimlaneListCard {
getDialogOptions() {
const ret = ReactiveCache.getCurrentUser().getMoveChecklistDialogOptions();
return ret;
}
setDone(cardId, options) {
ReactiveCache.getCurrentUser().setMoveChecklistDialogOption(this.currentBoardId, options);
this.data().checklist.move(cardId);
}
}).register('moveChecklistPopup');
/** Copy Checklist Dialog */
Template.copyChecklistPopup.onCreated(function () {
this.dialog = new BoardSwimlaneListCardDialog(this, {
getDialogOptions() {
return ReactiveCache.getCurrentUser().getCopyChecklistDialogOptions();
},
async setDone(cardId, options) {
ReactiveCache.getCurrentUser().setCopyChecklistDialogOption(this.currentBoardId, options);
await Template.currentData().checklist.copy(cardId);
},
});
});
registerChecklistDialogEvents('copyChecklistPopup');
(class extends DialogWithBoardSwimlaneListCard {
getDialogOptions() {
const ret = ReactiveCache.getCurrentUser().getCopyChecklistDialogOptions();
return ret;
}
setDone(cardId, options) {
ReactiveCache.getCurrentUser().setCopyChecklistDialogOption(this.currentBoardId, options);
this.data().checklist.copy(cardId);
}
}).register('copyChecklistPopup');
@@ -1,6 +0,0 @@
template(name='inlinedCardDescription')
if isOpen.get
form.inlined-form.js-inlined-form(id=id class=classNames)
+Template.contentBlock
else
+Template.elseBlock
+2 -7
View File
@@ -38,7 +38,6 @@
.palette-colors {
display: flex;
flex-wrap: wrap;
justify-content: flex-start; /* left-align color chips in wider popovers */
}
.palette-colors .palette-color {
flex-grow: 1;
@@ -223,13 +222,9 @@
.card-label-edit-button:hover {
background: #dbdbdb;
}
ul.edit-labels-pop-over span.label-handle {
ul.edit-labels-pop-over span.fa.label-handle {
padding-right: 10px;
display: inline-block;
width: 1.2em;
text-align: center;
color: #999;
}
ul.edit-labels-pop-over span.label-handle + .card-label {
ul.edit-labels-pop-over span.fa.label-handle + .card-label {
max-width: 180px;
}
+98 -106
View File
@@ -1,117 +1,119 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { LABEL_COLORS } from '/models/metadata/colors';
import { EscapeActions } from '/client/lib/escapeActions';
import { Utils } from '/client/lib/utils';
let labelColors;
Meteor.startup(() => {
labelColors = LABEL_COLORS;
labelColors = Boards.simpleSchema()._schema['labels.$.color'].allowedValues;
});
const getFallbackLabelColor = () => {
if (Array.isArray(labelColors) && labelColors.length > 0) {
return labelColors[0];
}
return 'green';
};
BlazeComponent.extendComponent({
onCreated() {
this.currentColor = new ReactiveVar(this.data().color);
},
Template.formLabel.onCreated(function () {
const initialColor = this.data?.color || getFallbackLabelColor();
this.currentColor = new ReactiveVar(initialColor);
});
Template.formLabel.helpers({
labels() {
const colors = Array.isArray(labelColors) ? labelColors : [getFallbackLabelColor()];
return colors.map(color => ({ color, name: '' }));
return labelColors.map(color => ({ color, name: '' }));
},
isSelected(color) {
return Template.instance().currentColor.get() === color;
},
});
Template.formLabel.events({
'click .js-palette-color'(event, tpl) {
const paletteData = Blaze.getData(event.currentTarget);
const selectedColor = paletteData?.color || Template.currentData()?.color || getFallbackLabelColor();
tpl.currentColor.set(selectedColor);
isSelected(color) {
return this.currentColor.get() === color;
},
});
events() {
return [
{
'click .js-palette-color'() {
this.currentColor.set(this.currentData().color);
},
},
];
},
}).register('formLabel');
Template.createLabelPopup.helpers({
// This is the default color for a new label. We search the first color that
// is not already used in the board (although it's not a problem if two
// labels have the same color).
defaultColor() {
const board = Utils.getCurrentBoard();
const colors = Array.isArray(labelColors) ? labelColors : [getFallbackLabelColor()];
const labels = Array.isArray(board?.labels) ? board.labels : [];
const usedColors = labels.map(l => l.color);
const availableColors = colors.filter(c => !usedColors.includes(c));
return availableColors.length > 0 ? availableColors[0] : colors[0];
const labels = Utils.getCurrentBoard().labels;
const usedColors = _.pluck(labels, 'color');
const availableColors = _.difference(labelColors, usedColors);
return availableColors.length > 1 ? availableColors[0] : labelColors[0];
},
});
Template.cardLabelsPopup.onRendered(function () {
const tpl = this;
const itemsSelector = 'li.js-card-label-item:not(.placeholder)';
const $labels = tpl.$('.edit-labels-pop-over');
BlazeComponent.extendComponent({
onRendered() {
const itemsSelector = 'li.js-card-label-item:not(.placeholder)';
const $labels = this.$('.edit-labels-pop-over');
$labels.sortable({
connectWith: '.edit-labels-pop-over',
tolerance: 'pointer',
appendTo: '.edit-labels-pop-over',
helper(element, currentItem) {
let ret = currentItem.clone();
if (currentItem.closest('.popup-container-depth-0').length == 0)
{ // only set css transform at every sub-popup, not at the main popup
const content = currentItem.closest('.content')[0]
const offsetLeft = content.offsetLeft;
const offsetTop = $('.pop-over > .header').height() * -1;
ret.css("transform", `translate(${offsetLeft}px, ${offsetTop}px)`);
$labels.sortable({
connectWith: '.edit-labels-pop-over',
tolerance: 'pointer',
appendTo: '.edit-labels-pop-over',
helper(element, currentItem) {
let ret = currentItem.clone();
if (currentItem.closest('.popup-container-depth-0').length == 0)
{ // only set css transform at every sub-popup, not at the main popup
const content = currentItem.closest('.content')[0]
const offsetLeft = content.offsetLeft;
const offsetTop = $('.pop-over > .header').height() * -1;
ret.css("transform", `translate(${offsetLeft}px, ${offsetTop}px)`);
}
return ret;
},
distance: 7,
items: itemsSelector,
placeholder: 'card-label-wrapper placeholder',
start(evt, ui) {
ui.helper.css('z-index', 1000);
ui.placeholder.height(ui.helper.height());
EscapeActions.clickExecute(evt.target, 'inlinedForm');
},
stop(evt, ui) {
const newLabelOrderOnlyIds = ui.item.parent().children().toArray().map(_element => Blaze.getData(_element)._id)
const card = Blaze.getData(this);
card.board().setNewLabelOrder(newLabelOrderOnlyIds);
},
});
// Disable drag-dropping if the current user is not a board member or is comment only
this.autorun(() => {
if (Utils.isTouchScreenOrShowDesktopDragHandles()) {
$labels.sortable({
handle: '.label-handle',
});
}
return ret;
},
distance: 7,
items: itemsSelector,
placeholder: 'card-label-wrapper placeholder',
start(evt, ui) {
ui.helper.css('z-index', 1000);
ui.placeholder.height(ui.helper.height());
EscapeActions.clickExecute(evt.target, 'inlinedForm');
},
stop(evt, ui) {
const newLabelOrderOnlyIds = ui.item.parent().children().toArray().map(_element => Blaze.getData(_element)._id)
const card = Blaze.getData(this);
card.board().setNewLabelOrder(newLabelOrderOnlyIds);
},
});
// Disable drag-dropping if the current user is not a board member or is comment only
tpl.autorun(() => {
if (Utils.isTouchScreenOrShowDesktopDragHandles()) {
$labels.sortable({
handle: '.label-handle',
});
}
});
});
Template.cardLabelsPopup.helpers({
isLabelSelected(cardId) {
return (ReactiveCache.getCard(cardId).labelIds || []).includes(this._id);
});
},
});
events() {
return [
{
'click .js-select-label'(event) {
const card = this.data();
const labelId = this.currentData()._id;
card.toggleLabel(labelId);
event.preventDefault();
},
'click .js-edit-label': Popup.open('editLabel'),
'click .js-add-label': Popup.open('createLabel'),
}
];
}
}).register('cardLabelsPopup');
Template.cardLabelsPopup.events({
'click .js-select-label'(event) {
const card = Template.currentData();
const labelId = this._id;
card.toggleLabel(labelId);
event.preventDefault();
});
Template.formLabel.events({
'click .js-palette-color'(event) {
const $this = $(event.currentTarget);
// hide selected ll colors
$('.js-palette-select').addClass('hide');
// show select color
$this.find('.js-palette-select').removeClass('hide');
},
'click .js-edit-label': Popup.open('editLabel'),
'click .js-add-label': Popup.open('createLabel'),
});
Template.createLabelPopup.events({
@@ -119,19 +121,11 @@ Template.createLabelPopup.events({
'submit .create-label'(event, templateInstance) {
event.preventDefault();
const board = Utils.getCurrentBoard();
if (!board) {
return;
}
const name = templateInstance
.$('#labelName')
.val()
.trim();
const selectedColorIcon = templateInstance.find('.js-palette-color .fa-check');
const selectedPaletteNode = selectedColorIcon?.closest
? selectedColorIcon.closest('.js-palette-color')
: null;
const selectedColorData = selectedPaletteNode && Blaze.getData(selectedPaletteNode);
const color = selectedColorData?.color || getFallbackLabelColor();
const color = Blaze.getData(templateInstance.find('.fa-check')).color;
board.addLabel(name, color);
Popup.back();
},
@@ -146,20 +140,18 @@ Template.editLabelPopup.events({
'submit .edit-label'(event, templateInstance) {
event.preventDefault();
const board = Utils.getCurrentBoard();
if (!board) {
return;
}
const name = templateInstance
.$('#labelName')
.val()
.trim();
const selectedColorIcon = templateInstance.find('.js-palette-color .fa-check');
const selectedPaletteNode = selectedColorIcon?.closest
? selectedColorIcon.closest('.js-palette-color')
: null;
const selectedColorData = selectedPaletteNode && Blaze.getData(selectedPaletteNode);
const color = selectedColorData?.color || getFallbackLabelColor();
const color = Blaze.getData(templateInstance.find('.fa-check')).color;
board.editLabel(this._id, name, color);
Popup.back();
},
});
Template.cardLabelsPopup.helpers({
isLabelSelected(cardId) {
return _.contains(ReactiveCache.getCard(cardId).labelIds, this._id);
},
});
+58 -344
View File
@@ -3,11 +3,11 @@
position: relative;
display: flex;
align-items: center;
margin-bottom: 8px;
margin-bottom: 9px;
}
.minicard-wrapper.placeholder {
background: #ccc;
border-radius: 1.2vw;
border-radius: 9px;
}
.minicard-wrapper.ui-sortable-helper {
cursor: grabbing;
@@ -16,40 +16,28 @@
}
.minicard-wrapper.ui-sortable-helper .and-n-other {
width: 100%;
height: 25px;
padding: 5px;
height: 16px;
padding: 4px;
background-color: #f2f2f2;
text-align: center;
border-radius: 0.4vw;
border-radius: 3px;
}
.minicard-wrapper.ui-sortable-helper .multi-selection-checkbox {
display: none;
}
.minicard-wrapper .multi-selection-checkbox + .minicard {
margin-left: 1vw;
}
/* Enlarge the clickable area of the small selection checkbox so it can be
reliably clicked at the left side of the minicard. The visible 13px box (and
its rotated is-checked checkmark) keep their size; an invisible overlay only
extends the hit target. */
.minicard-wrapper .multi-selection-checkbox::before {
content: "";
position: absolute;
top: -8px;
left: -8px;
right: -8px;
bottom: -8px;
margin-left: 8px;
}
@media only screen {
.minicard {
padding: 8px 1vw 3px;
padding: 6px 8px 2px;
position: relative;
flex: 1;
flex-wrap: wrap;
background-color: #fff;
min-height: 25px;
box-shadow: 0 2px 3px rgba(0,0,0,0.15);
border-radius: 0.3vw;
min-height: 20px;
box-shadow: 0 1px 2px rgba(0,0,0,0.15);
border-radius: 2px;
color: #4d4d4d;
overflow: hidden;
transition: transform 0.2s, border-radius 0.2s;
@@ -57,15 +45,14 @@
}
.minicard-details-menu-with-handle {
float: right;
padding-left: 0.7vw;
font-size: clamp(14px, 3vw, 18px);
padding: 0;
z-index: 1;
font-size: 18px;
padding-right: 30px;
padding-left: 5px;
}
.minicard-details-menu {
float: right;
font-size: clamp(14px, 3vw, 18px);
padding-left: 0.7vw;
font-size: 18px;
padding-left: 5px;
}
@media print {
.minicard-details-menu,
@@ -76,7 +63,7 @@
.minicard.linked-board .linked-icon,
.minicard.linked-card .linked-icon {
display: inline-block;
margin-right: 1.5vw;
margin-right: 11px;
vertical-align: baseline;
font-size: 0.9em;
}
@@ -85,11 +72,11 @@
color: #937760;
}
.is-selected .minicard {
transform: translateX(1.5vw);
transform: translateX(11px);
border-bottom-right-radius: 0;
border-top-right-radius: 0;
z-index: 25;
box-shadow: -0.3vw 2px 3px rgba(0,0,0,0.2);
box-shadow: -2px 1px 2px rgba(0,0,0,0.2);
}
.minicard:hover:not(.minicard-composer),
.is-selected .minicard,
@@ -103,21 +90,20 @@
background-position: center;
background-repeat: no-repeat;
background-size: contain;
height: 150px;
height: 145px;
user-select: none;
margin: 8px -1vw 8px -1vw;
border-radius: top 0.3vw;
margin: 6px -8px 6px -8px;
border-radius: top 2px;
}
.minicard .minicard-labels {
float: none;
margin-right: 6vw;
}
.minicard .minicard-labels .minicard-label {
width: clamp(12px, 1.5vw, 16px);
height: clamp(12px, 1.5vw, 16px);
border-radius: 0.3vw;
margin-right: 0.4vw;
margin-bottom: 4px;
width: 11px;
height: 11px;
border-radius: 2px;
margin-right: 3px;
margin-bottom: 3px;
}
.minicard .minicard-labels-no-text {
display: flex;
@@ -125,7 +111,6 @@
}
.minicard .minicard-custom-fields {
display: block;
margin-right: 6vw;
}
.minicard .minicard-custom-field {
display: flex;
@@ -134,44 +119,37 @@
flex-grow: 1;
display: block;
word-wrap: break-word;
max-width: 13vw;
margin-right: 0.5vw;
max-width: 100px;
margin-right: 4px;
}
.minicard .minicard-custom-field-item-fullwidth {
flex-grow: 1;
display: block;
word-wrap: break-word;
max-width: 100%;
margin-right: 0.5vw;
margin-right: 4px;
}
.minicard .handle {
width: clamp(20px, 2.5vw, 28px);
height: clamp(20px, 2.5vw, 28px);
width: 20px;
height: 20px;
position: absolute;
right: 0vw;
top: 28px;
right: 5px;
top: 5px;
display: none;
z-index: 1;
}
@media only screen {
.minicard .handle {
display: block;
}
}
.minicard .handle .drag-handle {
font-size: clamp(16px, 3vw, 20px);
.minicard .handle .fa-arrows {
font-size: 20px;
color: #ccc;
display: inline-block;
width: 1.4em;
text-align: center;
}
.minicard .minicard-title {
margin-right: 1.5vw;
}
.minicard .minicard-title .card-number {
color: #b3b3b3;
display: inline-block;
margin-right: 0.7vw;
margin-right: 5px;
}
@media only screen {
.minicard .minicard-title p:last-child {
@@ -186,146 +164,13 @@
display: flex;
flex-direction: row;
flex-wrap: wrap;
position: relative;
z-index: 5;
margin-right: 6vw;
clear: both;
}
.minicard .date {
margin-right: 0.4vw;
margin-right: 3px;
}
/* Unicode icons for minicard dates - matching cardDate.css */
.minicard .card-date.end-date time::before {
content: "🏁"; /* Finish flag - represents end/completion */
}
.minicard .card-date.due-date time::before {
content: "⏰"; /* Alarm clock - represents due/deadline */
}
.minicard .card-date.start-date time::before {
content: "🚀"; /* Rocket - represents start/launch */
}
.minicard .card-date.received-date time::before {
content: "📥"; /* Inbox tray - represents received/incoming */
}
.minicard .card-date time::before {
font-size: inherit;
margin-right: 0.3em;
display: inline-block;
}
/* Date type specific colors for minicards - matching cardDate.css */
.minicard .card-date.received-date {
background-color: #dbdbdb; /* Grey for received - same as base card-date */
}
.minicard .card-date.received-date:hover,
.minicard .card-date.received-date.is-active {
background-color: #b3b3b3;
}
.minicard .card-date.start-date {
background-color: #90ee90; /* Light green for start */
color: #000; /* Black text for start */
}
.minicard .card-date.start-date:hover,
.minicard .card-date.start-date.is-active {
background-color: #7dd87d;
}
.minicard .card-date.due-date {
background-color: #ffd700; /* Yellow for due */
color: #000; /* Black text for due */
}
.minicard .card-date.due-date:hover,
.minicard .card-date.due-date.is-active {
background-color: #e6c200;
}
.minicard .card-date.end-date {
background-color: #ffb3b3; /* Light red for end */
color: #000; /* Black text for end */
}
.minicard .card-date.end-date:hover,
.minicard .card-date.end-date.is-active {
background-color: #ff9999;
}
/* Date status colors for minicards - matching cardDate.css */
.minicard .card-date.overdue {
background-color: #ff4444 !important; /* Red for overdue */
color: #fff !important;
}
.minicard .card-date.overdue:hover,
.minicard .card-date.overdue.is-active {
background-color: #cc3333 !important;
}
.minicard .card-date.due-soon {
background-color: #ffaa00 !important; /* Amber for due soon */
color: #000 !important;
}
.minicard .card-date.due-soon:hover,
.minicard .card-date.due-soon.is-active {
background-color: #e69900 !important;
}
.minicard .card-date.not-due {
/* No special background - uses default date type colors */
}
.minicard .card-date.current {
background-color: #5ba639 !important; /* Green for current/active */
color: #fff !important;
}
.minicard .card-date.current:hover,
.minicard .card-date.current.is-active {
background-color: #46802c !important;
}
.minicard .card-date.completed {
background-color: #90ee90 !important; /* Light green for completed */
color: #000 !important;
}
.minicard .card-date.completed:hover,
.minicard .card-date.completed.is-active {
background-color: #7dd87d !important;
}
.minicard .card-date.completed-early {
background-color: #4caf50 !important; /* Green for completed early */
color: #fff !important;
}
.minicard .card-date.completed-early:hover,
.minicard .card-date.completed-early.is-active {
background-color: #45a049 !important;
}
.minicard .card-date.completed-late {
background-color: #ff9800 !important; /* Orange for completed late */
color: #fff !important;
}
.minicard .card-date.completed-late:hover,
.minicard .card-date.completed-late.is-active {
background-color: #f57c00 !important;
}
.minicard .card-date.completed-on-time {
background-color: #2196f3 !important; /* Blue for completed on time */
color: #fff !important;
}
.minicard .card-date.completed-on-time:hover,
.minicard .card-date.completed-on-time.is-active {
background-color: #1976d2 !important;
}
.minicard .badges {
float: left;
margin-top: 8px;
margin-top: 7px;
color: #808080;
}
.minicard .badges:empty {
@@ -333,14 +178,14 @@
}
.minicard .badges .badge {
float: left;
margin-right: 1.5vw;
margin-bottom: 4px;
margin-right: 11px;
margin-bottom: 3px;
font-size: 0.9em;
}
.minicard .badges .badge.is-finished {
background: #3cb500;
padding: 0 0.4vw;
border-radius: 0.4vw;
padding: 0px 3px;
border-radius: 3px;
color: #fff;
}
.minicard .badges .badge:last-of-type {
@@ -356,18 +201,18 @@
}
.minicard .badges .badge .badge-text {
font-size: 0.9em;
padding-left: 0.3vw;
line-height: 1.2;
padding-left: 2px;
line-height: 14px;
}
.minicard .badges .badge .check-list-text {
padding-left: 0px;
line-height: 1.1;
line-height: 12px;
}
.minicard .minicard-members,
.minicard .minicard-assignees,
.minicard .minicard-creator {
float: right;
margin-left: 0.7vw;
margin-left: 5px;
margin-bottom: 4px;
}
.minicard .minicard-members .member,
@@ -375,8 +220,8 @@
.minicard .minicard-creator .member {
float: right;
border-radius: 50%;
height: clamp(24px, 3.5vw, 32px);
width: clamp(24px, 3.5vw, 32px);
height: 28px;
width: 28px;
margin-bottom: 4px;
}
.minicard .minicard-members .assignee,
@@ -384,13 +229,13 @@
.minicard .minicard-creator .assignee {
float: right;
border-radius: 50%;
height: clamp(24px, 3.5vw, 32px);
width: clamp(24px, 3.5vw, 32px);
height: 28px;
width: 28px;
}
.minicard .minicard-members + .badges,
.minicard .minicard-assignees + .badges,
.minicard .minicard-creator + .badges {
margin-top: 6px;
margin-top: 5px;
}
.minicard .minicard-assignees {
border-bottom: 1px solid #f00;
@@ -403,13 +248,13 @@
display: none;
}
.minicard .minicard-description {
padding: 8px 0 0 1vw;
padding: 6px 0 0 8px;
color: #000;
background-color: #eee;
width: 100%;
margin-bottom: 3px;
margin-left: -0.5vw;
border-radius: 0.4vw;
margin-bottom: 2px;
margin-left: -4px;
border-radius: 3px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
@@ -417,7 +262,7 @@
-webkit-box-orient: vertical;
}
.minicard.minicard-composer {
margin-bottom: 12px;
margin-bottom: 10px;
}
.minicard.minicard-composer textarea.minicard-composer-textarea,
.minicard.minicard-composer textarea.minicard-composer-textarea:focus {
@@ -428,8 +273,8 @@
height: auto;
margin: 0;
padding: 0;
max-height: 200px;
min-height: 50px;
max-height: 162px;
min-height: 36px;
margin-bottom: 20px;
overflow-y: auto;
}
@@ -735,134 +580,3 @@
.text-green {
color: #008000;
}
/* Drag and drop file upload visual feedback */
.minicard.is-dragging-over {
border: 2px dashed #0079bf;
background-color: #e3f2fd !important;
transform: scale(1.02);
transition: all 0.2s ease;
}
/* List name display on minicard */
.minicard-list-name {
font-size: 0.75em;
color: #8c8c8c;
margin-top: 2px;
display: flex;
align-items: center;
gap: 0.3vw;
}
/* Checklist display on minicard */
.minicard-checklist {
width: 100%;
box-sizing: border-box;
clear: both;
margin-top: 2px;
margin-bottom: 5px;
padding: 3px 0.5vw;
background-color: rgba(255, 255, 255, 0.8);
border-radius: 0.3vw;
border: 1px solid #e0e0e0;
}
.minicard-checklist .checklist-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 3px;
}
.minicard-checklist .checklist-title {
font-size: 0.8em;
font-weight: bold;
color: #4d4d4d;
flex: 1;
margin: 0;
}
.minicard-checklist .checklist-menu {
font-size: 1.2em;
color: #666;
cursor: pointer;
padding: 0 0.3vw;
border-radius: 0.2vw;
transition: background-color 0.2s;
}
.minicard-checklist .checklist-menu:hover {
background-color: rgba(0, 0, 0, 0.1);
}
.minicard-checklist .checklist-item {
font-size: 0.75em;
color: #666;
margin-bottom: 2px;
display: flex;
align-items: flex-start;
gap: 0.3vw;
line-height: 1.2;
cursor: pointer;
padding: 2px 0;
border-radius: 0.2vw;
transition: background-color 0.2s;
}
.minicard-checklist .checklist-item:hover {
background-color: rgba(0, 0, 0, 0.05);
}
.minicard-checklist .checklist-item.is-checked {
text-decoration: line-through;
color: #999;
}
.minicard-checklist .checklist-item .check-box-unicode {
flex-shrink: 0;
font-size: 0.8em;
margin-top: 1px;
transition: transform 0.2s;
}
.minicard-checklist .checklist-item:hover .check-box-unicode {
transform: scale(1.1);
}
.minicard-checklist .checklist-item .item-title {
flex: 1;
word-wrap: break-word;
overflow-wrap: break-word;
}
/* Trello-style "mark complete" toggle, left of the minicard title */
.minicard-title .minicard-complete-toggle {
display: inline-flex;
align-items: center;
vertical-align: middle;
/* Nudge down so the checkmark sits at the vertical middle of the title text
(the checked materialCheckBox is shifted up 4px by its own positioning). */
position: relative;
top: 4px;
margin-right: 6px;
cursor: pointer;
}
/* Sticker icon highlight styles (used instead of colour): taco pack => underline,
pete pack => round (ring around the icon). Icon keeps its normal colour. */
.minicard-sticker i.sticker-underline {
border-bottom: 2px solid currentColor;
padding-bottom: 1px;
line-height: 1;
}
.minicard-sticker i.sticker-round {
border: 1px solid currentColor;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.6em;
height: 1.6em;
line-height: 1;
box-sizing: border-box;
}
+112 -132
View File
@@ -3,60 +3,35 @@ template(name="minicard")
class="{{#if isLinkedCard}}linked-card{{/if}}"
class="{{#if isLinkedBoard}}linked-board{{/if}}"
class="{{#if colorClass}}minicard-{{colorClass}}{{/if}}")
if canMoveCard
if isTouchScreenOrShowDesktopDragHandles
.handle
i.fa.fa-arrows
if canModifyCard
a.minicard-details-menu-with-handle.js-open-minicard-details-menu(title="{{_ 'cardDetailsActionsPopup-title'}}")
i.fa.fa-bars
if isTouchScreenOrShowDesktopDragHandles
a.fa.fa-navicon.minicard-details-menu-with-handle.js-open-minicard-details-menu(title="{{_ 'cardDetailsActionsPopup-title'}}")
.handle
.fa.fa-arrows
else
a.fa.fa-navicon.minicard-details-menu.js-open-minicard-details-menu(title="{{_ 'cardDetailsActionsPopup-title'}}")
.dates
if showReceived
if getReceived
.date
+minicardReceivedDate
if showStart
if getStart
.date
+minicardStartDate
if showDue
if getDue
.date(class="{{#if getDueComplete}}due-complete{{/if}}")
+minicardDueDate
if showEnd
if getEnd
+minicardEndDate
if getReceived
unless getStart
unless getDue
unless getEnd
.date
+minicardReceivedDate
if getStart
.date
+minicardStartDate
if getDue
.date
+minicardDueDate
if getEnd
+minicardEndDate
if getSpentTime
.date
+cardSpentTime
if cover
if currentBoard.allowsCoverAttachmentOnMinicard
.minicard-cover(style="background-image: url('{{cover.link 'original'}}?dummyReloadAfterSessionEstablished={{sess}}');")
// Upload progress indicator for drag-and-drop uploads
if hasActiveUploads
.minicard-upload-progress
.upload-progress-header
i.fa.fa-upload
span {{_ 'uploading-files'}} ({{uploadCount}})
each uploads
.upload-progress-item(class="{{#if $eq status 'error'}}upload-error{{/if}}")
.upload-progress-filename {{file.name}}
.upload-progress-bar
.upload-progress-fill(style="width: {{progress}}%")
if $eq status 'error'
.upload-progress-error
i.fa.fa-warning
span {{_ 'upload-failed'}}
else if $eq status 'completed'
.upload-progress-success
i.fa.fa-check
span {{_ 'upload-completed'}}
.minicard-title
if canModifyCard
a.minicard-complete-toggle.js-toggle-card-complete(title="{{#if getDueComplete}}{{_ 'card-mark-incomplete'}}{{else}}{{_ 'card-mark-complete'}}{{/if}}")
.materialCheckBox(class="{{#if getDueComplete}}is-checked{{/if}}")
if $eq 'prefix-with-full-path' currentBoard.presentParentTask
.parent-prefix
| {{ parentString ' > ' }}
@@ -65,30 +40,32 @@ template(name="minicard")
| {{ parentCardName }}
if isLinkedBoard
a.js-linked-link
span.linked-icon
i.fa.fa-folder
span.linked-icon.fa.fa-folder
else if isLinkedCard
a.js-linked-link
span.linked-icon
i.fa.fa-id-card
span.linked-icon.fa.fa-id-card
if getArchived
span.linked-icon.linked-archived
i.fa.fa-archive
span.linked-icon.linked-archived.fa.fa-archive
+viewer
if showCardNumber
if currentBoard.allowsCardNumber
span.card-number
| ##{getCardNumber} &nbsp;
| ##{getCardNumber}
= getTitle
if showLabels
if labels
.minicard-labels(class="{{#if hiddenMinicardLabelText}}minicard-labels-no-text{{/if}}")
each labels
unless hiddenMinicardLabelText
span.js-card-label.card-label(class="card-label-{{color}}" title=name)
+viewer
= name
if hiddenMinicardLabelText
.minicard-label(class="card-label-{{color}}" title="{{name}}")
if $eq 'subtext-with-full-path' currentBoard.presentParentTask
.parent-subtext
| {{ parentString ' > ' }}
if $eq 'subtext-with-parent' currentBoard.presentParentTask
.parent-subtext
| {{ parentCardName }}
if labels
.minicard-labels(class="{{#if hiddenMinicardLabelText}}minicard-labels-no-text{{/if}}")
each labels
unless hiddenMinicardLabelText
span.js-card-label.card-label(class="card-label-{{color}}" title=name)
+viewer
= name
if hiddenMinicardLabelText
.minicard-label(class="card-label-{{color}}" title="{{name}}")
.minicard-custom-fields
each customFieldsWD
@@ -151,106 +128,109 @@ template(name="minicard")
.minicard-creator
+userAvatar(userId=this.userId noRemove=true)
if shouldShowChecklistAtMinicard
each shouldShowChecklistAtMinicard
+minicardChecklist(checklist=. card=..)
.badges
if stickers.length
.badge.badge-state-image-only.minicard-stickers
each stickers
span.badge-icon.minicard-sticker(title="{{#if name}}{{name}}{{else}}{{icon}}{{/if}}")
i.fa(class="fa-{{icon}} {{#if highlight}}sticker-{{highlight}}{{/if}}")
if comments.length
.badge(title="{{_ 'card-comments-title' comments.length }}")
span.badge-icon.badge-comment.badge-text
i.fa.fa-comment-o
= ' '
= comments.length
if canModifyCard
if comments.length
.badge(title="{{_ 'card-comments-title' comments.length }}")
span.badge-icon.fa.fa-comment-o.badge-comment.badge-text
= ' '
= comments.length
//span.badge-comment.badge-text
//|
{{_ 'comment'}}
//| {{_ 'comment'}}
if getDescription
unless currentBoard.allowsDescriptionTextOnMinicard
.badge.badge-state-image-only(title=getDescription)
span.badge-icon
i.fa.fa-file-text-o
span.badge-icon.fa.fa-align-left
if getVoteQuestion
.badge.badge-state-image-only(title=getVoteQuestion)
span.badge-icon(class="{{#if voteState}}text-green{{/if}}")
i.fa.fa-thumbs-up
span.badge-icon.fa.fa-thumbs-up(class="{{#if voteState}}text-green{{/if}}")
span.badge-text {{ voteCountPositive }}
span.badge-icon(class="{{#if $eq voteState false}}text-red{{/if}}")
i.fa.fa-thumbs-down
span.badge-icon.fa.fa-thumbs-down(class="{{#if $eq voteState false}}text-red{{/if}}")
span.badge-text {{ voteCountNegative }}
if getPokerQuestion
.badge.badge-state-image-only(title=getPokerQuestion)
span.badge-icon(class="{{#if pokerState}}text-green{{/if}}")
i.fa.fa-check-square
span.badge-icon.fa.fa-check(class="{{#if pokerState}}text-green{{/if}}")
if expiredPoker
span.badge-text {{ getPokerEstimation }}
if attachments.length
if currentBoard.allowsBadgeAttachmentOnMinicard
.badge
span.badge-icon
i.fa.fa-paperclip
span.badge-icon.fa.fa-paperclip
span.badge-text= attachments.length
if showSubtasks
if allSubtasks.count
.badge
span.badge-icon
i.fa.fa-globe
span.badge-text.check-list-text {{subtasksFinishedCount}}/{{allSubtasksCount}}
//{{subtasksFinishedCount}}/{{subtasksCount}} does not work because when a subtaks is archived, the count goes down
if checklists.length
.badge(class="{{#if checklistFinished}}is-finished{{/if}}")
span.badge-icon.fa.fa-check-square-o
span.badge-text.check-list-text {{checklistFinishedCount}}/{{checklistItemCount}}
if allSubtasks.count
.badge
span.badge-icon.fa.fa-sitemap
span.badge-text.check-list-text {{subtasksFinishedCount}}/{{allSubtasksCount}}
//{{subtasksFinishedCount}}/{{subtasksCount}} does not work because when a subtaks is archived, the count goes down
if currentBoard.allowsCardSortingByNumber
if currentBoard.allowsCardSortingByNumberOnMinicard
.badge
span.badge-icon
i.fa.fa-sort-numeric-asc
span.badge-icon.fa.fa-sort
span.badge-text.check-list-sort {{ sort }}
if currentBoard.allowsDescriptionTextOnMinicard
if getDescription
.minicard-description
+viewer
| {{ getDescription }}
if shouldShowListOnMinicard
.minicard-list-name
i.fa.fa-list
| {{ listName }}
if $eq 'subtext-with-full-path' currentBoard.presentParentTask
.parent-subtext
| {{ parentString ' > ' }}
if $eq 'subtext-with-parent' currentBoard.presentParentTask
.parent-subtext
| {{ parentCardName }}
template(name="editCardSortOrderPopup")
input.js-edit-card-sort-popup(type='text' autofocus value=sort dir="auto")
.edit-controls.clearfix
button.primary.confirm.js-submit-edit-card-sort-popup(type="submit") {{_ 'save'}}
template(name="minicardChecklist")
.minicard-checklist
.checklist-header
if canModifyCard
+inlinedForm(classNames="js-edit-checklist-title" checklist=checklist)
+editChecklistForm(checklist=checklist)
else
h4.checklist-title.js-open-inlined-form.is-editable= checklist.title
else
h4.checklist-title= checklist.title
if canModifyCard
a.checklist-menu.js-open-checklist-menu(title="{{_ 'checklistActionsPopup-title'}}")
i.fa.fa-bars
.checklist-items.js-checklist-items
each visibleItems
+inlinedForm(classNames="js-edit-checklist-item" item=. checklist=checklist)
+editChecklistItemForm(type='item' item=. checklist=checklist)
else
+checklistItemDetail(item=. checklist=checklist card=card)
template(name="minicardDetailsActionsPopup")
ul.pop-over-list
if canModifyCard
+inlinedForm(autoclose=false classNames="js-add-checklist-item" checklist=checklist)
+addChecklistItemForm(checklist=checklist showNewlineBecomesNewChecklistItem=false)
else
a.add-checklist-item.js-open-inlined-form(title="{{_ 'add-checklist-item'}}")
i.fa.fa-plus
li
a.js-move-card
i.fa.fa-arrow-right
| {{_ 'moveCardPopup-title'}}
li
a.js-copy-card
i.fa.fa-copy
| {{_ 'copyCardPopup-title'}}
hr
li
a.js-archive
i.fa.fa-arrow-right
i.fa.fa-archive
| {{_ 'archive-card'}}
hr
li
a.js-move-card-to-top
i.fa.fa-arrow-up
| {{_ 'moveCardToTop-title'}}
li
a.js-move-card-to-bottom
i.fa.fa-arrow-down
| {{_ 'moveCardToBottom-title'}}
hr
li
a.js-add-labels
i.fa.fa-tags
| {{_ 'card-edit-labels'}}
li
a.js-due-date
i.fa.fa-sign-in
| {{_ 'editCardDueDatePopup-title'}}
li
a.js-set-card-color
i.fa.fa-paint-brush
| {{_ 'setCardColorPopup-title'}}
li
a.js-link
i.fa.fa-link
| {{_ 'link-card'}}
li
a.js-toggle-watch-card
if isWatching
i.fa.fa-eye
| {{_ 'unwatch'}}
else
i.fa.fa-eye-slash
| {{_ 'watch'}}
+122 -310
View File
@@ -1,29 +1,18 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { TAPi18n } from '/imports/i18n';
import { CustomFieldStringTemplate } from '/client/lib/customFields';
import { handleFileUpload } from './attachments';
import uploadProgressManager from '../../lib/uploadProgressManager';
import { Utils } from '/client/lib/utils';
import ChecklistItems from '/models/checklistItems';
function getMinicardFlag(board, onMinicardField, legacyField, defaultValue) {
if (!board) return false;
if (board[onMinicardField] !== null && board[onMinicardField] !== undefined) {
return board[onMinicardField];
}
if (legacyField && board[legacyField] !== null && board[legacyField] !== undefined) {
return board[legacyField];
}
return defaultValue;
}
import { CustomFieldStringTemplate } from '/client/lib/customFields'
// Template.cards.events({
// 'click .member': Popup.open('cardMember')
// });
Template.minicard.helpers({
BlazeComponent.extendComponent({
template() {
return 'minicard';
},
formattedCurrencyCustomFieldValue(definition) {
const customField = this
const customField = this.data()
.customFieldsWD()
.find(f => f._id === definition._id);
const customFieldTrueValue =
@@ -37,7 +26,7 @@ Template.minicard.helpers({
},
formattedStringtemplateCustomFieldValue(definition) {
const customField = this
const customField = this.data()
.customFieldsWD()
.find(f => f._id === definition._id);
@@ -50,7 +39,7 @@ Template.minicard.helpers({
showCreatorOnMinicard() {
// cache "board" to reduce the mini-mongodb access
const board = this.board();
const board = this.data().board();
let ret = false;
if (board) {
ret = board.allowsCreatorOnMinicard ?? false;
@@ -58,47 +47,72 @@ Template.minicard.helpers({
return ret;
},
isWatching() {
return this.findWatcher(Meteor.userId());
const card = this.currentData();
return card.findWatcher(Meteor.userId());
},
showMembers() {
const board = this.board();
return getMinicardFlag(board, 'allowsMembersOnMinicard', 'allowsMembers', true);
// cache "board" to reduce the mini-mongodb access
const board = this.data().board();
let ret = false;
if (board) {
ret =
board.allowsMembers === null ||
board.allowsMembers === undefined ||
board.allowsMembers
;
}
return ret;
},
showAssignee() {
const board = this.board();
return getMinicardFlag(board, 'allowsAssigneeOnMinicard', 'allowsAssignee', true);
},
showReceived() {
const board = this.board();
return getMinicardFlag(board, 'allowsReceivedDateOnMinicard', 'allowsReceivedDate', true);
},
showStart() {
const board = this.board();
return getMinicardFlag(board, 'allowsStartDateOnMinicard', 'allowsStartDate', true);
},
showDue() {
const board = this.board();
return getMinicardFlag(board, 'allowsDueDateOnMinicard', 'allowsDueDate', true);
},
showEnd() {
const board = this.board();
return getMinicardFlag(board, 'allowsEndDateOnMinicard', 'allowsEndDate', true);
},
showLabels() {
const board = this.board();
return getMinicardFlag(board, 'allowsLabelsOnMinicard', 'allowsLabels', true);
},
showCardNumber() {
const board = this.board();
return getMinicardFlag(board, 'allowsCardNumberOnMinicard', 'allowsCardNumber', false);
},
showSubtasks() {
const board = this.board();
return getMinicardFlag(board, 'allowsSubtasksOnMinicard', 'allowsSubtasks', true);
// cache "board" to reduce the mini-mongodb access
const board = this.data().board();
let ret = false;
if (board) {
ret =
board.allowsAssignee === null ||
board.allowsAssignee === undefined ||
board.allowsAssignee
;
}
return ret;
},
/** opens the card label popup only if clicked onto a label
* <li> this is necessary to have the data context of the minicard.
* if .js-card-label is used at click event, then only the data context of the label itself is available at this.currentData()
*/
cardLabelsPopup(event) {
if (this.find('.js-card-label:hover')) {
Popup.open("cardLabels")(event, {dataContextIfCurrentDataIsUndefined: this.currentData()});
}
},
events() {
return [
{
'click .js-linked-link'() {
if (this.data().isLinkedCard()) Utils.goCardId(this.data().linkedId);
else if (this.data().isLinkedBoard())
Utils.goBoardId(this.data().linkedId);
},
'click .js-toggle-minicard-label-text'() {
if (window.localStorage.getItem('hiddenMinicardLabelText')) {
window.localStorage.removeItem('hiddenMinicardLabelText'); //true
} else {
window.localStorage.setItem('hiddenMinicardLabelText', 'true'); //true
}
},
'click span.badge-icon.fa.fa-sort, click span.badge-text.check-list-sort' : Popup.open("editCardSortOrder"),
'click .minicard-labels' : this.cardLabelsPopup,
'click .js-open-minicard-details-menu': Popup.open('minicardDetailsActions'),
}
];
},
}).register('minicard');
Template.minicard.helpers({
hiddenMinicardLabelText() {
const currentUser = ReactiveCache.getCurrentUser();
if (currentUser) {
@@ -109,275 +123,73 @@ Template.minicard.helpers({
return false;
}
},
cover() {
if (!this.coverId) return null;
const attachment = ReactiveCache.getAttachment(this.coverId);
if (!attachment) return null;
const coverLink = typeof attachment.link === 'function' ? attachment.link() : '';
if (!coverLink) return null;
return {
link() {
return coverLink;
},
};
},
// XXX resolve this nasty hack for https://github.com/veliovgroup/Meteor-Files/issues/763
sess() {
return Meteor.connection && Meteor.connection._lastSessionId
? Meteor.connection._lastSessionId
: null;
},
// Upload progress helpers
hasActiveUploads() {
return uploadProgressManager.hasActiveUploads(this._id);
},
uploads() {
return uploadProgressManager.getUploadsForCard(this._id);
},
uploadCount() {
return uploadProgressManager.getUploadCountForCard(this._id);
},
listName() {
const list = this.list();
return list ? list.title : '';
},
shouldShowListOnMinicard() {
// Show list name if either:
// 1. Board-wide setting is enabled, OR
// 2. This specific card has the setting enabled
const currentBoard = this.board();
if (!currentBoard) return false;
return currentBoard.allowsShowListsOnMinicard || this.showListOnMinicard;
},
shouldShowChecklistAtMinicard() {
// Return checklists that should be shown on minicard
const currentBoard = this.board();
if (!currentBoard) return [];
const checklists = this.checklists();
const visibleChecklists = [];
checklists.forEach(checklist => {
// Show checklist if either:
// 1. Board-wide setting is enabled, OR
// 2. This specific checklist has the setting enabled
if (currentBoard.allowsChecklistAtMinicard || checklist.showChecklistAtMinicard) {
visibleChecklists.push(checklist);
}
});
return visibleChecklists;
isWatching() {
return this.findWatcher(Meteor.userId());
}
});
Template.minicard.events({
'click .js-linked-link'() {
if (this.isLinkedCard()) Utils.goCardId(this.linkedId);
else if (this.isLinkedBoard())
Utils.goBoardId(this.linkedId);
},
'click .js-toggle-card-complete'(event) {
// Trello-style "mark complete" toggle (left of the title). Don't let the
// click open the card.
BlazeComponent.extendComponent({
events() {
return [
{
'keydown input.js-edit-card-sort-popup'(evt) {
// enter = save
if (evt.keyCode === 13) {
this.find('button[type=submit]').click();
}
},
'click button.js-submit-edit-card-sort-popup'(event) {
// save button pressed
event.preventDefault();
const sort = this.$('.js-edit-card-sort-popup')[0]
.value
.trim();
if (!Number.isNaN(sort)) {
let card = this.data();
card.move(card.boardId, card.swimlaneId, card.listId, sort);
Popup.back();
}
},
}
]
}
}).register('editCardSortOrderPopup');
Template.minicardDetailsActionsPopup.events({
'click .js-due-date': Popup.open('editCardDueDate'),
'click .js-move-card': Popup.open('moveCard'),
'click .js-copy-card': Popup.open('copyCard'),
'click .js-set-card-color': Popup.open('setCardColor'),
'click .js-add-labels': Popup.open('cardLabels'),
'click .js-link': Popup.open('linkCard'),
'click .js-move-card-to-top'(event) {
event.preventDefault();
event.stopPropagation();
if (!Utils.canModifyCard()) {
return;
}
this.setDueComplete(!this.getDueComplete());
},
'click .js-toggle-minicard-label-text'() {
if (window.localStorage.getItem('hiddenMinicardLabelText')) {
window.localStorage.removeItem('hiddenMinicardLabelText'); //true
} else {
window.localStorage.setItem('hiddenMinicardLabelText', 'true'); //true
}
},
'click span.badge-icon.fa.fa-sort, click span.badge-text.check-list-sort' : Popup.open("editCardSortOrder"),
'click .minicard-labels'(event, tpl) {
if (tpl.find('.js-card-label:hover')) {
Popup.open("cardLabels")(event, {dataContextIfCurrentDataIsUndefined: Template.currentData()});
}
},
'click .js-open-minicard-details-menu'(event, tpl) {
event.preventDefault();
event.stopPropagation();
const card = Template.currentData();
Popup.open('cardDetailsActions').call({currentData: () => card}, event);
},
// Drag and drop file upload handlers
'dragover .minicard'(event) {
// Only prevent default for file drags to avoid interfering with sortable
const dataTransfer = event.originalEvent.dataTransfer;
if (dataTransfer && dataTransfer.types && dataTransfer.types.includes('Files')) {
event.preventDefault();
event.stopPropagation();
}
},
'dragenter .minicard'(event) {
const dataTransfer = event.originalEvent.dataTransfer;
if (dataTransfer && dataTransfer.types && dataTransfer.types.includes('Files')) {
event.preventDefault();
event.stopPropagation();
const card = this;
const board = card.board();
// Only allow drag-and-drop if user can modify card and board allows attachments
if (Utils.canModifyCard() && board && board.allowsAttachments) {
$(event.currentTarget).addClass('is-dragging-over');
}
}
},
'dragleave .minicard'(event) {
const dataTransfer = event.originalEvent.dataTransfer;
if (dataTransfer && dataTransfer.types && dataTransfer.types.includes('Files')) {
event.preventDefault();
event.stopPropagation();
$(event.currentTarget).removeClass('is-dragging-over');
}
},
'drop .minicard'(event) {
const dataTransfer = event.originalEvent.dataTransfer;
if (dataTransfer && dataTransfer.types && dataTransfer.types.includes('Files')) {
event.preventDefault();
event.stopPropagation();
$(event.currentTarget).removeClass('is-dragging-over');
const card = this;
const board = card.board();
// Check permissions
if (!Utils.canModifyCard() || !board || !board.allowsAttachments) {
return;
}
// Check if this is a file drop (not a card reorder)
if (!dataTransfer.files || dataTransfer.files.length === 0) {
return;
}
const files = dataTransfer.files;
if (files && files.length > 0) {
handleFileUpload(card, files);
}
}
},
});
Template.minicardChecklist.helpers({
visibleItems() {
const checklist = this.checklist || this;
const items = checklist.items();
return items.filter(item => {
// Hide finished items if hideCheckedChecklistItems is true
if (item.isFinished && checklist.hideCheckedChecklistItems) {
return false;
}
// Hide all items if hideAllChecklistItems is true
if (checklist.hideAllChecklistItems) {
return false;
}
return true;
});
},
});
Template.minicardChecklist.events({
'click .js-convert-checklist-item-to-card'(event) {
event.stopPropagation();
const formData = Blaze.getData(event.currentTarget);
if (!formData) return;
const context = { currentData: () => formData };
Popup.open('convertChecklistItemToCard').call(context, event);
},
'click .js-open-checklist-menu'(event) {
const data = Template.currentData();
const checklist = data.checklist || data;
const card = data.card || this;
const context = { currentData: () => ({ checklist, card }) };
Popup.open('checklistActions').call(context, event);
},
'click .js-checklist-item .check-box-container'(event) {
event.stopPropagation();
event.preventDefault();
const data = Blaze.getData(event.target) || Blaze.getData(event.currentTarget);
const item = data && data.item;
if (item && item._id) {
item.toggleItem();
}
},
'click .js-submit-edit-checklist-item-form'(event) {
event.preventDefault();
event.stopPropagation();
const $btn = $(event.currentTarget);
const $form = $btn.closest('form');
const textarea = $form.find('textarea.js-edit-checklist-item')[0];
if (!textarea) return;
const title = textarea.value.trim();
if (!title) return;
const formData = Blaze.getData($form[0]);
if (formData && formData.item && formData.item._id) {
formData.item.setTitle(title);
} else if (formData && formData.checklist && formData.checklist._id) {
formData.checklist.setTitle(title);
}
$form.find('.js-close-inlined-form').trigger('click');
},
'submit .js-add-checklist-item'(event, tpl) {
event.preventDefault();
const textarea = tpl.find('textarea.js-add-checklist-item');
if (!textarea) return;
const title = textarea.value.trim();
const data = Template.currentData() || {};
const checklist = data.checklist;
if (!checklist || !title) return;
const items = checklist.items();
const lastItem = items[items.length - 1] || null;
const sortIndex = Utils.calculateIndexData(lastItem, null).base;
ChecklistItems.insert({
title,
checklistId: checklist._id,
cardId: checklist.cardId,
sort: sortIndex,
});
textarea.value = '';
textarea.focus();
},
'click .js-delete-checklist-item': Popup.afterConfirm('checklistItemDelete', function () {
const minOrder = this.getMinSort();
this.move(this.boardId, this.swimlaneId, this.listId, minOrder - 1);
Popup.back();
const item = this && this.item ? this.item : this;
if (item && item._id) {
ChecklistItems.remove(item._id);
}
}),
'keydown textarea.js-add-checklist-item'(event) {
if (event.keyCode === 13 && !event.shiftKey) {
event.preventDefault();
const $form = $(event.currentTarget).closest('form');
$form.find('button[type=submit]').click();
}
},
});
Template.editCardSortOrderPopup.events({
'keydown input.js-edit-card-sort-popup'(evt, tpl) {
// enter = save
if (evt.keyCode === 13) {
tpl.find('button[type=submit]').click();
}
},
'click button.js-submit-edit-card-sort-popup'(event, tpl) {
// save button pressed
'click .js-move-card-to-bottom'(event) {
event.preventDefault();
const sort = tpl.$('.js-edit-card-sort-popup')[0]
.value
.trim();
if (!Number.isNaN(sort)) {
let card = this;
card.move(card.boardId, card.swimlaneId, card.listId, sort);
Popup.back();
}
const maxOrder = this.getMaxSort();
this.move(this.boardId, this.swimlaneId, this.listId, maxOrder + 1);
Popup.back();
},
'click .js-archive': Popup.afterConfirm('cardArchive', function () {
Popup.close();
this.archive();
Utils.goBoardId(this.boardId);
}),
'click .js-toggle-watch-card'() {
const currentCard = this;
const level = currentCard.findWatcher(Meteor.userId()) ? null : 'watching';
Meteor.call('watch', 'card', currentCard._id, level, (err, ret) => {
if (!err && ret) Popup.back();
});
},
});
@@ -1,3 +0,0 @@
.minicard-title .minicard-complete-toggle {
display: none;
}
+3 -3
View File
@@ -11,7 +11,7 @@ template(name="resultCard")
= getBoard.title
else
.broken-cards-null
| {{_ 'no-name'}}
| NULL
if getBoard.archived
i.fa.fa-archive
li.result-card-context.result-card-context-separator
@@ -25,7 +25,7 @@ template(name="resultCard")
= getSwimlane.title
else
.broken-cards-null
| {{_ 'no-name'}}
| NULL
if getSwimlane.archived
i.fa.fa-archive
li.result-card-context.result-card-context-separator
@@ -39,6 +39,6 @@ template(name="resultCard")
= getList.title
else
.broken-cards-null
| {{_ 'no-name'}}
| NULL
if getList.archived
i.fa.fa-archive
+22 -54
View File
@@ -1,67 +1,35 @@
import { ReactiveCache } from '/imports/reactiveCache';
Template.resultCard.helpers({
userId() {
return Meteor.userId();
},
getBoard() {
const card = Template.currentData();
try {
if (card && card.boardId) {
const board = ReactiveCache.getBoard(card.boardId);
return board || null;
}
} catch (err) {
// Silently handle errors
}
return null;
},
getSwimlane() {
const card = Template.currentData();
try {
if (card && card.swimlaneId) {
const swimlane = ReactiveCache.getSwimlane(card.swimlaneId);
return swimlane || null;
}
} catch (err) {
// Silently handle errors
}
return null;
},
getList() {
const card = Template.currentData();
try {
if (card && card.listId) {
const list = ReactiveCache.getList(card.listId);
return list || null;
}
} catch (err) {
// Silently handle errors
}
return null;
},
originRelativeUrl() {
const card = Template.currentData();
if (card && card.boardId && card._id) {
return `/board/${card.boardId}?cardId=${card._id}`;
}
return '#';
},
});
Template.resultCard.events({
'click .js-minicard'(event) {
event.preventDefault();
const cardId = Template.currentData()._id;
const boardId = Template.currentData().boardId;
BlazeComponent.extendComponent({
clickOnMiniCard(evt) {
evt.preventDefault();
const this_ = this;
const cardId = this.currentData()._id;
const boardId = this.currentData().boardId;
Meteor.subscribe('popupCardData', cardId, {
onReady() {
Session.set('popupCardId', cardId);
Session.set('popupCardBoardId', boardId);
if (!Popup.isOpen()) {
Popup.open("cardDetails")(event);
}
this_.cardDetailsPopup(evt);
},
});
},
});
cardDetailsPopup(event) {
if (!Popup.isOpen()) {
Popup.open("cardDetails")(event);
}
},
events() {
return [
{
'click .js-minicard': this.clickOnMiniCard,
},
];
},
}).register('resultCard');
+4 -46
View File
@@ -15,7 +15,7 @@ textarea.js-edit-subtask-item {
color: #8c8c8c;
text-decoration: underline;
word-wrap: break-word;
flex-shrink: 0;
float: right;
padding-top: 6px;
}
.delete-text:hover,
@@ -24,13 +24,8 @@ textarea.js-edit-subtask-item {
.js-delete-subtask-item:hover {
color: inherit;
}
.subtask-title {
display: flex;
align-items: center;
width: 100%;
}
.subtask-title .checkbox {
flex-shrink: 0;
float: left;
width: 30px;
height: 30px;
font-size: 18px;
@@ -39,12 +34,10 @@ textarea.js-edit-subtask-item {
.subtask-title .title {
font-size: 18px;
line-height: 25px;
flex: 1;
order: 1;
}
.subtask-title .subtasks-stat {
margin: 0 0.5em;
flex-shrink: 0;
float: right;
padding-top: 6px;
}
.subtask-title .subtasks-stat.is-finished {
@@ -53,9 +46,6 @@ textarea.js-edit-subtask-item {
.subtask-title .js-delete-subtask {
margin: 0 0.5em;
}
.subtask-title .js-view-subtask {
margin: 0 0.5em;
}
.js-confirm-subtask-delete {
background-color: #f7f7f7;
position: absolute;
@@ -97,15 +87,6 @@ textarea.js-edit-subtask-item {
top: 0;
bottom: -600px;
right: 0;
z-index: 15;
}
/* Fix for mobile Safari: ensure this doesn't block card interaction */
@media screen and (max-width: 800px) {
#card-details-overlay {
z-index: 15;
pointer-events: none;
}
}
.subtasks {
background: #f7f7f7;
@@ -139,9 +120,6 @@ textarea.js-edit-subtask-item {
.subtasks-item:hover {
background-color: #ebebeb;
}
.subtasks-item .check-box-container {
padding-right: 10px;
}
.subtasks-item .check-box {
margin: 0.1em 0 0 0;
}
@@ -149,25 +127,6 @@ textarea.js-edit-subtask-item {
border-bottom: 2px solid #3cb500;
border-right: 2px solid #3cb500;
}
/* Unicode checkbox icons styling */
.subtasks-item .check-box-unicode {
font-size: 1.3em;
margin-right: 8px;
cursor: pointer;
display: inline-block;
vertical-align: middle;
line-height: 1;
}
/* Grey checkmarks when grey icons setting is enabled */
body.grey-icons-enabled .subtasks-item .check-box.is-checked {
border-bottom: 2px solid #7a7a7a;
border-right: 2px solid #7a7a7a;
}
body.grey-icons-enabled .subtasks-item .check-box-unicode {
filter: grayscale(100%);
-webkit-filter: grayscale(100%);
opacity: 0.85;
}
.subtasks-item .item-title {
flex: 1;
padding-left: 10px;
@@ -188,7 +147,6 @@ body.grey-icons-enabled .subtasks-item .check-box-unicode {
display: inline-block;
}
.subtask-details-menu {
flex-shrink: 0;
float: right;
padding: 6px 10px 6px 10px;
order: 2;
}
+6 -11
View File
@@ -1,6 +1,6 @@
template(name="subtasks")
h3.card-details-item-title
i.fa.fa-globe
i.fa.fa-sitemap
| {{_ 'subtasks'}}
if currentUser.isBoardAdmin
if toggleDeleteDialog.get
@@ -40,7 +40,7 @@ template(name="addSubtaskItemForm")
textarea.js-add-subtask-item(rows='1' autofocus dir="auto")
.edit-controls.clearfix
button.primary.confirm.js-submit-add-subtask-item-form(type="submit") {{_ 'save'}}
a.fa.fa-times-thin.js-close-inlined-form(title="{{_ 'close-add-checklist-item'}}")
a.fa.fa-times-thin.js-close-inlined-form
template(name="editSubtaskItemForm")
textarea.js-edit-subtask-item(rows='1' autofocus dir="auto")
@@ -50,8 +50,8 @@ template(name="editSubtaskItemForm")
= subtask.title
.edit-controls.clearfix
button.primary.confirm.js-submit-edit-subtask-item-form(type="submit") {{_ 'save'}}
a.fa.fa-times-thin.js-close-inlined-form(title="{{_ 'close-edit-checklist-item'}}")
span(title=createdAt) {{ displayDate createdAt }}
a.fa.fa-times-thin.js-close-inlined-form
span(title=createdAt) {{ moment createdAt }}
if canModifyCard
if currentUser.isBoardAdmin
a.js-delete-subtask-item {{_ "delete"}}...
@@ -74,13 +74,12 @@ template(name="subtasksItems")
template(name='subtaskItemDetail')
.js-subtasks-item.subtasks-item
if canModifyCard
.check-box-container
.check-box.materialCheckBox(class="{{#if item.isFinished}}is-checked{{/if}}")
.check-box.materialCheckBox(class="{{#if item.isFinished }}is-checked{{/if}}")
.item-title.js-open-inlined-form.is-editable(class="{{#if item.isFinished }}is-checked{{/if}}")
+viewer
= item.title
else
.materialCheckBox(class="{{#if item.isFinished}}is-checked{{/if}}")
.materialCheckBox(class="{{#if item.isFinished }}is-checked{{/if}}")
.item-title(class="{{#if item.isFinished }}is-checked{{/if}}")
+viewer
= item.title
@@ -95,10 +94,6 @@ template(name="subtaskActionsPopup")
a.js-view-subtask(title="{{ subtask.title }}")
i.fa.fa-eye
| {{_ "view-it"}}
li
a.js-go-to-subtask-board
i.fa.fa-folder
| {{_ "go-to-board"}}
if currentUser.isBoardAdmin
a.js-delete-subtask.delete-subtask
i.fa.fa-trash
+76 -101
View File
@@ -1,35 +1,16 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
import Cards from '/models/cards';
import { Filter } from '/client/lib/filter';
Template.subtasks.events({
'click .js-open-subtask-details-menu'(event) {
// Close any existing popup first to avoid accumulating content
if (Popup.isOpen()) {
Popup.close();
}
// Now open the popup for this specific subtask
Popup.open('subtaskActions').call(this, event);
},
async 'submit .js-add-subtask'(event, tpl) {
BlazeComponent.extendComponent({
addSubtask(event) {
event.preventDefault();
const textarea = tpl.find('textarea.js-add-subtask-item');
const textarea = this.find('textarea.js-add-subtask-item');
const title = textarea.value.trim();
const cardId = Template.currentData().cardId;
const cardId = this.currentData().cardId;
const card = ReactiveCache.getCard(cardId);
const sortIndex = -1;
const crtBoard = ReactiveCache.getBoard(card.boardId);
const targetBoard = await crtBoard.getDefaultSubtasksBoardAsync();
if (!targetBoard) {
return;
}
const targetList = await targetBoard.getDefaultSubtasksListAsync();
if (!targetList) {
return;
}
const listId = targetList._id;
const targetBoard = crtBoard.getDefaultSubtasksBoard();
const listId = targetBoard.getDefaultSubtasksListId();
//Get the full swimlane data for the parent task.
const parentSwimlane = ReactiveCache.getSwimlane({
@@ -37,22 +18,20 @@ Template.subtasks.events({
_id: card.swimlaneId,
});
//find the swimlane of the same name in the target board.
const targetSwimlane = parentSwimlane
? ReactiveCache.getSwimlane({
boardId: targetBoard._id,
title: parentSwimlane.title,
})
: undefined;
const targetSwimlane = ReactiveCache.getSwimlane({
boardId: targetBoard._id,
title: parentSwimlane.title,
});
//If no swimlane with a matching title exists in the target board, fall back to the default swimlane.
const swimlaneId =
targetSwimlane === undefined
? targetBoard.getDefaultSwimline()._id
: targetSwimlane._id;
const nextCardNumber = await targetBoard.getNextCardNumber();
const nextCardNumber = targetBoard.getNextCardNumber();
if (title) {
const _id = await Cards.insertAsync({
const _id = Cards.insert({
title,
parentId: cardId,
members: [],
@@ -63,7 +42,7 @@ Template.subtasks.events({
sort: sortIndex,
swimlaneId,
type: 'cardType-card',
cardNumber: nextCardNumber,
cardNumber: nextCardNumber
});
// In case the filter is active we need to add the newly inserted card in
@@ -73,7 +52,7 @@ Template.subtasks.events({
Filter.addException(_id);
setTimeout(() => {
tpl.$('.add-subtask-item')
this.$('.add-subtask-item')
.last()
.click();
}, 100);
@@ -81,20 +60,27 @@ Template.subtasks.events({
textarea.value = '';
textarea.focus();
},
'submit .js-edit-subtask-title'(event, tpl) {
event.preventDefault();
const textarea = tpl.find('textarea.js-edit-subtask-item');
const title = textarea.value.trim();
const subtask = Template.currentData().subtask;
subtask.setTitle(title);
},
async 'click .js-delete-subtask-item'() {
const subtask = Template.currentData().subtask;
deleteSubtask() {
const subtask = this.currentData().subtask;
if (subtask && subtask._id) {
await subtask.archive();
subtask.archive();
}
},
keydown(event) {
isBoardAdmin() {
return ReactiveCache.getCurrentUser().isBoardAdmin();
},
editSubtask(event) {
event.preventDefault();
const textarea = this.find('textarea.js-edit-subtask-item');
const title = textarea.value.trim();
const subtask = this.currentData().subtask;
subtask.setTitle(title);
},
pressKey(event) {
//If user press enter key inside a form, submit it
//Unless the user is also holding down the 'shift' key
if (event.keyCode === 13 && !event.shiftKey) {
@@ -103,66 +89,53 @@ Template.subtasks.events({
$form.find('button[type=submit]').click();
}
},
});
Template.subtasks.onCreated(function () {
this.toggleDeleteDialog = new ReactiveVar(false);
});
events() {
return [
{
'click .js-open-subtask-details-menu': Popup.open('subtaskActions'),
'submit .js-add-subtask': this.addSubtask,
'submit .js-edit-subtask-title': this.editSubtask,
'click .js-delete-subtask-item': this.deleteSubtask,
keydown: this.pressKey,
},
];
},
}).register('subtasks');
Template.subtasks.helpers({
BlazeComponent.extendComponent({
// ...
}).register('subtaskItemDetail');
BlazeComponent.extendComponent({
isBoardAdmin() {
return ReactiveCache.getCurrentUser().isBoardAdmin();
},
toggleDeleteDialog() {
return Template.instance().toggleDeleteDialog;
},
});
Template.subtaskItemDetail.events({
async 'click .js-subtasks-item .check-box-container'() {
const item = Template.currentData().item;
if (item && item._id) {
await item.toggleItem();
}
},
});
Template.subtaskActionsPopup.helpers({
isBoardAdmin() {
return ReactiveCache.getCurrentUser().isBoardAdmin();
},
});
Template.subtaskActionsPopup.events({
'click .js-view-subtask'(event) {
if ($(event.target).hasClass('js-view-subtask')) {
const subtask = Template.currentData().subtask;
const board = subtask.board();
FlowRouter.go('card', {
boardId: board._id,
slug: board.slug,
cardId: subtask._id,
swimlaneId: subtask.swimlaneId,
listId: subtask.listId,
});
}
},
'click .js-go-to-subtask-board'() {
const subtask = Template.currentData().subtask;
const board = subtask.board();
if (board) {
Popup.close();
FlowRouter.go('board', { id: board._id, slug: board.slug });
}
},
'click .js-delete-subtask' : Popup.afterConfirm('subtaskDelete', async function () {
Popup.back(2);
const subtask = this.subtask;
if (subtask && subtask._id) {
await subtask.archive();
}
}),
});
events() {
return [
{
'click .js-view-subtask'(event) {
if ($(event.target).hasClass('js-view-subtask')) {
const subtask = this.currentData().subtask;
const board = subtask.board();
FlowRouter.go('card', {
boardId: board._id,
slug: board.slug,
cardId: subtask._id,
});
}
},
'click .js-delete-subtask' : Popup.afterConfirm('subtaskDelete', function () {
Popup.back(2);
const subtask = this.subtask;
if (subtask && subtask._id) {
subtask.archive();
}
}),
}
]
}
}).register('subtaskActionsPopup');
Template.editSubtaskItemForm.helpers({
user() {
@@ -172,3 +145,5 @@ Template.editSubtaskItemForm.helpers({
return ReactiveCache.getCurrentUser().isBoardAdmin();
},
});
@@ -1,123 +0,0 @@
/* Original Position Component Styles */
.original-position-info {
margin: 5px 0;
padding: 8px;
border-radius: 4px;
font-size: 12px;
line-height: 1.4;
}
.original-position-loading {
color: #666;
font-style: italic;
}
.original-position-loading i {
margin-right: 5px;
}
.original-position-details {
background-color: #f8f9fa;
border: 1px solid #e9ecef;
border-radius: 3px;
padding: 6px 8px;
}
.original-position-moved {
color: #856404;
background-color: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: 3px;
padding: 4px 6px;
margin-bottom: 4px;
}
.original-position-moved i {
color: #f39c12;
margin-right: 5px;
}
.original-position-unchanged {
color: #155724;
background-color: #d4edda;
border: 1px solid #c3e6cb;
border-radius: 3px;
padding: 4px 6px;
margin-bottom: 4px;
}
.original-position-unchanged i {
color: #28a745;
margin-right: 5px;
}
.original-position-text {
font-weight: 500;
}
.original-title {
color: #6c757d;
font-size: 11px;
margin-top: 4px;
padding-top: 4px;
border-top: 1px solid #e9ecef;
}
.original-title strong {
color: #495057;
}
/* Integration with existing Wekan styles */
.swimlane .original-position-info,
.list .original-position-info,
.card .original-position-info {
margin: 2px 0;
padding: 4px 6px;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.original-position-info {
font-size: 11px;
padding: 6px;
}
.original-position-details {
padding: 4px 6px;
}
.original-position-moved,
.original-position-unchanged {
padding: 3px 5px;
}
}
/* Dark theme support */
@media (prefers-color-scheme: dark) {
.original-position-details {
background-color: #2d3748;
border-color: #4a5568;
color: #e2e8f0;
}
.original-position-moved {
background-color: #744210;
border-color: #b7791f;
color: #fbd38d;
}
.original-position-unchanged {
background-color: #22543d;
border-color: #38a169;
color: #9ae6b4;
}
.original-title {
color: #a0aec0;
border-color: #4a5568;
}
.original-title strong {
color: #e2e8f0;
}
}
@@ -1,19 +0,0 @@
template(name="originalPosition")
.original-position-info
if isLoading
.original-position-loading
| ⏳ Loading original position...
else
if showOriginalPosition
.original-position-details
if hasMovedFromOriginal
.original-position-moved
span.original-position-text ℹ️ {{getOriginalPositionDescription}}
else
.original-position-unchanged
span.original-position-text ✅ In original position
if getOriginalTitle
.original-title
strong Original title:
| {{getOriginalTitle}}
@@ -1,94 +0,0 @@
import { ReactiveVar } from 'meteor/reactive-var';
import { Meteor } from 'meteor/meteor';
/**
* Component to display original position information for swimlanes, lists, and cards
*/
Template.originalPosition.onCreated(function () {
this.originalPosition = new ReactiveVar(null);
this.isLoading = new ReactiveVar(false);
this.hasMoved = new ReactiveVar(false);
const tpl = this;
function loadOriginalPosition(entityId, entityType) {
tpl.isLoading.set(true);
const methodName = `positionHistory.get${entityType.charAt(0).toUpperCase() + entityType.slice(1)}OriginalPosition`;
Meteor.call(methodName, entityId, (error, result) => {
tpl.isLoading.set(false);
if (error) {
console.error('Error loading original position:', error);
tpl.originalPosition.set(null);
} else {
tpl.originalPosition.set(result);
// Check if the entity has moved
const movedMethodName = `positionHistory.has${entityType.charAt(0).toUpperCase() + entityType.slice(1)}Moved`;
Meteor.call(movedMethodName, entityId, (movedError, movedResult) => {
if (!movedError) {
tpl.hasMoved.set(movedResult);
}
});
}
});
}
this.autorun(() => {
const data = Template.currentData();
if (data && data.entityId && data.entityType) {
loadOriginalPosition(data.entityId, data.entityType);
}
});
});
Template.originalPosition.helpers({
getOriginalPosition() {
return Template.instance().originalPosition.get();
},
isLoading() {
return Template.instance().isLoading.get();
},
hasMovedFromOriginal() {
return Template.instance().hasMoved.get();
},
getOriginalPositionDescription() {
const position = Template.instance().originalPosition.get();
if (!position) return 'No original position data';
if (position.originalPosition) {
const data = Template.currentData();
const entityType = data.entityType;
let description = `Original position: ${position.originalPosition.sort || 0}`;
if (entityType === 'list' && position.originalSwimlaneId) {
description += ` in swimlane ${position.originalSwimlaneId}`;
} else if (entityType === 'card') {
if (position.originalSwimlaneId) {
description += ` in swimlane ${position.originalSwimlaneId}`;
}
if (position.originalListId) {
description += ` in list ${position.originalListId}`;
}
}
return description;
}
return 'No original position data';
},
getOriginalTitle() {
const position = Template.instance().originalPosition.get();
return position ? position.originalTitle : '';
},
showOriginalPosition() {
return Template.instance().originalPosition.get() !== null;
},
});
+3 -2
View File
@@ -4,10 +4,11 @@ template(name="datepicker")
.fields
.left
label(for="date") {{_ 'date'}}
input.js-date-field#date(type="date" name="date" value=showDate autofocus)
input.js-date-field#date(type="text" name="date" value=showDate placeholder=dateFormat autofocus)
.right
label(for="time") {{_ 'time'}}
input.js-time-field#time(type="time" name="time" value=showTime)
input.js-time-field#time(type="text" name="time" value=showTime placeholder=timeFormat)
.js-datepicker
if error.get
.warning {{_ error.get}}
button.primary.wide.left.js-submit-date(type="submit") {{_ 'save'}}
-16
View File
@@ -1,16 +0,0 @@
import {
setupDatePicker,
datePickerRendered,
datePickerHelpers,
datePickerEvents,
} from '/client/lib/datepicker';
Template.datepicker.onCreated(function () {
setupDatePicker(this);
});
Template.datepicker.onRendered(function () {
datePickerRendered(this);
});
Template.datepicker.helpers(datePickerHelpers());
+52 -70
View File
@@ -5,11 +5,11 @@ button {
box-sizing: border-box;
background-color: #ebebeb;
border: 1px solid #ccc;
border-radius: 0.4vw;
border-radius: 3px;
display: block;
margin-bottom: 1.5vh;
min-height: 4.5vh;
padding: 1vh 1vw;
margin-bottom: 12px;
min-height: 34px;
padding: 7px;
}
select.full,
textarea.full,
@@ -31,18 +31,17 @@ button:focus {
outline: 0;
}
input[type="file"] {
margin-bottom: 2vh;
margin-bottom: 16px;
}
input[type="radio"] {
-webkit-appearance: radio;
appearance: radio;
min-height: inherit;
}
input[type="text"],
input[type="password"],
input[type="email"] {
transition: background 85ms ease-in, border-color 85ms ease-in;
width: min(250px, 30vw);
width: 250px;
}
input[type="text"].inline-input,
input[type="password"].inline-input,
@@ -50,10 +49,10 @@ input[type="email"].inline-input {
background: none;
border: 0;
margin: 0;
padding: 0.3vh;
padding: 2px;
min-height: 0;
height: 2.5vh;
width: min(200px, 25vw);
height: 18px;
width: 200px;
}
input[type="text"].full-line,
input[type="password"].full-line,
@@ -97,31 +96,31 @@ input[type="email"]:disabled,
textarea:disabled {
background-color: #dcdcdc;
border-color: #bfbfbf;
color: #222;
color: #8c8c8c;
-webkit-touch-callout: none;
-webkit-user-select: none;
user-select: none;
}
select {
max-height: 40vh;
width: min(256px, 32vw);
margin-bottom: 1vh;
max-height: 300px;
width: 256px;
margin-bottom: 8px;
}
select.inline {
width: 100%;
}
option[disabled] {
color: #222;
color: #8c8c8c;
}
textarea {
height: 20vh;
height: 150px;
transition: background 85ms ease-in, border-color 85ms ease-in;
resize: vertical;
width: 100%;
}
textarea.editor {
resize: none;
padding-bottom: 3vh;
padding-bottom: 22px;
}
.button {
border-radius: 3px;
@@ -130,36 +129,33 @@ textarea.editor {
}
input[type="submit"],
button {
background: #000;
background: linear-gradient(#000, #000);
background: #cfcfcf;
background: linear-gradient(#cfcfcf, #c2c2c2);
border: none;
cursor: pointer;
display: inline-block;
font-weight: 700;
line-height: 1.3;
padding: 1vh 2.5vw;
line-height: 22px;
padding: 7px 20px;
text-align: center;
color: #fff;
}
input[type="submit"] .wide,
button .wide {
padding-left: 4vw;
padding-right: 4vw;
padding-left: 30px;
padding-right: 30px;
}
input[type="submit"]:hover,
button:hover,
input[type="submit"]:focus,
button:focus {
background: #222;
background: linear-gradient(#222, #222);
color: #fff;
background: #c2c2c2;
background: linear-gradient(#c2c2c2, #b5b5b5);
}
input[type="submit"]:active,
button:active {
background: #111;
background: linear-gradient(#111, #111);
box-shadow: inset 0 3px 6px rgba(0,0,0,0.3);
color: #fff;
background: #b5b5b5;
background: linear-gradient(#b5b5b5, #a8a8a8);
box-shadow: inset 0 3px 6px rgba(0,0,0,0.1);
}
input[type="submit"]:active:hover,
button:active:hover,
@@ -186,12 +182,6 @@ input[type="submit"].primary:active,
button.primary:active {
background: #01628c;
}
input[type="submit"].negate,
button.negate {
background: #eb5a46;
box-shadow: 0 1px 0 #4d4d4d;
color: #fff;
}
input[type="submit"].negate:hover,
button.negate:hover,
input[type="submit"].negate:focus,
@@ -226,15 +216,15 @@ input[type="submit"]:disabled:active,
input[type="button"].disabled:active,
button.disabled:active,
.button.disabled:active {
background: #555;
background: #cfcfcf;
cursor: default;
box-shadow: none;
color: #999;
color: #a8a8a8;
}
fieldset {
border: 1px solid #bfbfbf;
padding: 2vh 2vw;
margin-bottom: 2vh;
padding: 15px;
margin-bottom: 15px;
}
input[type="hidden"] {
display: none;
@@ -242,18 +232,18 @@ input[type="hidden"] {
.radio-div,
.check-div {
display: block;
margin: 0 0 0.5vh 2.5vw;
min-height: 2.5vh;
margin: 0 0 4px 20px;
min-height: 20px;
position: relative;
}
.radio-div input,
.check-div input {
left: -2.5vw;
left: -18px;
min-height: 0;
margin: 0;
padding: 0;
position: absolute;
top: 0.3vh;
top: 2px;
}
.radio-div label,
.check-div label {
@@ -262,23 +252,23 @@ input[type="hidden"] {
label {
display: block;
font-weight: 700;
margin-bottom: 0.5vh;
margin-bottom: 4px;
}
label.form-error {
color: #d32f2f;
color: #ba1212;
}
input::-webkit-input-placeholder,
textarea::-webkit-input-placeholder,
input::-moz-placeholder,
textarea::-moz-placeholder {
color: #333 !important;
color: #8c8c8c;
}
.edit-controls,
.add-controls {
display: flex;
align-items: center;
margin-top: 0px;
margin-bottom: 1.5vh;
margin-bottom: 12px;
}
@media print {
.add-controls {
@@ -290,13 +280,13 @@ textarea::-moz-placeholder {
.edit-controls input[type=button],
.add-controls input[type=button] {
float: left;
height: 4.5vh;
height: 32px;
margin-bottom: 0px;
}
.edit-controls .fa-times-thin,
.add-controls .fa-times-thin {
font-size: clamp(20px, 4vw, 26px);
margin: 0.5vh 1.5vw;
font-size: 26px;
margin: 3px 10px;
}
[type="checkbox"]:not(:checked),
[type="checkbox"]:checked {
@@ -324,18 +314,10 @@ textarea::-moz-placeholder {
margin-right: 6px;
border-top: 2px solid transparent;
border-left: 2px solid transparent;
border-bottom: 2px solid #3cb500;
border-right: 2px solid #3cb500;
transform: rotate(40deg);
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
transform-origin: 100% 100%;
}
/* Grey checkmarks when grey icons setting is enabled */
body.grey-icons-enabled .materialCheckBox.is-checked {
border-bottom: 2px solid #7a7a7a;
border-right: 2px solid #7a7a7a;
}
.button-link {
background: #fff;
background: linear-gradient(#fff, #f5f5f5);
@@ -394,14 +376,14 @@ body.grey-icons-enabled .materialCheckBox.is-checked {
.button-link.setting.disabled {
background: #fff;
border-color: #e9e9e9;
color: #222;
color: #8c8c8c;
cursor: default;
}
.button-link.setting.disabled select {
display: none;
}
.button-link.setting.disabled:hover .label {
color: #222;
color: #8c8c8c;
}
.button-link.setting.disabled,
.button-link.setting.disabled:hover,
@@ -409,15 +391,15 @@ body.grey-icons-enabled .materialCheckBox.is-checked {
.button-link.setting.disabled.primary,
.button-link.setting.disabled.primary:hover,
.button-link.setting.disabled.primary:active {
background: #555;
border-color: #444;
border-bottom-color: #333;
background: #cfcfcf;
border-color: #c2c2c2;
border-bottom-color: #b5b5b5;
cursor: default;
box-shadow: none;
color: #999;
color: #a8a8a8;
}
.button-link.setting .label {
color: #222;
color: #8c8c8c;
display: block;
font-size: 12px;
line-height: 14px;
@@ -527,7 +509,7 @@ button.loud-text-button:hover {
border-radius: 3px;
-webkit-user-select: none;
user-select: none;
color: #222;
color: #8c8c8c;
display: block;
margin: 2px 0;
padding: 6px 8px;
@@ -592,7 +574,7 @@ button.loud-text-button:hover {
top: 6px;
}
.big-link.none {
color: #222;
color: #8c8c8c;
text-decoration: none;
}
.big-link.none:hover {
@@ -622,7 +604,7 @@ button.loud-text-button:hover {
}
.show-more {
border-radius: 3px;
color: #222;
color: #8c8c8c;
display: block;
padding: 16px 8px 16px 40px;
margin: 8px 0;
+5 -6
View File
@@ -1,7 +1,6 @@
template(name='inlinedForm')
.js-inlined-form-wrapper(style='display: contents;' data-position=position)
if isOpen.get
form.inlined-form.js-inlined-form(id=id class=classNames)
+Template.contentBlock
else
+Template.elseBlock
if isOpen.get
form.inlined-form.js-inlined-form(id=id class=classNames)
+Template.contentBlock
else
+Template.elseBlock
-203
View File
@@ -1,203 +0,0 @@
/* Gantt chart cell background colors for Received, Start, Due, End (matching cardDetails) */
.ganttview-received {
background-color: #dbdbdb !important;
color: #000 !important;
font-size: 18px !important;
font-weight: bold !important;
}
.ganttview-start {
background-color: #90ee90 !important;
color: #000 !important;
font-size: 18px !important;
font-weight: bold !important;
}
.ganttview-due {
background-color: #ffd700 !important;
color: #000 !important;
font-size: 18px !important;
font-weight: bold !important;
}
.ganttview-end {
background-color: #ffb3b3 !important;
color: #000 !important;
font-size: 18px !important;
font-weight: bold !important;
}
/* Gantt View Styles */
.gantt-view {
width: 100%;
height: auto;
overflow: visible;
background-color: #fff;
}
.gantt-view.swimlane {
background-color: #fff;
padding: 10px;
}
.gantt-container {
overflow-x: auto;
overflow-y: visible;
background-color: #fff;
display: block;
width: 100%;
}
.gantt-container table,
.gantt-table {
border-collapse: collapse;
width: 100%;
min-width: 800px;
border: 2px solid #666;
font-family: sans-serif;
font-size: 13px;
background-color: #fff;
}
.gantt-container thead {
background-color: #e8e8e8;
border-bottom: 2px solid #666;
font-weight: bold;
position: sticky;
top: 0;
z-index: 10;
}
.gantt-container thead th,
.gantt-container thead tr > td:first-child {
border-right: 2px solid #666;
padding: 4px; /* half of 8px */
width: 100px; /* half of 200px */
text-align: left;
font-weight: bold;
background-color: #e8e8e8;
min-width: 100px; /* half of 200px */
}
.gantt-container thead td {
border-right: 1px solid #999;
padding: 2px 1px; /* half */
text-align: center;
background-color: #f5f5f5;
font-size: 11px;
min-width: 15px; /* half of 30px */
font-weight: bold;
height: auto;
line-height: 1.2;
white-space: normal;
word-break: break-word;
}
.gantt-container tbody tr {
border-bottom: 1px solid #999;
height: 32px;
}
.gantt-container tbody tr:hover {
background-color: #f9f9f9;
}
.gantt-container tbody tr:hover td {
background-color: #f9f9f9 !important;
}
.gantt-container tbody td {
border-right: 1px solid #ccc;
padding: 1px; /* half */
text-align: center;
min-width: 15px; /* half of 30px */
height: 32px;
vertical-align: middle;
line-height: 28px;
background-color: #ffffff;
font-size: 18px;
font-weight: bold;
}
.gantt-container tbody td:nth-child(even) {
background-color: #fafafa;
}
.gantt-container tbody td:first-child {
border-right: 2px solid #666;
padding: 4px; /* half of 8px */
font-weight: 500;
cursor: pointer;
background-color: #fafafa !important;
text-align: left;
width: 100px; /* half of 200px */
min-width: 100px; /* half of 200px */
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
height: auto;
line-height: normal;
}
.gantt-container tbody td:first-child:hover {
background-color: #f0f0f0 !important;
text-decoration: underline;
}
.js-gantt-task-cell {
cursor: pointer;
}
.js-gantt-date-icon {
cursor: pointer;
}
.gantt-container .ganttview-weekend {
background-color: #efefef;
}
.gantt-container .ganttview-today {
background-color: #fcf8e3;
border-right: 2px solid #ffb347;
}
/* Task bar styling - VERY VISIBLE */
.gantt-container tbody td.ganttview-block {
background-color: #4CAF50 !important;
color: #fff !important;
font-size: 18px !important;
font-weight: bold !important;
padding: 2px !important;
border-radius: 2px;
}
/* Responsive adjustments */
@media (max-width: 768px) {
.gantt-container table {
font-size: 11px;
}
.gantt-container thead td {
min-width: 20px;
padding: 2px;
}
.gantt-container tbody td {
min-width: 20px;
padding: 1px;
height: 20px;
}
.gantt-container tbody td:first-child {
width: 100px;
font-size: 12px;
}
}
/* Print styles */
@media print {
.gantt-container {
overflow: visible;
}
.gantt-container table {
page-break-inside: avoid;
}
}
Loaded 100 of 1553 files, more files were not shown because too many files have changed in this diff. Show more