Compare commits

..
24 Commits
Author SHA1 Message Date
Lauri Ojansivu e49fe2e5df v2.97 2019-07-01 21:56:13 +03:00
Lauri Ojansivu 56c18c66ba Update changelog. 2019-07-01 21:01:39 +03:00
Lauri Ojansivu 3e8cb8c6e1 - Add background color names to background colors.
- Add new background colors: moderatepink, strongcyan, limegreen.
- Add new background colors with themes: dark, relax.
- Note: due date etc on cards is visible on all themes. Hiding is not implemented yet.

Thanks to xet7 !
2019-07-01 20:53:43 +03:00
Lauri Ojansivu e6365bbeeb v2.96 2019-07-01 16:52:59 +03:00
Lauri Ojansivu 8069f7072c Update changelog. 2019-07-01 16:32:56 +03:00
Lauri Ojansivu 8477e94f3b [Remove RELAX MODE and DARK MODE, because they changed look of all existing
boards](https://github.com/wekan/wekan/issues/1149#issuecomment-507255114).
At some later Wekan release they will be added back as separate themes
that can be selected, without changing existing boards.

Thanks to chirrut2 and xet7 !
2019-07-01 16:24:55 +03:00
Lauri Ojansivu 94611c4f38 v2.95 2019-07-01 05:51:41 +03:00
Lauri Ojansivu 0e6035f846 Update translations. 2019-07-01 05:46:05 +03:00
Lauri Ojansivu a8faeb2e5f Update changelog. 2019-07-01 05:44:21 +03:00
Lauri Ojansivu 118c9613dc Update changelog. 2019-07-01 05:29:02 +03:00
Lauri Ojansivu b42ecb7948 In RELAX MODE and DARK MODE, hide card fields: received, start, due, end, members, requested, assigned.
Thanks to xet7 !
2019-07-01 05:24:29 +03:00
Lauri Ojansivu e0398f01c8 Update changelog. 2019-07-01 05:13:35 +03:00
Lauri Ojansivu b260d05a8b Make list header add card + button more black, to make it more visible.
Thanks to xet7 !
2019-07-01 05:03:44 +03:00
Lauri Ojansivu bbc68309af On minicard, make comment icon and number of comments have red color
on white rounded background, so it is more visible when there is comments on card.

Thanks to xet7 !
2019-07-01 05:02:48 +03:00
Lauri Ojansivu 48ebc5f117 Make card description text more visible with black borders and more white text area.
Thanks to xet7 !
2019-07-01 05:01:52 +03:00
Lauri Ojansivu c04292e988 Added themes to board background colors:
- RELAX MODE, so when green background selected, list background is light green.
- [DARK MODE](https://github.com/wekan/wekan/issues/1149), when dark backgroud
  color selected. Please test and send color visibility fixes as pull requests.

Thanks to xet7 !

Related #1149
2019-07-01 05:00:18 +03:00
Lauri Ojansivu 2203d46493 Merge branch 'devel' into edge 2019-06-28 09:15:38 +03:00
Lauri Ojansivu dae8033d61 v2.93 2019-06-28 09:14:34 +03:00
Lauri Ojansivu b5c566615b Fix lint errors. 2019-06-28 09:07:30 +03:00
Lauri Ojansivu d792033ffa Update translations. 2019-06-28 08:55:58 +03:00
Lauri Ojansivu 63fa75819c Update changelog. 2019-06-28 08:54:19 +03:00
Lauri Ojansivu f25e54a9f1 Merge pull request #2519 from Worteks/devel
Add test if email attribute is an Array
2019-06-28 08:50:29 +03:00
Thibaut Démaret 621efd59db Add test if email attribute is an Array 2019-06-28 06:59:51 +02:00
Lauri Ojansivu 5e36c188f9 Update translations. 2019-06-27 20:31:41 +03:00
2779 changed files with 96558 additions and 885099 deletions

No files matched your search

-12
View File
@@ -1,12 +0,0 @@
{
"presets": [
"@babel/preset-stage-3"
],
"env": {
"COVERAGE": {
"plugins": [
"istanbul"
]
}
}
}
-293
View File
@@ -1,293 +0,0 @@
FROM ubuntu:24.04
LABEL maintainer="wekan"
LABEL org.opencontainers.image.ref.name="ubuntu"
LABEL org.opencontainers.image.version="24.04"
LABEL org.opencontainers.image.source="https://github.com/wekan/wekan"
# 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
ENV BUILD_DEPS="apt-utils gnupg gosu wget bzip2 g++ iproute2 apt-transport-https libarchive-tools"
ENV DEV_DEPS="curl python3 ca-certificates build-essential git"
ARG DEBIAN_FRONTEND=noninteractive
ENV \
DEBUG=false \
NODE_VERSION=v14.21.4 \
METEOR_RELEASE=METEOR@2.14 \
USE_EDGE=false \
METEOR_EDGE=1.5-beta.17 \
NPM_VERSION=6.14.17 \
FIBERS_VERSION=4.0.1 \
ARCHITECTURE=linux-x64 \
SRC_PATH=./ \
WITH_API=true \
RESULTS_PER_PAGE="" \
DEFAULT_BOARD_ID="" \
ACCOUNTS_LOCKOUT_KNOWN_USERS_FAILURES_BEFORE=3 \
ACCOUNTS_LOCKOUT_KNOWN_USERS_PERIOD=60 \
ACCOUNTS_LOCKOUT_KNOWN_USERS_FAILURE_WINDOW=15 \
ACCOUNTS_LOCKOUT_UNKNOWN_USERS_FAILURES_BERORE=3 \
ACCOUNTS_LOCKOUT_UNKNOWN_USERS_LOCKOUT_PERIOD=60 \
ACCOUNTS_LOCKOUT_UNKNOWN_USERS_FAILURE_WINDOW=15 \
ACCOUNTS_COMMON_LOGIN_EXPIRATION_IN_DAYS=90 \
ATTACHMENTS_UPLOAD_EXTERNAL_PROGRAM="" \
ATTACHMENTS_UPLOAD_MIME_TYPES="" \
ATTACHMENTS_UPLOAD_MAX_SIZE=0 \
AVATARS_UPLOAD_EXTERNAL_PROGRAM="" \
AVATARS_UPLOAD_MIME_TYPES="" \
AVATARS_UPLOAD_MAX_SIZE=0 \
RICHER_CARD_COMMENT_EDITOR=false \
CARD_OPENED_WEBHOOK_ENABLED=false \
MAX_IMAGE_PIXEL="" \
IMAGE_COMPRESS_RATIO="" \
NOTIFICATION_TRAY_AFTER_READ_DAYS_BEFORE_REMOVE="" \
BIGEVENTS_PATTERN=NONE \
NOTIFY_DUE_DAYS_BEFORE_AND_AFTER="" \
NOTIFY_DUE_AT_HOUR_OF_DAY="" \
EMAIL_NOTIFICATION_TIMEOUT=30000 \
MATOMO_ADDRESS="" \
MATOMO_SITE_ID="" \
MATOMO_DO_NOT_TRACK=true \
MATOMO_WITH_USERNAME=false \
METRICS_ALLOWED_IP_ADDRESSES="" \
BROWSER_POLICY_ENABLED=true \
TRUSTED_URL="" \
WEBHOOKS_ATTRIBUTES="" \
OAUTH2_ENABLED=false \
OIDC_REDIRECTION_ENABLED=false \
OAUTH2_CA_CERT="" \
OAUTH2_ADFS_ENABLED=false \
OAUTH2_B2C_ENABLED=false \
OAUTH2_LOGIN_STYLE=redirect \
OAUTH2_CLIENT_ID="" \
OAUTH2_SECRET="" \
OAUTH2_SERVER_URL="" \
OAUTH2_AUTH_ENDPOINT="" \
OAUTH2_USERINFO_ENDPOINT="" \
OAUTH2_TOKEN_ENDPOINT="" \
OAUTH2_ID_MAP="" \
OAUTH2_USERNAME_MAP="" \
OAUTH2_FULLNAME_MAP="" \
OAUTH2_ID_TOKEN_WHITELIST_FIELDS="" \
OAUTH2_REQUEST_PERMISSIONS='openid profile email' \
OAUTH2_EMAIL_MAP="" \
LDAP_ENABLE=false \
LDAP_PORT=389 \
LDAP_HOST="" \
LDAP_AD_SIMPLE_AUTH="" \
LDAP_USER_AUTHENTICATION=false \
LDAP_USER_AUTHENTICATION_FIELD=uid \
LDAP_BASEDN="" \
LDAP_LOGIN_FALLBACK=false \
LDAP_RECONNECT=true \
LDAP_TIMEOUT=10000 \
LDAP_IDLE_TIMEOUT=10000 \
LDAP_CONNECT_TIMEOUT=10000 \
LDAP_AUTHENTIFICATION=false \
LDAP_AUTHENTIFICATION_USERDN="" \
LDAP_AUTHENTIFICATION_PASSWORD="" \
LDAP_LOG_ENABLED=false \
LDAP_BACKGROUND_SYNC=false \
LDAP_BACKGROUND_SYNC_INTERVAL="" \
LDAP_BACKGROUND_SYNC_KEEP_EXISTANT_USERS_UPDATED=false \
LDAP_BACKGROUND_SYNC_IMPORT_NEW_USERS=false \
LDAP_ENCRYPTION=false \
LDAP_CA_CERT="" \
LDAP_REJECT_UNAUTHORIZED=false \
LDAP_USER_SEARCH_FILTER="" \
LDAP_USER_SEARCH_SCOPE="" \
LDAP_USER_SEARCH_FIELD="" \
LDAP_SEARCH_PAGE_SIZE=0 \
LDAP_SEARCH_SIZE_LIMIT=0 \
LDAP_GROUP_FILTER_ENABLE=false \
LDAP_GROUP_FILTER_OBJECTCLASS="" \
LDAP_GROUP_FILTER_GROUP_ID_ATTRIBUTE="" \
LDAP_GROUP_FILTER_GROUP_MEMBER_ATTRIBUTE="" \
LDAP_GROUP_FILTER_GROUP_MEMBER_FORMAT="" \
LDAP_GROUP_FILTER_GROUP_NAME="" \
LDAP_UNIQUE_IDENTIFIER_FIELD="" \
LDAP_UTF8_NAMES_SLUGIFY=true \
LDAP_USERNAME_FIELD="" \
LDAP_FULLNAME_FIELD="" \
LDAP_MERGE_EXISTING_USERS=false \
LDAP_EMAIL_FIELD="" \
LDAP_EMAIL_MATCH_ENABLE=false \
LDAP_EMAIL_MATCH_REQUIRE=false \
LDAP_EMAIL_MATCH_VERIFIED=false \
LDAP_SYNC_USER_DATA=false \
LDAP_SYNC_USER_DATA_FIELDMAP="" \
LDAP_SYNC_GROUP_ROLES="" \
LDAP_DEFAULT_DOMAIN="" \
LDAP_SYNC_ADMIN_STATUS="" \
LDAP_SYNC_ADMIN_GROUPS="" \
HEADER_LOGIN_ID="" \
HEADER_LOGIN_FIRSTNAME="" \
HEADER_LOGIN_LASTNAME="" \
HEADER_LOGIN_EMAIL="" \
HEADER_LOGIN_TRUSTED_IPS="" \
HEADER_LOGIN_TRUSTED_PROXIES="" \
LOGOUT_WITH_TIMER=false \
LOGOUT_IN="" \
LOGOUT_ON_HOURS="" \
LOGOUT_ON_MINUTES="" \
CORS="" \
CORS_ALLOW_HEADERS="" \
CORS_EXPOSE_HEADERS="" \
DEFAULT_AUTHENTICATION_METHOD="" \
PASSWORD_LOGIN_ENABLED=true \
CAS_ENABLED=false \
CAS_BASE_URL="" \
CAS_LOGIN_URL="" \
CAS_VALIDATE_URL="" \
SAML_ENABLED=false \
SAML_PROVIDER="" \
SAML_ENTRYPOINT="" \
SAML_ISSUER="" \
SAML_CERT="" \
SAML_IDPSLO_REDIRECTURL="" \
SAML_PRIVATE_KEYFILE="" \
SAML_PUBLIC_CERTFILE="" \
SAML_IDENTIFIER_FORMAT="" \
SAML_LOCAL_PROFILE_MATCH_ATTRIBUTE="" \
SAML_ATTRIBUTES="" \
ORACLE_OIM_ENABLED=false \
WAIT_SPINNER="" \
WRITABLE_PATH=/data \
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
#---------------------------------------------------------------------
ENV PATH=$PATH:/home/wekan/.meteor/
RUN <<EOR
echo "export PATH=$PATH" >> /etc/environment
EOR
# Copy source dir
RUN <<EOR
set -o xtrace
mkdir -p /home/wekan/app/.meteor
mkdir -p /home/wekan/app/packages
EOR
COPY \
.meteor/.finished-upgraders \
.meteor/.id \
.meteor/cordova-plugins \
.meteor/packages \
.meteor/platforms \
.meteor/release \
.meteor/versions \
/home/wekan/app/.meteor/
COPY \
package.json \
settings.json \
/home/wekan/app/
COPY \
tests \
/home/wekan/app/tests/
COPY \
packages \
/home/wekan/app/packages/
# Install OS
RUN <<EOR
set -o xtrace
# Add non-root user wekan
useradd --user-group --system --home-dir /home/wekan wekan
# OS dependencies
apt-get update --assume-yes
apt-get install --assume-yes --no-install-recommends ${BUILD_DEPS} ${DEV_DEPS}
# 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)
# 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/${NODE_VERSION}/SHASUMS256.txt"
# Verify nodejs authenticity
grep "node-${NODE_VERSION}-${ARCHITECTURE}.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
rm "node-$NODE_VERSION-$ARCHITECTURE.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"
# Install node dependencies
npm install -g npm@${NPM_VERSION}
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
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 (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
# Put back the original tar
mv $(which tar)~ $(which tar)
# Cleanup
apt-get remove --purge --assume-yes ${BUILD_DEPS}
apt-get install --assume-yes --no-install-recommends build-essential
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
mkdir /data
chown wekan --recursive /data
EOR
USER wekan
ENV PORT=3000
EXPOSE $PORT
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 ["/home/wekan/.meteor/meteor", "run", "--verbose", "--settings", "settings.json"]
-14
View File
@@ -1,14 +0,0 @@
#!/bin/bash
cd /home/wekan/app
rm -rf node_modules
/home/wekan/.meteor/meteor npm install
rm -rf .build
/home/wekan/.meteor/meteor build .build --directory
cp -f fix-download-unicode/cfs_access-point.txt .build/bundle/programs/server/packages/cfs_access-point.js
cd .build/bundle/programs/server
rm -rf node_modules
/home/wekan/.meteor/meteor npm install
cd node_modules/fibers
node build.js
cd /home/wekan/app
-17
View File
@@ -1,17 +0,0 @@
// See https://aka.ms/vscode-remote/devcontainer.json for format details.
{
"dockerComposeFile": ["docker-compose.yml", "docker-compose.extend.yml"],
"service": "wekan-dev",
"workspaceFolder": "/home/wekan/app",
"extensions": [
"mutantdino.resourcemonitor",
"editorconfig.editorconfig",
"dbaeumer.vscode-eslint",
"codezombiech.gitignore",
"eamodio.gitlens",
"gruntfuggly.todo-tree",
"dotjoshjohnson.xml",
"redhat.vscode-yaml",
"vuhrmeister.vscode-meteor"
]
}
-51
View File
@@ -1,51 +0,0 @@
version: '3.7'
services:
wekandb-dev:
image: mongo:6
container_name: wekan-dev-db
restart: unless-stopped
command: mongod --oplogSize 128
networks:
- wekan-dev-tier
expose:
- 27017
volumes:
- /etc/localtime:/etc/localtime:ro
- ./volumes/wekan-db:/data/db
- ./volumes/wekan-db-dump:/dump
wekan-dev:
container_name: wekan-dev-app
restart: always
networks:
- wekan-dev-tier
build:
context: ..
dockerfile: .devcontainer/Dockerfile
ports:
- 3000:3000
- 9229:9229
environment:
- MONGO_URL=mongodb://wekandb-dev:27017/wekan
- ROOT_URL=http://localhost:3000
- WITH_API=true
- RICHER_CARD_COMMENT_EDITOR=true
- BROWSER_POLICY_ENABLED=true
- WRITABLE_PATH=/data
depends_on:
- wekandb-dev
volumes:
- /etc/localtime:/etc/localtime:ro
- ./volumes/data:/data
- ../client:/home/wekan/app/client
- ../models:/home/wekan/app/models
- ../config:/home/wekan/app/config
- ../imports:/home/wekan/app/imports
- ../server:/home/wekan/app/server
- ../public:/home/wekan/app/public
networks:
wekan-dev-tier:
driver: bridge
-36
View File
@@ -1,36 +0,0 @@
*~
*.swp
.meteor-spk
*.sublime-workspace
tmp/
node_modules/
npm-debug.log
.gitmodules
.vscode/
.idea/
.build/*
**/parts/
**/stage
**/prime
**/*.snap
snap/.snapcraft/
.idea
.DS_Store
.DS_Store?
.build*
*.browserify.js.cached
*.browserify.js.map
.build*
versions.json
.versions
.npm
.build*
._*
.Trashes
Thumbs.db
ehthumbs.db
.eslintcache
.meteor/local
.devcontainer/docker-compose.extend.yml
.devcontainer/volumes*/
.git
-17
View File
@@ -8,20 +8,3 @@ end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
[*.{js,html}]
charset = utf-8
end_of_line = lf
indent_brace_style = 1TBS
indent_size = 2
indent_style = space
insert_final_newline = true
max_line_length = 80
quote_type = auto
spaces_around_operators = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
-2
View File
@@ -1,2 +0,0 @@
packages/*
.snap-meteor-1.8/*
+27 -73
View File
@@ -1,59 +1,38 @@
{
"extends": [
"eslint:recommended",
"plugin:meteor/recommended",
"prettier",
"prettier/standard"
],
"extends": "eslint:recommended",
"env": {
"es6": true,
"node": true,
"browser": true,
"meteor": true
"browser": true
},
"parser": "babel-eslint",
"parserOptions": {
"ecmaVersion": 2018,
"sourceType": "module"
"ecmaVersion": 2017,
"sourceType": "module",
"ecmaFeatures": {
"experimentalObjectRestSpread": true
}
},
"rules": {
"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,
"indent": 0,
"indent": [2, 2],
"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"
],
"meteor/audit-argument-checks": 0,
"linebreak-style": [2, "unix"],
"new-parens": 2,
"no-lonely-if": 2,
"no-multiple-empty-lines": 2,
@@ -61,15 +40,10 @@
"no-spaced-func": 2,
"no-trailing-spaces": 2,
"operator-linebreak": 2,
"quotes": [
2,
"single",
{
"avoidEscape": true
}
],
"quotes": [2, "single"],
"semi-spacing": 2,
"space-unary-ops": 2,
"arrow-parens": 2,
"arrow-spacing": 2,
"no-class-assign": 2,
"no-dupe-class-members": 2,
@@ -78,35 +52,8 @@
"prefer-const": 2,
"prefer-spread": 2,
"prefer-template": 2,
"no-unused-vars": "warn",
"@typescript-eslint/no-floating-promises": "error",
"prettier/prettier": [
"error",
{
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"singleQuote": true,
"trailingComma": "all"
}
],
"meteor/no-session": 0
"no-unused-vars" : "warn"
},
"settings": {
"import/resolver": {
"meteor": {
"extensions": [
".js",
".jsx"
]
}
}
},
"plugins": [
"prettier",
"meteor",
"@typescript-eslint"
],
"globals": {
"Meteor": false,
"Session": false,
@@ -123,31 +70,37 @@
"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,
"Activities": true,
"Attachments": true,
"Boards": true,
"CardCommentReactions": true,
"CardComments": true,
"DatePicker": true,
"DatePicker" : true,
"Cards": true,
"CustomFields": true,
"Lists": true,
@@ -156,11 +109,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,
@@ -168,16 +123,15 @@
"allowIsBoardMemberByCard": true,
"allowIsBoardMemberCommentOnly": true,
"allowIsBoardMemberNoComments": true,
"allowIsBoardMemberWorker": true,
"Emoji": true,
"Checklists": true,
"Settings": true,
"InvitationCodes": true,
"JsonRoutes": true,
"Authentication": true,
"Integrations": true,
"HTTP": true,
"AccountSettings": true,
"TableVisibilityModeSettings": true,
"Announcements": true,
"Swimlanes": true,
"ChecklistItems": true,
-3
View File
@@ -1,3 +0,0 @@
* text=auto eol=lf
*.{cmd,[cC][mM][dD]} text eol=crlf
*.{bat,[bB][aA][tT]} text eol=crlf
-4
View File
@@ -1,4 +0,0 @@
# These are supported funding model platforms
github: wekan
custom: ['https://wekan.fi/commercial-support/']
+27
View File
@@ -0,0 +1,27 @@
## Issue
Add these issues to elsewhere:
- Snap: https://github.com/wekan/wekan-snap/issues
Other Wekan issues can be added here.
**Server Setup Information**:
* Did you test in newest Wekan?:
* For new Wekan install, did you configure root-url correctly https://github.com/wekan/wekan/wiki/Settings ?
* Wekan version:
* If this is about old version of Wekan, what upgrade problem you have?:
* Operating System:
* Deployment Method(snap/docker/sandstorm/mongodb bundle/source):
* Http frontend if any (Caddy, Nginx, Apache, see config examples from Wekan GitHub wiki first):
* Node Version:
* MongoDB Version:
* ROOT_URL environment variable http(s)://(subdomain).example.com(/suburl):
**Problem description**:
- *REQUIRED: Add recorded animated gif about how it works currently, and screenshot mockups how it should work. Use peek to record animgif in Linux https://github.com/phw/peek*
- *Explain steps how to reproduce*
- *In webbrowser, what does show Right Click / Inspect / Console ? Chrome shows more detailed info than Firefox.*
- *If using Snap, what does show command `sudo snap logs wekan.wekan` ?*
- *If using Docker, what does show command `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.
-43
View File
@@ -1,43 +0,0 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
ignore:
# Build-time-only (devDependency) Rspack toolchain. These packages compile
# the bundle on the developer / CI machine and are NOT shipped in the
# deployed WeKan server or client bundle, so their advisories do not affect
# a running WeKan instance. (For example webpack-dev-server / @rspack/dev-
# server, pulled via @rspack/cli, and the socket.io/ws chain pulled via
# @rsdoctor/rspack-plugin, never run in WeKan — it builds with `meteor run`,
# not the Rspack CLI or dev-server.)
#
# They also cannot simply be removed or bumped:
# * @meteorjs/rspack re-pins and re-installs @rspack/cli and
# @rsdoctor/rspack-plugin into package.json on every build, so deleting
# them does not stick.
# * The only upgrades Dependabot/npm offer are build-breaking: a
# @meteorjs/rspack downgrade, or @rspack/cli 2.0 / Rspack 2.0, which
# needs Meteor 3.6 (see https://github.com/meteor/meteor/pull/14360).
# WeKan currently runs METEOR@3.5-rc.2.
#
# So pin them here until Meteor 3.6 + Rspack 2.0 land, then revisit.
- dependency-name: "@meteorjs/rspack"
- dependency-name: "@rspack/core"
- dependency-name: "@rspack/cli"
- dependency-name: "@rsdoctor/rspack-plugin"
- dependency-name: "css-loader"
- dependency-name: "style-loader"
# Playwright end-to-end test harness — pure test-only tooling that never ships in
# WeKan. It now depends only on @playwright/test and the official `mongodb` driver
# (the heavy, CVE-prone `mongosh` CLI was replaced by helpers/mongo-runner.js).
# Watch this directory so those two stay patched.
- package-ecosystem: "npm"
directory: "/tests/playwright"
schedule:
interval: "weekly"
-14
View File
@@ -1,14 +0,0 @@
name: 'Dependency Review'
on: [pull_request]
permissions:
contents: read
jobs:
dependency-review:
runs-on: ubuntu-latest
steps:
- name: 'Checkout Repository'
uses: actions/checkout@v7
- name: 'Dependency Review'
uses: actions/dependency-review-action@v5
-63
View File
@@ -1,63 +0,0 @@
name: Docker
# This workflow uses actions that are not certified by GitHub.
# They are provided by a third-party and are governed by
# separate terms of service, privacy policy, and support
# documentation.
on:
schedule:
- cron: '28 23 * * *'
push:
branches: [ main ]
# Publish semver tags as releases.
tags: [ 'v*.*.*' ]
pull_request:
branches: [ main ]
env:
# Use docker.io for Docker Hub if empty
REGISTRY: ghcr.io
# github.repository as <account>/<repo>
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v7
# 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@af1e73f918a031802d376d3c8bbc3fe56130a9b0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Extract metadata (tags, labels) for Docker
# https://github.com/docker/metadata-action
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302
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@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
-20
View File
@@ -1,20 +0,0 @@
name: Docker Image CI
on:
push:
branches:
- main
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: Build the Docker image
run: docker build . --file Dockerfile --tag wekan:$(date +%s)
-298
View File
@@ -1,298 +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@v7
- name: Setup Node 24
uses: actions/setup-node@v6
with:
node-version: '24'
cache: 'npm'
- name: Cache Meteor installation
uses: actions/cache@v6
with:
path: ~/.meteor
key: meteor-${{ hashFiles('.meteor/release') }}-${{ hashFiles('.meteor/packages') }}
restore-keys: meteor-
- name: Cache Meteor build (.meteor/local)
uses: actions/cache@v6
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
# NOTE: do not set TEST_WATCH here. meteortesting:mocha computes
# `testWatch: TEST_WATCH || …`, and the string '0' is truthy in JS, so
# TEST_WATCH=0 turns ON watch mode and the process never exits — that is
# what made this job hang until the 45-minute timeout. With TEST_WATCH
# unset, `meteor test --once` runs the server tests and exits with the
# correct status (matching the local rebuild-wekan.sh run).
run: meteor test --once --driver-package meteortesting:mocha
e2e-tests:
name: Playwright E2E (${{ matrix.project }} ${{ matrix.shard }}/2)
runs-on: ubuntu-24.04
needs: unit-tests
strategy:
fail-fast: false
matrix:
# Playwright bundles Chromium, Firefox and WebKit builds that all run
# headless on the Linux runner (installed per-job below with --with-deps).
project: [chromium, firefox, webkit]
# Split each browser's ~245 specs across 2 parallel shards (6 jobs total)
# to cut wall-clock time. Each shard job starts its own WeKan + Mongo, so
# there is no shared-server contention between shards. Raise the shard
# count (here and in the --shard flag below) once the baseline is green
# and/or once E2E runs against a prebuilt bundle (cheaper per-job startup).
shard: [1, 2]
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@v7
- name: Setup Node 24
uses: actions/setup-node@v6
with:
node-version: '24'
cache: 'npm'
- name: Cache Meteor installation
uses: actions/cache@v6
with:
path: ~/.meteor
key: meteor-${{ hashFiles('.meteor/release') }}-${{ hashFiles('.meteor/packages') }}
restore-keys: meteor-
- name: Cache Meteor build (.meteor/local)
uses: actions/cache@v6
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: Build and start WeKan (production bundle)
run: |
# Run E2E against a PRODUCTION build instead of `meteor run` (dev mode).
# In dev the client JS is served by the rspack dev server, which did not
# serve the bundle reliably in headless CI: the browser received HTML for
# the bundle ("Unexpected token '<'"), Meteor never initialised, and every
# spec timed out. A production build bakes the client JS into the bundle,
# served statically by the Meteor server at :3000.
meteor build ./output --directory
( cd output/bundle/programs/server && npm install )
# WeKan's server/00checkStartup.js refuses to start unless WRITABLE_PATH
# (used for attachment/file storage) is set and writable.
mkdir -p "$PWD/wekan-writable"
# Match the env the dev runner (rebuild-wekan.sh) and the specs expect:
# WITH_API enables the REST API (specs 17/23/24/28), comment-editor
# flag matches dev.
MONGO_URL=mongodb://localhost:27017/wekan_test \
ROOT_URL=http://localhost:3000 \
PORT=3000 \
WRITABLE_PATH="$PWD/wekan-writable" \
WITH_API=true \
RICHER_CARD_COMMENT_EDITOR=false \
node output/bundle/main.js > wekan-run.log 2>&1 &
# The production server serves the client JS statically, so once :3000
# responds the bundle is being served. Wait up to 10 minutes.
for i in $(seq 1 60); 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/60"
sleep 10
done
if ! curl -sf http://localhost:3000 > /dev/null; then
echo "WeKan failed to start. Last 250 log lines:"
tail -n 250 wekan-run.log
exit 1
fi
env:
TOOL_NODE_FLAGS: --max-old-space-size=8192
NODE_OPTIONS: --max-old-space-size=8192
DISABLE_CAPTCHA: 'true'
- name: Run Playwright tests (${{ matrix.project }} shard ${{ matrix.shard }}/2)
run: |
npx playwright test \
--project=${{ matrix.project }} \
--shard=${{ matrix.shard }}/2 \
--reporter=github
working-directory: tests/playwright
env:
WEKAN_BASE_URL: http://localhost:3000
WEKAN_MONGO_URL: mongodb://localhost:27017/wekan_test
# Make the firefox and webkit projects available to --project
# (playwright.config.js only defines them when this is set).
WEKAN_PLAYWRIGHT_ALL: '1'
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@v7
with:
name: playwright-report-${{ matrix.project }}-shard${{ matrix.shard }}
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 }}-shard${{ matrix.shard }}
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@v7
- name: Setup Node 24
uses: actions/setup-node@v6
with:
node-version: '24'
cache: 'npm'
- name: Cache Meteor installation
uses: actions/cache@v6
with:
path: ~/.meteor
key: meteor-${{ hashFiles('.meteor/release') }}-${{ hashFiles('.meteor/packages') }}
restore-keys: meteor-
- name: Cache Meteor build (.meteor/local)
uses: actions/cache@v6
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: Build and start WeKan (production bundle)
run: |
# Run a PRODUCTION build, not `meteor run` (dev): exactly like the
# Playwright e2e job, because the rspack dev server does not serve the
# client JS bundle in headless CI, so Meteor never initialises and the
# puppeteer regression test times out in waitForMeteorGlobals(). The
# production bundle serves the client statically. WRITABLE_PATH is
# required by server/00checkStartup.js; WITH_API mirrors the dev runner.
meteor build ./output --directory
( cd output/bundle/programs/server && npm install )
mkdir -p "$PWD/wekan-writable"
MONGO_URL=mongodb://localhost:27017/wekan_regression \
ROOT_URL=http://localhost:3000 \
PORT=3000 \
WRITABLE_PATH="$PWD/wekan-writable" \
WITH_API=true \
RICHER_CARD_COMMENT_EDITOR=false \
node output/bundle/main.js > wekan-run.log 2>&1 &
for i in $(seq 1 60); 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/60"
sleep 10
done
if ! curl -sf http://localhost:3000 > /dev/null; then
echo "WeKan failed to start. Last 250 log lines:"
tail -n 250 wekan-run.log
exit 1
fi
env:
TOOL_NODE_FLAGS: --max-old-space-size=8192
NODE_OPTIONS: --max-old-space-size=8192
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'
-909
View File
@@ -1,909 +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
#
# The Docker image and snap cover the platforms MongoDB ships server binaries for
# (amd64, arm64; win64 + mac bundles too); MongoDB 7.x is the default database.
# Additionally, WeKan server bundles are built for every other architecture
# Node.js 24.x publishes binaries for — s390x and ppc64le — and attached to the
# GitHub Release as extra downloads (run a separate MongoDB for those). These
# extra-arch bundles are NOT part of the Docker image or snap. (armv7l/32-bit ARM
# is not built: Node.js 24 ships no armv7l binaries.)
#
# 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,
# only AFTER the `docker` job has published the image (skipped if
# docker fails)
# - build-*: builds the amd64 bundle (full Meteor 3 build), then rebuilds native
# modules for arm64 from that amd64 base
# - release: creates the GitHub Release with the newest CHANGELOG.md notes and
# uploads the core wekan-<version>-{amd64,arm64}.zip bundles
# - build-win64 / build-mac-arm64 / build-extra-arches: built AFTER release, each
# rebuilds the amd64 bundle's native modules for its platform (Windows,
# macOS arm64, and QEMU-emulated s390x/ppc64le) and attaches its
# .zip to the Release as an extra download. Decoupled from `release` so
# a slow/flaky build never blocks it; not in the Docker image or snap
# - 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 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@v7
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@v7
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@v7
with:
ref: main
path: wekan
- name: Checkout wekan.fi website repo
uses: actions/checkout@v7
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 (after docker) ──────────────
# Bumps the WeKan version in wekan/charts, packages the chart and publishes it
# to the gh-pages Helm index, then pushes both branches. Runs only AFTER the
# `docker` job succeeds: the chart points users at the freshly published
# multi-arch image, so we don't advertise a new chart version until that image
# is actually on the registries. If `docker` fails, this job is skipped.
charts:
needs: docker
runs-on: ubuntu-latest
steps:
- name: Checkout wekan repo (for releases/release-charts.sh + version refs)
uses: actions/checkout@v7
with:
ref: main
path: wekan
- name: Checkout wekan/charts repo (main + gh-pages)
uses: actions/checkout@v7
with:
repository: wekan/charts
token: ${{ secrets.WEKAN_REPO_TOKEN }}
fetch-depth: 0
path: charts
- name: Install Helm
uses: azure/setup-helm@v5
- 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 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@v7
with:
ref: refs/tags/v${{ needs.prepare.outputs.version }}
- name: Cache Meteor installation
uses: actions/cache@v6
with:
path: ~/.meteor
key: meteor-${{ hashFiles('.meteor/release') }}-${{ hashFiles('.meteor/packages') }}
restore-keys: meteor-
- name: Cache npm packages
uses: actions/cache@v6
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
# ─── 7. Create GitHub Release and upload all bundles ────────────────────────
release:
needs:
- prepare
- build-amd64
- build-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
# ─── 7b. Extra-platform bundles (win64, mac-arm64, s390x, ppc64le) ───────────
# Every bundle below is built AFTER `release` and attaches its own .zip to the
# already-created GitHub Release with `gh release upload --clobber`, rather than
# being a prerequisite of `release`. This deliberately decouples the slower /
# different-OS builds (Windows, macOS, QEMU-emulated arches) from the core
# amd64/arm64 release, Docker and snap, so a slow or flaky one never blocks them.
# These bundles are NOT part of the Docker image or snap.
# Build win64 bundle (rebuild native modules on Windows).
build-win64:
needs: [prepare, build-amd64, release]
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@v7
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
- name: Attach win64 bundle to the GitHub Release
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# The repo is checked out into src/, so the workspace root is not a git
# repository; set GH_REPO so `gh` targets wekan/wekan without relying on
# git remote detection (else: "fatal: not a git repository").
GH_REPO: ${{ github.repository }}
run: gh release upload "v${VERSION}" "wekan-${VERSION}-win64.zip" --clobber
# Build mac-arm64 bundle (rebuild native modules on Apple Silicon).
build-mac-arm64:
needs: [prepare, build-amd64, release]
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
- name: Attach mac-arm64 bundle to the GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# No repo checkout in this job, so set GH_REPO so `gh` knows the target
# repository instead of trying (and failing) to detect a git remote.
GH_REPO: ${{ github.repository }}
run: gh release upload "v${VERSION}" "wekan-${VERSION}-mac-arm64.zip" --clobber
# Extra-architecture Linux bundles (s390x, ppc64le).
# armv7l (32-bit ARM) is intentionally excluded: Node.js 24 ships no armv7l
# binaries (official or unofficial), so there is no node:24 linux/arm/v7 image
# to rebuild native modules against and no Node 24 runtime to run a bundle on
# an armv7 device. See https://nodejs.org/dist/latest-v24.x/ (no -linux-armv7l).
build-extra-arches:
needs: [prepare, build-amd64, release]
runs-on: ubuntu-24.04
timeout-minutes: 300
strategy:
fail-fast: false
matrix:
include:
- arch: s390x
platform: linux/s390x
- arch: ppc64le
platform: linux/ppc64le
env:
VERSION: ${{ needs.prepare.outputs.version }}
steps:
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y curl unzip zip
- name: Set up QEMU for ${{ matrix.arch }} emulation
uses: docker/setup-qemu-action@v4
- name: Download amd64 bundle
uses: actions/download-artifact@v8
with:
name: bundle-amd64
- name: Rebuild native modules for ${{ matrix.arch }} under emulation
run: |
unzip -q wekan-${VERSION}-amd64.zip
# Same bcrypt fix as the arm64 build: bcrypt 5.0.1 (Meteor
# accounts-password) ships node-addon-api@3 which fails to compile on
# Node.js 24. Swap in bcrypt 6.0.0 source; the emulated npm install
# below compiles it (and any other native module) for the target arch.
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 ${{ matrix.platform }} \
-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 ${{ matrix.arch }} zip
run: zip -r wekan-${VERSION}-${{ matrix.arch }}.zip bundle
- uses: actions/upload-artifact@v7
with:
name: bundle-${{ matrix.arch }}
path: wekan-${{ needs.prepare.outputs.version }}-${{ matrix.arch }}.zip
retention-days: 1
- name: Attach ${{ matrix.arch }} bundle to the GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# No repo checkout in this job, so set GH_REPO so `gh` knows the target
# repository instead of trying (and failing) to detect a git remote.
GH_REPO: ${{ github.repository }}
run: gh release upload "v${VERSION}" "wekan-${VERSION}-${{ matrix.arch }}.zip" --clobber
# ─── 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@v7
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@v4
- 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: Wait for release bundles to be downloadable
# The Dockerfile downloads wekan-<version>-<arch>.zip from the GitHub
# Release for each built platform. Even though this job needs `release`
# (so the assets are uploaded), GitHub's releases/download/<tag>/<asset>
# URL can briefly 404 right after upload (CDN/propagation lag), which used
# to fail the multi-arch build mid-way. Poll here until both the amd64 and
# arm64 bundles return 200, so the (expensive, emulated) buildx run only
# starts once the assets are actually fetchable. The Dockerfile also
# retries per-arch as a second layer of safety.
run: |
set -euo pipefail
base="https://github.com/wekan/wekan/releases/download/v${VERSION}"
for arch in amd64 arm64; do
url="${base}/wekan-${VERSION}-${arch}.zip"
echo "Waiting for ${url} ..."
ok=0
for i in $(seq 1 30); do
code=$(curl -sSL -o /dev/null -w '%{http_code}' --retry 0 -I "$url" || echo 000)
if [ "$code" = "200" ]; then
echo " available (HTTP $code) after $((i*10))s"
ok=1
break
fi
echo " attempt $i/30: HTTP $code, retrying in 10s..."
sleep 10
done
if [ "$ok" -ne 1 ]; then
echo "::error::Release asset not downloadable after retries: $url"
exit 1
fi
done
- name: Verify the release bundle reports the release version
# Belt-and-suspenders against the "image tagged vX shows vY in the Admin
# Panel" bug: the image is just the unzipped wekan-<version>-<arch>.zip
# bundle (Dockerfile moves it to /build), and the Admin Panel shows that
# bundle's WeKan version (server/statistics.js reads
# require('/package.json').version). Check the amd64 bundle's WeKan version
# equals the release version BEFORE the expensive multi-arch buildx, so a
# stale/mismatched bundle fails fast instead of shipping (and pushing to
# three registries) a mislabeled image.
#
# Meteor does NOT ship the app package.json as a standalone file — it
# inlines it into the compiled bundle/programs/server/app/app.js as a JSON
# module, e.g. {"name":"wekan","version":"v9.63.0",...}. That inlined
# module (anchored on "name":"wekan") is exactly what require('/package.json')
# resolves to at runtime, so it is the authoritative thing to check.
#
# IMPORTANT: only a CONFIRMED mismatch fails the release. If the version
# cannot be located (bundle layout changed, app.js minified differently),
# warn and continue — inability to verify must not block a good release,
# since --build-arg VERSION below is the actual guarantee. (An earlier
# version of this guard grepped for a v-prefixed *package.json* and made
# "not found" fatal, which false-failed the whole v9.63 docker build.)
run: |
set -euo pipefail
url="https://github.com/wekan/wekan/releases/download/v${VERSION}/wekan-${VERSION}-amd64.zip"
wget -q "$url" -O bundle.zip
tmp="$(mktemp -d)"
# Extract only the compiled app program (not the whole multi-hundred-MB
# bundle); the inlined app package.json lives in app/app.js.
unzip -o -q bundle.zip 'bundle/programs/server/app/app.js' -d "$tmp" || true
appjs="$tmp/bundle/programs/server/app/app.js"
found=""
if [ -f "$appjs" ]; then
match="$(grep -aoE '"name":"wekan","version":"v?[0-9][^"]*"' "$appjs" | head -1 || true)"
found="$(printf '%s' "$match" | sed -E 's/.*"version":"([^"]*)".*/\1/')"
fi
rm -rf "$tmp" bundle.zip
if [ -z "$found" ]; then
echo "::warning::Could not locate the WeKan version in the bundle (app.js layout may have changed); skipping the bundle-version check. --build-arg VERSION still guarantees the correct bundle."
exit 0
fi
norm="${found#v}"
if [ "$norm" != "${VERSION}.0" ] && [ "$norm" != "${VERSION}" ]; then
echo "::error::Bundle reports app version ${found} but the release is v${VERSION}. The bundle baked into the image is stale/mismatched — the Admin Panel would show ${norm}. Aborting before build."
exit 1
fi
echo "Bundle app version ${found} matches release v${VERSION}."
- name: Build and push multi-arch image
# Pass VERSION explicitly as a build-arg. The Dockerfile's `ARG VERSION`
# default is only a fallback for manual builds; the release version is the
# authoritative source here. Without this the image would build against the
# Dockerfile default (downloading that version's wekan-<v>-<arch>.zip and
# baking its package.json), so a v${VERSION}-tagged image could ship — and
# report in the Admin Panel — a stale, different version.
run: |
docker buildx build \
--platform linux/amd64,linux/arm64 \
--build-arg VERSION=${VERSION} \
-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@v7
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
# The Snap Store intermittently rejects the install with
# 'error: cannot install "snapcraft": too many requests' (a 429-style
# rate limit) that instantly fails the whole snap job before any build.
# Retry a few times so one transient store throttle does not fail the
# release (same spirit as the remote-build retry below).
run: |
set -uo pipefail
for i in 1 2 3 4 5; do
sudo snap install snapcraft --classic && exit 0
echo "snapcraft install attempt $i/5 failed (Snap Store throttle?); retrying in 30s..."
sleep 30
done
echo "::error::snapcraft install failed after 5 attempts (Snap Store rate limit)."
exit 1
- 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: Verify snapcraft.yaml version matches the release
# The `bump` job (releases/version.sh) is the single source of truth for
# the snap version and updates snapcraft.yaml before the tag is created.
# snapcraft.yaml's `version:` names the produced files
# (wekan_<version>_<arch>.snap), so if a stale tag carried the wrong
# version the build would produce wekan_<old>_*.snap and the upload/attach
# globs (wekan_${VERSION}_*.snap) would match nothing — passing the literal
# glob to `snapcraft upload` ("is not a valid file", exit 64). Assert it
# here so that mismatch fails fast (before a ~20 min Launchpad build) with
# a clear pointer, instead of re-bumping the version inside this build job.
run: |
set -euo pipefail
if ! grep -qxF "version: '${VERSION}'" snapcraft.yaml; then
echo "::error::snapcraft.yaml is not at version '${VERSION}' ($(grep -m1 '^version:' snapcraft.yaml)). The bump job (releases/version.sh) did not update the tagged commit."
exit 1
fi
echo "snapcraft.yaml is at version '${VERSION}'."
- name: Build snaps on Launchpad (amd64 + arm64)
# `snapcraft remote-build` is experimental and talks to Launchpad over
# HTTPS after pushing the whole repo; it intermittently dies with a
# transient TLS drop (e.g. "SSLEOFError: EOF occurred in violation of
# protocol", exit 70) that has nothing to do with the build itself.
# Retry a few times so one network hiccup doesn't fail the release.
run: |
set -uo pipefail
shopt -s nullglob
attempts=3
for i in $(seq 1 "$attempts"); do
echo "::group::Remote build attempt $i/$attempts"
snapcraft remote-build --launchpad-accept-public-upload && rc=0 || rc=$?
echo "::endgroup::"
# snapcraft remote-build can exit 0 even when the Launchpad build
# FAILED (it just downloads the build logs and produces no .snap), so
# don't trust the exit code alone — require that .snap files exist.
snaps=( wekan_${VERSION}_*.snap )
if [ "$rc" -eq 0 ] && [ "${#snaps[@]}" -gt 0 ]; then
echo "Remote build succeeded on attempt $i: ${snaps[*]}"
exit 0
fi
echo "Attempt $i: snapcraft exit=$rc, .snap files produced=${#snaps[@]}."
# Surface the Launchpad build logs so a real build failure is
# diagnosable here, instead of as a later cryptic upload error.
for log in snapcraft-wekan-*.txt; do
echo "::group::Launchpad build log: $log (last 150 lines)"
tail -n 150 "$log"
echo "::endgroup::"
done
if [ "$i" -lt "$attempts" ]; then
echo "Retrying in 60s..."
sleep 60
fi
done
echo "::error::Remote build failed after $attempts attempts (no wekan_${VERSION}_*.snap produced)." >&2
exit 1
- name: Push snaps to the Snap Store (edge + beta channels)
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAP_AUTH }}
run: |
set -euo pipefail
shopt -s nullglob
snaps=( wekan_${VERSION}_*.snap )
if [ "${#snaps[@]}" -eq 0 ]; then
echo "::error::No wekan_${VERSION}_*.snap files to upload."
exit 1
fi
for f in "${snaps[@]}"; 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: |
set -euo pipefail
shopt -s nullglob
snaps=( wekan_${VERSION}_*.snap )
if [ "${#snaps[@]}" -eq 0 ]; then
echo "::error::No wekan_${VERSION}_*.snap files to attach."
exit 1
fi
gh release upload "v${VERSION}" "${snaps[@]}" --clobber
-30
View File
@@ -1,30 +0,0 @@
name: Release Charts
on:
push:
branches:
- main
permissions:
contents: read
jobs:
release:
permissions:
contents: write # for helm/chart-releaser-action to push chart release and create a release
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Configure Git
run: |
git config user.name "$GITHUB_ACTOR"
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
- name: Run chart-releaser
uses: helm/chart-releaser-action@v1.7.0
env:
CR_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
+2 -43
View File
@@ -1,15 +1,14 @@
*~
/*.log
*.sw*
*.swp
.meteor-spk
*.sublime-workspace
tmp/
node_modules/
npm-debug.log
.gitmodules
.vscode/
.idea/
.build/*
package-lock.json
**/parts/
**/stage
**/prime
@@ -30,43 +29,3 @@ versions.json
.Trashes
Thumbs.db
ehthumbs.db
.eslintcache
.meteor/local
.meteor/local-test
.devcontainer/docker-compose.extend.yml
.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/
# Meteor custom local directory (METEOR_LOCAL_DIR)
_build-local-test
*/build-assets-local-test
*/build-chunks-local-test
-10
View File
@@ -1,10 +0,0 @@
FROM gitpod/workspace-mongodb
USER gitpod
# Install custom tools, runtime, etc. using apt-get
# For example, the command below would install "bastet" - a command line tetris clone:
#
# RUN sudo apt-get -q update && # sudo apt-get install -yq bastet && # sudo rm -rf /var/lib/apt/lists/*
#
# More information: https://www.gitpod.io/docs/config-docker/
-4
View File
@@ -1,4 +0,0 @@
tasks:
- init: npm install
image:
file: .gitpod.Dockerfile
-2
View File
@@ -16,5 +16,3 @@ notices-for-facebook-graph-api-2
1.4.1-add-shell-server-package
1.4.3-split-account-service-packages
1.5-add-dynamic-import-package
1.7-split-underscore-from-meteor-base
1.8.3-split-jquery-from-blaze
+73 -44
View File
@@ -3,63 +3,92 @@
# '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.2.0
# Build system
ecmascript@0.18.1
standard-minifier-js@3.2.0
rspack@1.1.0
ecmascript
stylus@2.513.13
standard-minifier-css@1.3.5
standard-minifier-js@2.2.0
mquandalle:jade
blaze@3.0.2
# Polyfills
es5-shim@4.6.15
# Collections
aldeed:collection2
aldeed:schema-index@4.0.0-beta.5
reywood:publish-composite
mongo@2.4.0
cfs:standard-packages
cottz:publish-relations
dburles:collection-helpers
idmontie:migrations
matb33:collection-hooks
matteodem:easy-search
mongo@1.3.1
mquandalle:collection-mutations
# Account system
accounts-password@3.3.0
accounts-express@1.0.0
communitypackages:core
communitypackages:unstyled
communitypackages:flow-routing
# Routing
ostrio:flow-router-extra
kenton:accounts-sandstorm
service-configuration@1.0.11
useraccounts:unstyled
useraccounts:flow-routing
wekan-ldap
wekan-accounts-cas
wekan-accounts-sandstorm
wekan-accounts-lockout
wekan-oidc
wekan-accounts-oidc
# Utilities
check@1.5.0
random@1.2.2
reactive-dict@1.3.2
session@1.2.3
tracker@1.3.4
audit-argument-checks@1.0.8
check@1.2.5
jquery@1.11.10
random@1.0.10
reactive-dict@1.2.0
session@1.1.7
tracker@1.1.3
underscore@1.0.10
3stack:presence
alethes:pages
arillo:flow-router-helpers
audit-argument-checks@1.0.7
kadira:blaze-layout
kadira:dochead
meteorhacks:picker
meteorhacks:subs-manager
mquandalle:autofocus
ongoworks:speakingurl
raix:handlebar-helpers
tap:i18n
http@1.3.0
# UI components
ostrio:i18n
reactive-var@1.0.13
shell-server@0.7.0
email@3.2.0
dynamic-import@0.7.4
blaze
reactive-var@1.0.11
fortawesome:fontawesome
mousetrap:mousetrap
mquandalle:jquery-textcomplete
mquandalle:jquery-ui-drag-drop-sort
mquandalle:mousetrap-bindglobal
peerlibrary:blaze-components@=0.15.1
templates:tabs
verron:autosize
simple:json-routes
rajit:bootstrap3-datepicker
shell-server@0.3.0
simple:rest-accounts-password
useraccounts:core
email@1.2.3
horka:swipebox
dynamic-import@0.2.0
staringatlights:fast-render
mixmax:smart-disconnect
accounts-password@1.5.0
cfs:gridfs
rzymek:fullcalendar
momentjs:moment@2.22.2
browser-policy-framing
mquandalle:moment
msavin:usercache
wekan-scrollbar
mquandalle:perfect-scrollbar
mdg:meteor-apm-agent
meteorhacks:unblock
lucasantoniassi:accounts-lockout
wekan-markdown
quave:synced-cron
ostrio:cookies
ostrio:files
# lmieulet:meteor-coverage
meteortesting:mocha@3.0.0
matb33:collection-hooks
service-configuration@1.3.5
minifier-css@2.0.2
ejson@1.2.0
logging@1.3.6
wekan-fullcalendar
wekan-fontawesome
jam:offline
jam:method
jam:pub-sub
+1 -1
View File
@@ -1 +1 @@
METEOR@3.5
METEOR@1.6.0.1
+187 -120
View File
@@ -1,121 +1,188 @@
accounts-base@3.3.0
accounts-express@1.0.0
accounts-oauth@1.4.7
accounts-password@3.3.0
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.1
babel-runtime@1.5.2
base64@1.0.14
binary-heap@1.0.13
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
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
ddp-common@1.4.5
ddp-rate-limiter@1.3.0
ddp-server@3.3.0
diff-sequence@1.1.3
dynamic-import@0.7.4
ecmascript@0.18.1
ecmascript-runtime@0.8.3
ecmascript-runtime-client@0.13.0
ecmascript-runtime-server@0.11.1
ejson@1.2.0
email@3.2.0
es5-shim@4.8.1
facts-base@1.0.2
fetch@0.2.0
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
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
minifier-js@3.2.1
minimongo@2.2.0
modern-browsers@0.2.3
modules@0.20.3
modules-runtime@0.13.2
mongo@2.4.0
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
oauth2@1.3.3
observe-sequence@2.0.1
ordered-dict@1.2.0
ostrio:cookies@2.9.2
ostrio:cstorage@4.0.1
ostrio:files@3.0.1
ostrio:flow-router-extra@3.15.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
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
sha@1.0.10
shell-server@0.7.0
socket-stream-client@0.7.0
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.1
url@1.3.6
webapp@2.2.0
webapp-hashing@1.1.2
wekan-accounts-cas@0.2.0
wekan-accounts-lockout@1.1.0
3stack:presence@1.1.2
accounts-base@1.4.0
accounts-oauth@1.1.15
accounts-password@1.5.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.3
alethes:pages@1.8.6
allow-deny@1.1.0
arillo:flow-router-helpers@0.5.2
audit-argument-checks@1.0.7
autoupdate@1.3.12
babel-compiler@6.24.7
babel-runtime@1.1.1
base64@1.0.10
binary-heap@1.0.10
blaze@2.3.2
blaze-tools@1.0.10
boilerplate-generator@1.3.1
browser-policy-common@1.0.11
browser-policy-framing@1.1.0
caching-compiler@1.1.9
caching-html-compiler@1.1.2
callback-hook@1.0.10
cfs:access-point@0.1.49
cfs:base-package@0.0.30
cfs:collection@0.5.5
cfs:collection-filters@0.2.4
cfs:data-man@0.0.6
cfs:file@0.1.17
cfs:gridfs@0.0.34
cfs:http-methods@0.0.32
cfs:http-publish@0.0.13
cfs:power-queue@0.9.11
cfs:reactive-list@0.0.9
cfs:reactive-property@0.0.4
cfs:standard-packages@0.5.9
cfs:storage-adapter@0.2.3
cfs:tempstore@0.1.5
cfs:upload-http@0.0.20
cfs:worker@0.1.4
check@1.2.5
chuangbo:cookie@1.1.0
coffeescript@1.12.7_3
coffeescript-compiler@1.12.7_3
cottz:publish-relations@2.0.8
dburles:collection-helpers@1.1.0
ddp@1.4.0
ddp-client@2.2.0
ddp-common@1.3.0
ddp-rate-limiter@1.0.7
ddp-server@2.1.1
deps@1.0.12
diff-sequence@1.0.7
dynamic-import@0.2.1
ecmascript@0.9.0
ecmascript-runtime@0.5.0
ecmascript-runtime-client@0.5.0
ecmascript-runtime-server@0.5.0
ejson@1.1.0
email@1.2.3
es5-shim@4.6.15
fastclick@1.0.13
fortawesome:fontawesome@4.7.0
geojson-utils@1.0.10
horka:swipebox@1.0.2
hot-code-push@1.0.4
html-tools@1.0.11
htmljs@1.0.11
http@1.3.0
id-map@1.0.9
idmontie:migrations@1.0.3
jquery@1.11.10
kadira:blaze-layout@2.3.0
kadira:dochead@1.5.0
kadira:flow-router@2.12.1
kenton:accounts-sandstorm@0.7.0
launch-screen@1.1.1
livedata@1.0.18
localstorage@1.2.0
logging@1.1.19
lucasantoniassi:accounts-lockout@1.0.0
matb33:collection-hooks@0.8.4
matteodem:easy-search@1.6.4
mdg:meteor-apm-agent@3.1.2
mdg:validation-error@0.5.1
meteor@1.8.2
meteor-base@1.2.0
meteor-platform@1.2.6
meteorhacks:aggregate@1.3.0
meteorhacks:collection-utils@1.2.0
meteorhacks:meteorx@1.4.1
meteorhacks:picker@1.0.3
meteorhacks:subs-manager@1.6.4
meteorhacks:unblock@1.1.0
meteorspark:util@0.2.0
minifier-css@1.2.16
minifier-js@2.2.2
minifiers@1.1.8-faster-rebuild.0
minimongo@1.4.3
mixmax:smart-disconnect@0.0.4
mobile-status-bar@1.0.14
modules@0.11.0
modules-runtime@0.9.1
momentjs:moment@2.22.2
mongo@1.3.1
mongo-dev-server@1.1.0
mongo-id@1.0.6
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:jquery-ui-drag-drop-sort@0.2.0
mquandalle:moment@1.0.1
mquandalle:mousetrap-bindglobal@0.0.1
mquandalle:perfect-scrollbar@0.6.5_2
msavin:usercache@1.0.0
npm-bcrypt@0.9.3
npm-mongo@2.2.33
oauth@1.2.1
oauth2@1.2.0
observe-sequence@1.0.16
ongoworks:speakingurl@1.1.0
ordered-dict@1.0.9
peerlibrary:assert@0.2.5
peerlibrary:base-component@0.16.0
peerlibrary:blaze-components@0.15.1
peerlibrary:computed-field@0.7.0
peerlibrary:reactive-field@0.3.0
promise@0.10.0
raix:eventemitter@0.1.3
raix:handlebar-helpers@0.2.5
rajit:bootstrap3-datepicker@1.7.1
random@1.0.10
rate-limit@1.0.8
reactive-dict@1.2.0
reactive-var@1.0.11
reload@1.1.11
retry@1.0.9
routepolicy@1.0.12
rzymek:fullcalendar@3.8.0
service-configuration@1.0.11
session@1.1.7
sha@1.0.9
shell-server@0.3.1
simple:authenticate-user-by-token@1.0.1
simple:json-routes@2.1.0
simple:rest-accounts-password@1.1.2
simple:rest-bearer-token-parser@1.0.1
simple:rest-json-error-handler@1.0.1
softwarerero:accounts-t9n@1.3.11
spacebars@1.0.15
spacebars-compiler@1.1.3
srp@1.0.10
standard-minifier-css@1.3.5
standard-minifier-js@2.2.3
staringatlights:fast-render@2.16.5
staringatlights:inject-data@2.0.5
stylus@2.513.13
tap:i18n@1.8.2
templates:tabs@2.3.0
templating@1.3.2
templating-compiler@1.3.3
templating-runtime@1.3.2
templating-tools@1.1.2
tracker@1.1.3
ui@1.0.13
underscore@1.0.10
url@1.1.0
useraccounts:core@1.14.2
useraccounts:flow-routing@1.14.2
useraccounts:unstyled@1.14.2
verron:autosize@3.0.8
webapp@1.4.0
webapp-hashing@1.0.9
wekan-accounts-oidc@1.0.10
wekan-accounts-sandstorm@0.9.0
wekan-fontawesome@6.4.2
wekan-fullcalendar@5.11.5
wekan-ldap@0.1.0
wekan-markdown@1.0.9
wekan-oidc@1.1.0
zodern:types@1.0.13
wekan-markdown@1.0.7
wekan-oidc@1.0.12
wekan-scrollbar@3.1.3
wekan-accounts-cas@0.1.0
wekan-ldap@0.0.2
yasaricli:slugify@0.0.7
zimme:active-route@2.3.2
-9
View File
@@ -1,9 +0,0 @@
npm-packages/
# Root tests/ holds Playwright specs, Node E2E and node-based unit tests - none
# of it is Meteor code, and Meteor's file watcher otherwise churns on the
# tests/playwright/test-results/ tree (inotify errors). The leading slash anchors
# this to the repo root only, so it does NOT match server/lib/tests/ or
# client/lib/tests/ (the Mocha suites, loaded via meteor.testModule in
# package.json). A naked "tests/" would match a dir named tests at any depth and
# would hide those suites, making `meteor test` report "0 passing".
/tests/
-13
View File
@@ -1,13 +0,0 @@
dependencies:
- libreadline-dev
- libssl-dev
- bsdtar
targets:
ubuntu-18.04:
ubuntu-16.04:
ubuntu-14.04:
centos-6:
centos-7:
debian-10:
sles-12:
sles-11:
-8
View File
@@ -1,8 +0,0 @@
packages/
node_modules/
.build/
.meteor/
.vscode/
.tx/
.github/
.snap-meteor-1.8/
-8
View File
@@ -1,8 +0,0 @@
{
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"semi": true,
"singleQuote": true,
"trailingComma": "all"
}
+4 -4
View File
@@ -1,10 +1,10 @@
dist: focal
dist: trusty
sudo: required
env:
TRAVIS_DOCKER_COMPOSE_VERSION: 1.24.0
TRAVIS_NODE_VERSION: 14.21.3
TRAVIS_NPM_VERSION: latest
TRAVIS_DOCKER_COMPOSE_VERSION: 1.17.0
TRAVIS_NODE_VERSION: 8.9.3
TRAVIS_NPM_VERSION: 5.5.1
before_install:
- sudo apt-get update -y
+53 -7
View File
@@ -1,9 +1,55 @@
[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
# This is the configuration of the Transifex tool that we use to manage the
# translations on Wekan. Documentation at: http://docs.transifex.com/client.
#
# Push
# ====
#
# It is recommended that contributors use the Transifex web UI to create and
# edit translated strings. However in case a contributor has directly jumped
# into the code and made its translations in the corresponding i18n.json file
# we can push it using
#
# > tx push -t -l ar
#
# Where `ar` is the language identifier. In addition, the project maintainer
# should push the English source file to Transifex at least before each release
# candidate using:
#
# > tx push -s
#
# Pull
# ====
#
# The set of accepted language is directly managed in Transifex, the only
# restriction we define to bundle a new language in the application, is that its
# completion is at least at 75%.
#
# We use:
#
# > tx pull
#
# to download new versions of existing translations, and
#
# > tx pull -a --minimum-perc=75
#
# to download new sufficiently advanced translations.
[o:wekan:p:wekan:r:application]
file_filter = imports/i18n/data/<lang>.i18n.json
source_file = imports/i18n/data/en.i18n.json
[main]
host = https://www.transifex.com
# tap:i18n requires us to use `-` separator in the language identifiers whereas
# Transifex uses a `_` separator, without an option to customize it on one side
# or the other, so we need to do a Manual mapping.
lang_map = bg_BG:bg, en_GB:en-GB, es_AR:es-AR, el_GR:el, fi_FI:fi, hu_HU:hu, id_ID:id, mn_MN:mn, no:nb, lv_LV:lv, pt_BR:pt-BR, ro_RO:ro, zh_CN:zh-CN, zh_TW:zh-TW, zh_HK:zh-HK
[wekan.application]
file_filter = i18n/<lang>.i18n.json
source_lang = en
type = KEYVALUEJSON
type = KEYVALUEJSON
# We might have a dedicated second resource later to translate the “Welcome
# Board” data.
#
# [wekan.welcomeBoard]
# file_filter = private/welcomeBoard/<lang>.json
# source_lang = en
# type = KEYVALUEJSON
-57
View File
@@ -1,57 +0,0 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Meteor: Node",
"runtimeExecutable": "meteor",
"runtimeArgs": [
"--port=4000",
"--exclude-archs=web.browser.legacy,web.cordova",
"--raw-logs"
],
"env": {
"WRITABLE_PATH": "/tmp/uploads",
},
"outputCapture": "std",
"restart": true,
"timeout": 60000
},
{
"type": "chrome",
"request": "launch",
"name": "Meteor: Chrome",
"url": "http://localhost:4000",
"sourceMapPathOverrides": {
"meteor://💻app/*": "${workspaceFolder}/*"
},
"userDataDir": "${env:HOME}/.vscode/chrome"
},
{
"type": "node",
"request": "launch",
"name": "Test: Node",
"runtimeExecutable": "meteor",
"runtimeArgs": [
"test",
"--port=4040",
"--exclude-archs=web.browser.legacy,web.cordova",
"--driver-package=meteortesting:mocha",
"--settings=settings.json",
"--raw-logs"
],
"env": {
"TEST_WATCH": "1"
},
"outputCapture": "std",
"timeout": 60000
}
],
"compounds": [
{
"name": "Meteor: All",
"configurations": ["Meteor: Node", "Meteor: Chrome"]
}
]
}
+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
}
}
+14 -16243
View File
File diff suppressed because it is too large. Load diff
-27
View File
@@ -1,27 +0,0 @@
# Code of Conduct
For all code at WeKan GitHub Organization https://github.com/wekan
- For pull requests, mention only those participants that are **human**.
- All code in pull requests need to have permission already to add it to WeKan with MIT license, and will become 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
- 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
- Security issues: [SECURITY.md](SECURITY.md)
- License violations
- Anything private, sensitive or negative
## Public
- Feature Requests and Bug Reports https://github.com/wekan/wekan/issues
- Anything happy, positive, encouraging, helping, at friendly WeKan Global FOSS Community
+2 -92
View File
@@ -1,94 +1,4 @@
## About money
To get started, [please sign the Contributor License Agreement](https://www.clahub.com/agreements/wekan/wekan).
Not paid:
[Then, please read documentation at wiki](https://github.com/wekan/wekan/wiki).
- Money is not paid for these, everyone uses their own time at their own cost:
- 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:
- https://github.com/wekan/wekan/issues
Paid by customers of WeKan to WeKan:
- 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/
## Contributing Security related
For responsible security disclosure, please follow this process: [SECURITY.md](SECURITY.md)
CVE Hall of Fame is at https://wekan.fi/hall-of-fame/
## Contributing to Documentation Wiki
Fork WeKan repo https://github.com/wekan/wekan ,
edit `docs` directory content at GitHub web interface,
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).
WeKan code contributors Hall of Fame is at ChangeLog, where
GitHub usernames are mentioned with changes added:
https://github.com/wekan/wekan/blob/main/CHANGELOG.md
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:
https://github.com/wekan/wekan/tree/main/docs/DeveloperDocs
WeKan code has MIT license.
About 300 persons have contributed to WeKan, stats at:
https://www.openhub.net/p/wekan
WeKan maintainer xet7 reviews PR for typos etc before accepting to WeKan,
so that WeKan code will still work OK.
## Contributing translations
Non-English translations are contributed only at
https://app.transifex.com/wekan/wekan
When adding new features, in your PR to
https://github.com/wekan/wekan/pulls
only add new English source language strings
to https://github.com/wekan/wekan/blob/main/imports/i18n/data/en.i18n.json
Maintainer of WeKan xet7 downloads all newest
translations from Transifex and adds
them to WeKan repo before making
new release.
## About WeKan Organization https://github.com/wekan
Only xet7 has write access to WeKan Organization.
xet7 reviews all PRs before merging.
There has been over 300 contributors to WeKan, newest stats at:
https://www.openhub.net/p/wekan
+172 -160
View File
@@ -1,77 +1,44 @@
FROM ubuntu:26.04
FROM ubuntu:disco
LABEL maintainer="wekan"
LABEL org.opencontainers.image.ref.name="ubuntu"
LABEL org.opencontainers.image.version="26.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.78
ARG DEBIAN_FRONTEND=noninteractive
ENV BUILD_DEPS="apt-utils gnupg wget bzip2 g++ curl libarchive-tools build-essential git ca-certificates python3 unzip"
ENV \
# Set the environment variables (defaults where required)
# DOES NOT WORK: paxctl fix for alpine linux: https://github.com/wekan/wekan/issues/1303
# ENV BUILD_DEPS="paxctl"
ENV BUILD_DEPS="apt-utils bsdtar gnupg gosu wget curl bzip2 build-essential python3 python3-pip git ca-certificates gcc-8" \
DEBUG=false \
NODE_VERSION=v24.18.0 \
METEOR_RELEASE=METEOR@3.5-rc.2 \
NODE_VERSION=v8.16.0 \
METEOR_RELEASE=1.6.0.1 \
USE_EDGE=false \
NPM_VERSION=11.12.1 \
METEOR_EDGE=1.5-beta.17 \
NPM_VERSION=latest \
FIBERS_VERSION=2.0.0 \
ARCHITECTURE=linux-x64 \
SRC_PATH=./ \
WITH_API=true \
MONGO_OPLOG_URL="" \
RESULTS_PER_PAGE="" \
DEFAULT_BOARD_ID="" \
ACCOUNTS_LOCKOUT_KNOWN_USERS_FAILURES_BEFORE=3 \
ACCOUNTS_LOCKOUT_KNOWN_USERS_PERIOD=60 \
ACCOUNTS_LOCKOUT_KNOWN_USERS_FAILURE_WINDOW=15 \
ACCOUNTS_LOCKOUT_UNKNOWN_USERS_FAILURES_BERORE=3 \
ACCOUNTS_LOCKOUT_UNKNOWN_USERS_LOCKOUT_PERIOD=60 \
ACCOUNTS_LOCKOUT_UNKNOWN_USERS_FAILURE_WINDOW=15 \
ACCOUNTS_COMMON_LOGIN_EXPIRATION_IN_DAYS=90 \
ATTACHMENTS_UPLOAD_EXTERNAL_PROGRAM="" \
ATTACHMENTS_UPLOAD_MIME_TYPES="" \
ATTACHMENTS_UPLOAD_MAX_SIZE=0 \
AVATARS_UPLOAD_EXTERNAL_PROGRAM="" \
AVATARS_UPLOAD_MIME_TYPES="" \
AVATARS_UPLOAD_MAX_SIZE=72000 \
RICHER_CARD_COMMENT_EDITOR=false \
CARD_OPENED_WEBHOOK_ENABLED=false \
MAX_IMAGE_PIXEL="" \
IMAGE_COMPRESS_RATIO="" \
NOTIFICATION_TRAY_AFTER_READ_DAYS_BEFORE_REMOVE="" \
BIGEVENTS_PATTERN=NONE \
NOTIFY_ON_ASSIGN="true" \
NOTIFY_DUE_DAYS_BEFORE_AND_AFTER="" \
NOTIFY_DUE_AT_HOUR_OF_DAY="" \
EMAIL_NOTIFICATION_TIMEOUT=30000 \
MATOMO_ADDRESS="" \
MATOMO_SITE_ID="" \
MATOMO_DO_NOT_TRACK=true \
MATOMO_WITH_USERNAME=false \
METRICS_ALLOWED_IP_ADDRESSES="" \
BROWSER_POLICY_ENABLED=true \
TRUSTED_URL="" \
WEBHOOKS_ATTRIBUTES="" \
OAUTH2_ENABLED=false \
OIDC_REDIRECTION_ENABLED=false \
OAUTH2_CA_CERT="" \
OAUTH2_ADFS_ENABLED=false \
OAUTH2_B2C_ENABLED=false \
OAUTH2_LOGIN_STYLE=redirect \
OAUTH2_CLIENT_ID="" \
OAUTH2_SECRET="" \
OAUTH2_SECRET_FILE="" \
OAUTH2_SERVER_URL="" \
OAUTH2_AUTH_ENDPOINT="" \
OAUTH2_USERINFO_ENDPOINT="" \
OAUTH2_TOKEN_ENDPOINT="" \
OAUTH2_LOGOUT_ENDPOINT="" \
OAUTH2_ID_MAP="" \
OAUTH2_USERNAME_MAP="" \
OAUTH2_AUTO_REGISTRATION="true" \
OAUTH2_ADMIN_GROUPS="" \
OAUTH2_FULLNAME_MAP="" \
OAUTH2_ID_TOKEN_WHITELIST_FIELDS="" \
OAUTH2_REQUEST_PERMISSIONS='openid profile email' \
@@ -79,9 +46,6 @@ ENV \
LDAP_ENABLE=false \
LDAP_PORT=389 \
LDAP_HOST="" \
LDAP_AD_SIMPLE_AUTH="" \
LDAP_USER_AUTHENTICATION=false \
LDAP_USER_AUTHENTICATION_FIELD=uid \
LDAP_BASEDN="" \
LDAP_LOGIN_FALLBACK=false \
LDAP_RECONNECT=true \
@@ -91,16 +55,15 @@ 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="" \
LDAP_BACKGROUND_SYNC_INTERVAL=100 \
LDAP_BACKGROUND_SYNC_KEEP_EXISTANT_USERS_UPDATED=false \
LDAP_BACKGROUND_SYNC_IMPORT_NEW_USERS=false \
LDAP_BACKGROUND_SYNC_DISABLE_NONEXISTANT_USERS=false \
LDAP_ENCRYPTION=false \
LDAP_CA_CERT="" \
LDAP_REJECT_UNAUTHORIZED=false \
LDAP_USER_AUTHENTICATION=false \
LDAP_USER_SEARCH_FILTER="" \
LDAP_USER_SEARCH_SCOPE="" \
LDAP_USER_SEARCH_FIELD="" \
@@ -127,16 +90,10 @@ ENV \
LDAP_DEFAULT_DOMAIN="" \
LDAP_SYNC_ADMIN_STATUS="" \
LDAP_SYNC_ADMIN_GROUPS="" \
LDAP_SYNC_ORGANIZATIONS=false \
LDAP_SYNC_ORGANIZATIONS_GROUPS="" \
LDAP_SYNC_TEAMS=false \
LDAP_SYNC_TEAMS_GROUPS="" \
HEADER_LOGIN_ID="" \
HEADER_LOGIN_FIRSTNAME="" \
HEADER_LOGIN_LASTNAME="" \
HEADER_LOGIN_EMAIL="" \
HEADER_LOGIN_TRUSTED_IPS="" \
HEADER_LOGIN_TRUSTED_PROXIES="" \
LOGOUT_WITH_TIMER=false \
LOGOUT_IN="" \
LOGOUT_ON_HOURS="" \
@@ -144,113 +101,168 @@ ENV \
CORS="" \
CORS_ALLOW_HEADERS="" \
CORS_EXPOSE_HEADERS="" \
DEFAULT_AUTHENTICATION_METHOD="" \
PASSWORD_LOGIN_ENABLED=true \
CAS_ENABLED=false \
CAS_BASE_URL="" \
CAS_LOGIN_URL="" \
CAS_VALIDATE_URL="" \
SAML_ENABLED=false \
SAML_PROVIDER="" \
SAML_ENTRYPOINT="" \
SAML_ISSUER="" \
SAML_CERT="" \
SAML_IDPSLO_REDIRECTURL="" \
SAML_PRIVATE_KEYFILE="" \
SAML_PUBLIC_CERTFILE="" \
SAML_IDENTIFIER_FORMAT="" \
SAML_LOCAL_PROFILE_MATCH_ATTRIBUTE="" \
SAML_ATTRIBUTES="" \
ORACLE_OIM_ENABLED=false \
WAIT_SPINNER="" \
WRITABLE_PATH=/data \
S3="" \
MAIL_SERVICE_PASSWORD_FILE="" \
MONGO_PASSWORD_FILE="" \
S3_SECRET_FILE=""
DEFAULT_AUTHENTICATION_METHOD=""
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
# Copy the app to the image
COPY ${SRC_PATH} /home/wekan/app
# 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
RUN \
set -o xtrace && \
# Add non-root user wekan
useradd --user-group --system --home-dir /home/wekan wekan && \
\
# OS dependencies
apt-get update -y && apt-get install -y --no-install-recommends ${BUILD_DEPS} && \
pip3 install -U pip setuptools wheel && \
\
# 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) && \
\
# Download nodejs
wget https://nodejs.org/dist/${NODE_VERSION}/node-${NODE_VERSION}-${ARCHITECTURE}.tar.gz && \
wget https://nodejs.org/dist/${NODE_VERSION}/SHASUMS256.txt.asc && \
#---------------------------------------------------------------------------------------------
# Node Fibers 100% CPU usage issue:
# https://github.com/wekan/wekan-mongodb/issues/2#issuecomment-381453161
# https://github.com/meteor/meteor/issues/9796#issuecomment-381676326
# https://github.com/sandstorm-io/sandstorm/blob/0f1fec013fe7208ed0fd97eb88b31b77e3c61f42/shell/server/00-startup.js#L99-L129
# Also see beginning of wekan/server/authentication.js
# import Fiber from "fibers";
# Fiber.poolSize = 1e9;
# OLD: Download node version 8.12.0 prerelease that has fix included, => Official 8.12.0 has been released
# Description at https://releases.wekan.team/node.txt
#wget https://releases.wekan.team/node-${NODE_VERSION}-${ARCHITECTURE}.tar.gz && \
#echo "1ed54adb8497ad8967075a0b5d03dd5d0a502be43d4a4d84e5af489c613d7795 node-v8.12.0-linux-x64.tar.gz" >> SHASUMS256.txt.asc && \
\
# Verify nodejs authenticity
grep ${NODE_VERSION}-${ARCHITECTURE}.tar.gz SHASUMS256.txt.asc | shasum -a 256 -c - && \
#export GNUPGHOME="$(mktemp -d)" && \
#\
# Try other key servers if ha.pool.sks-keyservers.net is unreachable
# Code from https://github.com/chorrell/docker-node/commit/2b673e17547c34f17f24553db02beefbac98d23c
# gpg keys listed at https://github.com/nodejs/node#release-team
# and keys listed here from previous version of this Dockerfile
#for key in \
#9554F04D7259F04124DE6B476D5A82AC7E37093B \
#94AE36675C464D64BAFA68DD7434390BDBE9B9C5 \
#FD3A5288F042B6850C66B31F09FE44734EB7990E \
#71DCFD284A79C3B38668286BC97EC7A07EDE3FC1 \
#DD8F2338BAE7501E3DD5AC78C273792F7D83545D \
#C4F0DFFF4E8C1A8236409D08E73BC641CC11F4C8 \
#B9AE9905FFD7803F25714661B63B535A4C206CA9 \
#; do \
#gpg --keyserver ha.pool.sks-keyservers.net --recv-keys "$key" || \
#gpg --keyserver pgp.mit.edu --recv-keys "$key" || \
#gpg --keyserver keyserver.pgp.com --recv-keys "$key" ; \
#done && \
#gpg --verify SHASUMS256.txt.asc && \
# Ignore socket files then delete files then delete directories
#find "$GNUPGHOME" -type f | xargs rm -f && \
#find "$GNUPGHOME" -type d | xargs rm -fR && \
rm -f SHASUMS256.txt.asc && \
\
# Install Node
tar xvzf node-${NODE_VERSION}-${ARCHITECTURE}.tar.gz && \
rm node-${NODE_VERSION}-${ARCHITECTURE}.tar.gz && \
mv node-${NODE_VERSION}-${ARCHITECTURE} /opt/nodejs && \
ln -s /opt/nodejs/bin/node /usr/bin/node && \
ln -s /opt/nodejs/bin/npm /usr/bin/npm && \
\
#DOES NOT WORK: paxctl fix for alpine linux: https://github.com/wekan/wekan/issues/1303
#paxctl -mC `which node` && \
\
# Install Node dependencies
npm install -g npm@${NPM_VERSION} && \
npm install -g node-gyp && \
npm install -g fibers@${FIBERS_VERSION} && \
\
# Change user to wekan and install meteor
cd /home/wekan/ && \
chown wekan:wekan --recursive /home/wekan && \
curl "https://install.meteor.com" -o /home/wekan/install_meteor.sh && \
#curl "https://install.meteor.com/?release=${METEOR_RELEASE}" -o /home/wekan/install_meteor.sh && \
# OLD: sed -i "s|RELEASE=.*|RELEASE=${METEOR_RELEASE}\"\"|g" ./install_meteor.sh && \
# Install Meteor forcing its progress
sed -i 's/VERBOSITY="--silent"/VERBOSITY="--progress-bar"/' ./install_meteor.sh && \
echo "Starting meteor ${METEOR_RELEASE} installation... \n" && \
chown wekan:wekan /home/wekan/install_meteor.sh && \
\
# Check if opting for a release candidate instead of major release
if [ "$USE_EDGE" = false ]; then \
gosu wekan:wekan sh /home/wekan/install_meteor.sh; \
else \
gosu wekan:wekan git clone --recursive --depth 1 -b release/METEOR@${METEOR_EDGE} git://github.com/meteor/meteor.git /home/wekan/.meteor; \
fi; \
\
# Get additional packages
#mkdir -p /home/wekan/app/packages && \
#chown wekan:wekan --recursive /home/wekan && \
# REPOS BELOW ARE INCLUDED TO WEKAN REPO
#cd /home/wekan/app/packages && \
#gosu wekan:wekan git clone --depth 1 -b master https://github.com/wekan/flow-router.git kadira-flow-router && \
#gosu wekan:wekan git clone --depth 1 -b master https://github.com/meteor-useraccounts/core.git meteor-useraccounts-core && \
#gosu wekan:wekan git clone --depth 1 -b master https://github.com/wekan/meteor-accounts-cas.git && \
#gosu wekan:wekan git clone --depth 1 -b master https://github.com/wekan/wekan-ldap.git && \
#gosu wekan:wekan git clone --depth 1 -b master https://github.com/wekan/wekan-scrollbar.git && \
#gosu wekan:wekan git clone --depth 1 -b master https://github.com/wekan/meteor-accounts-oidc.git && \
#gosu wekan:wekan git clone --depth 1 -b master --recurse-submodules https://github.com/wekan/markdown.git && \
#gosu wekan:wekan mv meteor-accounts-oidc/packages/switch_accounts-oidc wekan-accounts-oidc && \
#gosu wekan:wekan mv meteor-accounts-oidc/packages/switch_oidc wekan-oidc && \
#gosu wekan:wekan rm -rf meteor-accounts-oidc && \
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; \
\
# extract the OpenAPI specification
npm install -g api2html@0.3.3 && \
mkdir -p /home/wekan/python && \
chown wekan:wekan --recursive /home/wekan/python && \
cd /home/wekan/python && \
gosu wekan:wekan git clone --depth 1 -b master https://github.com/Kronuz/esprima-python && \
cd /home/wekan/python/esprima-python && \
python3 setup.py install --record files.txt && \
cd /home/wekan/app &&\
gosu wekan:wekan mkdir -p ./public/api && \
gosu wekan:wekan python3 ./openapi/generate_openapi.py --release $(git describe --tags --abbrev=0) > ./public/api/wekan.yml && \
gosu wekan:wekan /opt/nodejs/bin/api2html -c ./public/logo-header.png -o ./public/api/wekan.html ./public/api/wekan.yml; \
# Build app
cd /home/wekan/app && \
gosu wekan:wekan /home/wekan/.meteor/meteor add standard-minifier-js && \
gosu wekan:wekan /home/wekan/.meteor/meteor npm install && \
gosu wekan:wekan /home/wekan/.meteor/meteor build --directory /home/wekan/app_build && \
cp /home/wekan/app/fix-download-unicode/cfs_access-point.txt /home/wekan/app_build/bundle/programs/server/packages/cfs_access-point.js && \
rm /home/wekan/app_build/bundle/programs/server/npm/node_modules/meteor/rajit_bootstrap3-datepicker/lib/bootstrap-datepicker/node_modules/phantomjs-prebuilt/lib/phantom/bin/phantomjs && \
chown wekan:wekan /home/wekan/app_build/bundle/programs/server/packages/cfs_access-point.js && \
#Removed binary version of bcrypt because of security vulnerability that is not fixed yet.
#https://github.com/wekan/wekan/commit/4b2010213907c61b0e0482ab55abb06f6a668eac
#https://github.com/wekan/wekan/commit/7eeabf14be3c63fae2226e561ef8a0c1390c8d3c
#cd /home/wekan/app_build/bundle/programs/server/npm/node_modules/meteor/npm-bcrypt && \
#gosu wekan:wekan rm -rf node_modules/bcrypt && \
#gosu wekan:wekan npm install bcrypt && \
cd /home/wekan/app_build/bundle/programs/server/ && \
gosu wekan:wekan npm install && \
#gosu wekan:wekan npm install bcrypt && \
mv /home/wekan/app_build/bundle /build && \
\
# Put back the original tar
mv $(which tar)~ $(which tar) && \
\
# Cleanup
apt-get remove --purge -y ${BUILD_DEPS} && \
apt-get autoremove -y && \
npm uninstall -g api2html &&\
rm -R /var/lib/apt/lists/* && \
rm -R /home/wekan/.meteor && \
rm -R /home/wekan/app && \
rm -R /home/wekan/app_build && \
cat /home/wekan/python/esprima-python/files.txt | xargs rm -R && \
rm -R /home/wekan/python && \
rm /home/wekan/install_meteor.sh
# OS Updates
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
cp $(which tar) $(which tar)~
ln -sf $(which bsdtar) $(which tar)
# WeKan Bundle Installation
mkdir -p /home/wekan/app
cd /home/wekan/app
# Retry the release-asset download: even though the CI `docker` job needs the
# `release` job (so the asset is already uploaded), GitHub's
# releases/download/<tag>/<asset> URL can briefly return 404 right after upload
# (CDN/propagation lag). A plain wget treats 404 as fatal, which failed the
# build; retry on transient HTTP errors so propagation lag no longer breaks it.
WEKAN_ZIP_URL="https://github.com/wekan/wekan/releases/download/v${VERSION}/wekan-${VERSION}-${WEKAN_ARCH}.zip"
wget --tries=20 --waitretry=20 --retry-on-http-error=404,403,500,502,503 "${WEKAN_ZIP_URL}" \
|| { echo "Failed to download ${WEKAN_ZIP_URL} after retries"; exit 8; }
unzip "wekan-${VERSION}-${WEKAN_ARCH}.zip"
rm "wekan-${VERSION}-${WEKAN_ARCH}.zip"
npm install --prefix ./bundle/programs/server
mv /home/wekan/app/bundle /build
# Restore 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}
apt-get autoremove --assume-yes
apt-get clean --assume-yes
rm -Rf /tmp/*
rm -Rf /var/lib/apt/lists/*
rm -Rf /home/wekan/app
mkdir -p /data
chown wekan:wekan --recursive /data
EOR
USER wekan
ENV PORT=8080
EXPOSE $PORT
STOPSIGNAL SIGKILL
WORKDIR /build
USER wekan
CMD ["bash", "-c", "ulimit -s 65500; exec node main.js"]
CMD ["node", "/build/main.js"]
-40
View File
@@ -1,40 +0,0 @@
# Future
## Moved Import/Export/Sync issues to Big Picture Roadmap wiki page
This change is limited to only Import/Export/Sync issues, while those are In Progress of being fixed.
2023-11-21 xet7 closed 261 issues that are linked at https://github.com/wekan/wekan/wiki/Sync ,
that is Roadmap of Import/Export/Sync in WeKan. It means, that those issues progress will be
updated at that wiki page, when xet7 and other WeKan contributors fix those.
Many of those issues are In Progress of being fixed and added.
## Platform Updates
Issues related to platforms are being closed, because only list of working platforms is mentioned now
at WeKan website https://wekan.github.io Install section and at [ChangeLog](https://github.com/wekan/wekan/blob/main/CHANGELOG.md)
where is this new text:
> Newest WeKan at amd64 platforms: Linux bundle, Snap Candidate, Docker, Kubernetes. Fixing other platforms In Progress.
Platform support changes often, because:
- There are many dependencies, that update or break or change often
- Node.js segfaults at some CPU/OS
- Some platforms have build errors
Roadmap is to update all existing platforms, and add more platforms.
Upcoming platform upgrades:
- Fix migrations, so that newest WeKan can be released to Snap Stable. (Currently newest is at Snap Candidate).
## WeKan features
Most Meteor WeKan features are listed here:
https://github.com/wekan/wekan/wiki/Deep-Dive-Into-WeKan
Remaining features and all changes are listed here:
https://github.com/wekan/wekan/blob/main/CHANGELOG.md
-10
View File
@@ -1,10 +0,0 @@
# Governance
Anyone can send pull request to https://github.com/wekan/wekan/wiki/pulls ,
if there is permission to add code to WeKan with MIT license.
As maintainer, xet7 checks all pull requests and merges them.
Only xet7 has write access to repo https://github.com/wekan/wekan
+1 -1
View File
@@ -1,6 +1,6 @@
The MIT License (MIT)
Copyright (c) Lauri Ojansivu
Copyright (c) 2014-2019 The Wekan Team
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+86 -141
View File
@@ -1,59 +1,42 @@
# WeKan ® - Open Source kanban
# Wekan - Open Source kanban
## Downloads
[![Contributors](https://img.shields.io/github/contributors/wekan/wekan.svg "Contributors")](https://github.com/wekan/wekan/graphs/contributors)
[![Docker Repository on Quay](https://quay.io/repository/wekan/wekan/status "Docker Repository on Quay")](https://quay.io/repository/wekan/wekan)
[![Docker Hub container status](https://img.shields.io/docker/build/wekanteam/wekan.svg "Docker Hub container status")](https://hub.docker.com/r/wekanteam/wekan)
[![Docker Hub pulls](https://img.shields.io/docker/pulls/wekanteam/wekan.svg "Docker Hub Pulls")](https://hub.docker.com/r/wekanteam/wekan)
[![Wekan Build Status][travis_badge]][travis_status]
[![Codacy Badge](https://api.codacy.com/project/badge/Grade/02137ecec4e34c5aa303f57637196a93 "Codacy Badge")](https://www.codacy.com/app/xet7/wekan?utm_source=github.com&amp;utm_medium=referral&amp;utm_content=wekan/wekan&amp;utm_campaign=Badge_Grade)
[![Code Climate](https://codeclimate.com/github/wekan/wekan/badges/gpa.svg "Code Climate")](https://codeclimate.com/github/wekan/wekan)
[![Project Dependencies](https://david-dm.org/wekan/wekan.svg "Project Dependencies")](https://david-dm.org/wekan/wekan)
[![Code analysis at Open Hub](https://img.shields.io/badge/code%20analysis-at%20Open%20Hub-brightgreen.svg "Code analysis at Open Hub")](https://www.openhub.net/p/wekan)
[![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2Fwekan%2Fwekan.svg?type=shield)](https://app.fossa.io/projects/git%2Bgithub.com%2Fwekan%2Fwekan?ref=badge_shield)
https://wekan.fi/install/
## [Translate Wekan at Transifex](https://transifex.com/wekan/wekan)
## Docker Containers
Translations to non-English languages are accepted only at [Transifex](https://transifex.com/wekan/wekan) using webbrowser.
New English strings of new features can be added as PRs to edge branch file wekan/i18n/en.i18n.json .
- [GitHub](https://github.com/wekan/wekan/pkgs/container/wekan)
- [Quay](https://quay.io/repository/wekan/wekan)
- [Docker Hub](https://hub.docker.com/r/wekanteam/wekan)
## [Wekan feature requests and bugs](https://github.com/wekan/wekan/issues)
docker-compose.yml at https://github.com/wekan/wekan/blob/main/docker-compose.yml
## Standards
- [WeKan and Standard for Public Code](https://wekan.fi/standard-for-public-code/) assessment was made at 2023-11.
Currently Wekan meets 8 out of 16 criteria out of the box.
Some others could be met with small changes.
## Code stats
- [CII Best Practices](https://bestpractices.coreinfrastructure.org/projects/4619)
- [Code Climate](https://codeclimate.com/github/wekan/wekan)
- [Open Hub](https://www.openhub.net/p/wekan)
- [OSS Insight](https://ossinsight.io/analyze/wekan/wekan)
## Translate WeKan ® at Transifex
Translations to non-English languages are accepted only at [Transifex](https://app.transifex.com/wekan/wekan) using webbrowser.
New English strings of new features can be added as PRs to master branch file wekan/imports/i18n/data/en.i18n.json .
## WeKan ® feature requests and bugs
Please add most of your questions as GitHub issue: [WeKan ® Feature Requests and Bugs](https://github.com/wekan/wekan/issues).
Please add most of your questions as GitHub issue: [Wekan feature requests and bugs](https://github.com/wekan/wekan/issues).
It's better than at chat where details get lost when chat scrolls up.
## Discussions
## Chat
[IRC](https://github.com/wekan/wekan/blob/main/docs/FAQ/IRC-FAQ.md)
[![Wekan Chat][vanila_badge]][wekan_chat] - Most Wekan community and developers are here. Works on webbrowser
and PWA app that can be added as icon on Android and bookmark on iOS, used like native app.
## Docker: Latest tag has newest release
You can use latest tag to get newest release tag.
See bottom of https://github.com/wekan/wekan/issues/3874
[Wekan IRC FAQ](https://github.com/wekan/wekan/wiki/IRC-FAQ)
## FAQ
**NOTE**:
**NOTE**:
- Please read the [FAQ](https://github.com/wekan/wekan/wiki/FAQ) first
- Please don't feed the trolls and spammers that are mentioned in the FAQ :)
- 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
## 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,
@@ -61,134 +44,96 @@ or working in a team on your next revolutionary idea, Kanban boards are an unbea
to keep your things organized. They give you a visual overview of the current state of your project,
and make you productive by allowing you to focus on the few items that matter the most.
Since WeKan ® is a free software, you don’t have to trust us with your data and can
Since Wekan is a free software, you don’t have to trust us with your data and can
install Wekan on your own computer or server. In fact we encourage you to do
that by providing one-click installation on various platforms.
- WeKan ® is used in [most countries of the world](https://snapcraft.io/wekan).
- WeKan ® largest user has 30k users using WeKan ® in their company.
- WeKan ® has been [translated](https://app.transifex.com/wekan/) to about 105 languages.
- [Features][features]: WeKan ® has real-time user interface.
- [Platforms](https://wekan.fi/install/): WeKan ® supports many platforms.
WeKan ® is critical part of new platforms Wekan is currently being integrated to.
- Wekan is used in [most countries of the world](https://snapcraft.io/wekan).
- Wekan largest user has 13k users using Wekan in their company.
- Wekan has been [translated](https://transifex.com/wekan/wekan) to about 50 languages.
- [Features][features]: Wekan has real-time user interface.
- [Platforms][platforms]: Wekan supports many platforms.
Wekan is critical part of new platforms Wekan is currently being integrated to.
- [Integrations][integrations]: Current possible integrations and future plans.
## Requirements
- 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.
- 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.
- 64bit: Linux [Snap](https://github.com/wekan/wekan-snap/wiki/Install) or [Sandstorm](https://sandstorm.io) /
[Mac](https://github.com/wekan/wekan/wiki/Mac) / [Windows](https://github.com/wekan/wekan/wiki/Install-Wekan-from-source-on-Windows).
[More Platforms](https://github.com/wekan/wekan/wiki/Platforms). [ARM progress](https://github.com/wekan/wekan/issues/1053#issuecomment-410919264).
- 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/devel/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 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.
New features and fixes are added to Wekan [many times a day](https://github.com/wekan/wekan/blob/devel/CHANGELOG.md).
- [Backups](https://github.com/wekan/wekan/wiki/Backup) 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
## Roadmap
[Roadmap][roadmap_wekan] - Public read-only board at WeKan ® demo.
[Roadmap Milestones](https://github.com/wekan/wekan/milestones)
[Developer Documentation][dev_docs]
- There are 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).
- 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/devel/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/).
- [Commercial Support](https://wekan.team).
- [Bounties](https://wekan.team/bounties/index.html).
We also welcome sponsors for features and bugfixes.
By working directly with WeKan ® you get the benefit of active maintenance and new features added by growing WeKan ® developer community.
By working directly with Wekan you get the benefit of active maintenance and new features added by growing Wekan developer community.
## Getting Started with Development
## Demo
The default branch uses Meteor 3.5-rc.2 with Node.js 24.x.
See [CHANGELOG.md](https://github.com/wekan/wekan/blob/main/CHANGELOG.md) for the latest runtime updates.
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.
[Wekan demo][roadmap_wefork]
## Screenshot
[More screenshots at Features page](https://github.com/wekan/wekan/tree/main/docs/Features)
[More screenshots at Features page](https://github.com/wekan/wekan/wiki/Features)
[![Screenshot of WeKan ®][screenshot_wekan]][roadmap_wekan]
[![Screenshot of Wekan][screenshot_wefork]][roadmap_wefork]
## Stable
- master+devel branch. At release, devel is merged to master.
- Receives fixes and features that have been tested at edge that they work.
- If you want automatic updates, [use Snap](https://github.com/wekan/wekan-snap/wiki/Install).
- If you want to test before update, [use Docker quay.io release tags](https://github.com/wekan/wekan/wiki/Docker).
## Edge
- edge branch. All new fixes and features are added to here first. [Testing Edge](https://github.com/wekan/wekan-snap/wiki/Snap-Developer-Docs).
## License
WeKan ® is released under the very permissive [MIT license](LICENSE), and made
Wekan is released under the very permissive [MIT license](LICENSE), and made
with [Meteor](https://www.meteor.com).
[dev_docs]: https://github.com/wekan/wekan/blob/main/docs/DeveloperDocs/Developer-Documentation.md
[screenshot_wekan]: https://wekan.fi/wekan-dark-mode.png
[platforms]: https://github.com/wekan/wekan/wiki/Platforms
[dev_docs]: https://github.com/wekan/wekan/wiki/Developer-Documentation
[screenshot_wekan]: http://i.imgur.com/cI4jW2h.png
[screenshot_wefork]: https://wekan.github.io/wekan-markdown.png
[features]: https://github.com/wekan/wekan/wiki/Features
[roadmap_wekan]: https://boards.wekan.team/b/D2SzJKZDS4Z48yeQH/wekan-open-source-kanban-board-with-mit-license
[integrations]: https://github.com/wekan/wekan/wiki/Integrations
[roadmap_wekan]: http://try.wekan.io/b/MeSsFJaSqeuo9M6bs/wekan-roadmap
[roadmap_wefork]: https://wekan.indie.host/b/t2YaGmyXgNkppcFBq/wekan-fork-roadmap
[wekan_issues]: https://github.com/wekan/wekan/issues
[wefork_issues]: https://github.com/wefork/wekan/issues
[docker_image]: https://hub.docker.com/r/wekanteam/wekan/
[translate_wekan]: https://app.transifex.com/wekan/wekan/
[travis_badge]: https://travis-ci.org/wekan/wekan.svg?branch=devel
[travis_status]: https://travis-ci.org/wekan/wekan
[wekan_wiki]: https://github.com/wekan/wekan/wiki
[translate_wekan]: https://www.transifex.com/wekan/wekan/
[open_source]: https://en.wikipedia.org/wiki/Open-source_software
[free_software]: https://en.wikipedia.org/wiki/Free_software
[vanila_badge]: https://vanila.io/img/join-chat-button2.png
[wekan_chat]: https://community.vanila.io/wekan
[![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2Fwekan%2Fwekan.svg?type=large)](https://app.fossa.io/projects/git%2Bgithub.com%2Fwekan%2Fwekan?ref=badge_large)
-113
View File
@@ -1,113 +0,0 @@
# 🔐 WeKan — Login System Overview
This document provides a detailed overview of WeKan’s **login and authentication system**, covering client-side UI, server-side logic, external authentication methods, and potential upgrade paths.
---
## 🖥️ Login Web UI
WeKan's login interface is implemented using a combination of:
- `layouts.jade` – Login HTML structure
- `layouts.js` – Login logic and interactivity
- `layouts.css` – Styling and layout
📁 Source: [`client/components/main`](https://github.com/wekan/wekan/tree/main/client/components/main)
---
## ⚙️ Server-Side Authentication
Server-side login functionality is handled in:
- [`server/authentication.js`](https://github.com/wekan/wekan/blob/main/server/authentication.js)
Other related configurations:
- 🔧 Account config: [`config/accounts.js`](https://github.com/wekan/wekan/blob/main/config/accounts.js)
- 📨 Sign-up invitations: [`models/settings.js#L275`](https://github.com/wekan/wekan/blob/main/models/settings.js#L275)
- 👤 User creation logic: [`models/users.js#L1339`](https://github.com/wekan/wekan/blob/main/models/users.js#L1339)
---
## 👥 Meteor User Accounts
WeKan utilizes Meteor’s `accounts` system. Relevant resources:
- 📚 Meteor 2.x Accounts Docs: [v2-docs.meteor.com/api/accounts](https://v2-docs.meteor.com/api/accounts)
- 🔍 Meteor Packages:
- [`packages`](https://github.com/wekan/wekan/blob/main/.meteor/packages)
- [`versions`](https://github.com/wekan/wekan/blob/main/.meteor/versions)
- 📦 Meteor 2.14 core packages: [Meteor 2.14 packages](https://github.com/meteor/meteor/tree/release/METEOR%402.14/packages)
---
## 🔐 External Authentication (OIDC, LDAP, etc.)
WeKan supports external authentication methods via internal packages.
📁 See [`packages/`](https://github.com/wekan/wekan/tree/main/packages) for:
- OpenID Connect (OIDC)
- LDAP
- OAuth and other integrations
---
## 📦 NPM & AtmosphereJS Dependencies
- 🔗 `package.json`: [Dependencies list](https://github.com/wekan/wekan/blob/main/package.json)
- 🧩 WekanTeam scoped NPM packages: [@wekanteam on npm](https://www.npmjs.com/search?q=%40wekanteam)
- ☁️ AtmosphereJS Meteor packages: [atmospherejs.com](https://atmospherejs.com)
---
## 🚧 Meteor Version & Upgrade Notes
- 📌 Current Version: **Meteor 2.14**
- [`.meteor/release`](https://github.com/wekan/wekan/blob/main/.meteor/release)
- 🔧 Maintained with only **critical fixes** until ~Summer 2025
- 🚀 Migration to **Meteor 3** or a new framework is under consideration
📘 Meteor 3 API: [docs.meteor.com/api/accounts](https://docs.meteor.com/api/accounts)
---
## 🧪 Prototypes & Examples
### 🐘 PHP Prototype Sign-Up
Used in experimental versions:
- Step 1: [`sign-up1.php`](https://github.com/wekan/php/blob/main/page/sign-up1.php)
- Step 2: [`sign-up2.php`](https://github.com/wekan/php/blob/main/page/sign-up2.php)
- Main entry: [`index.php#L72-L83`](https://github.com/wekan/php/blob/main/public/index.php#L72-L83)
---
### 🎨 WeKan Studio Prototype
Sign-up logic in the **WeKan Studio** version:
- [`signUp.fmt`](https://github.com/wekan/wekanstudio/blob/main/srv/templates/login/signUp.fmt)
---
## 📎 Future Considerations
- Upgrading to **Meteor 3.x**
- Refactoring frontend logic to fix translation rendering order
- Exploring **simplified authentication systems** in future prototypes
---
## 🔗 Project Links
- 🔧 Main Repo: [github.com/wekan/wekan](https://github.com/wekan/wekan)
- 🌐 Website: [wekan.github.io](https://wekan.github.io)
- 📚 Documentation: [Wekan Wiki](https://github.com/wekan/wekan/wiki)
---
---
+71 -231
View File
@@ -1,34 +1,12 @@
## About Money: No bounty
Security is very important to us. If you discover any issue regarding security, please disclose
the information responsibly by sending an email to security (at) wekan.team and not by
creating a GitHub issue. We will respond swiftly to fix verifiable security issues.
There is no bounty, like described at [CONTRIBUTING.md](CONTRIBUTING.md),
because WeKan is NOT Big Tech. WeKan is FLOSS.
We thank you with a place at our hall of fame page, that is
at https://wekan.github.io/hall-of-fame . Others have just posted public GitHub issue,
so they are not at that hall-of-fame page.
## Steps for Coordinated Vulnerability Disclosure
### 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,227 +24,72 @@ 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,
and we don't have any permissions to do security scans on those donated servers.
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 by companies that have [thousands of users](https://github.com/wekan/wekan/wiki/AWS) and at healthcare.
- 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.
- Admin Panel has feature to disable all public boards, so all boards are private.
Wekan uses xss package for input fields like cards, as you can see from
[package.json](https://github.com/wekan/wekan/blob/devel/package.json). Other used versions can be seen from
[Meteor versions file](https://github.com/wekan/wekan/blob/devel/.meteor/versions).
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.
## SSL/TLS
Wekan attachments are not accessible without logging in. Import from Trello works by copying
Trello export JSON to Wekan Trello import page, and in Trello JSON file there is direct links to all publicly
accessible Trello attachment files, that Standalone Wekan downloads directly to Wekan MongoDB database in
[CollectionFS](https://github.com/wekan/wekan/pull/875) format. When Wekan board is exported in
Wekan JSON format, all board attachments are included in Wekan JSON file as base64 encoded text.
That Wekan JSON format file can be imported to Sandstorm Wekan with all the attachments, when we get
latest Wekan version working on Sandstorm, only couple of bugs are left before that. In Sandstorm it's not
possible yet to import from Trello with attachments, because Wekan does not implement Sandstorm-compatible
access to outside of Wekan grain.
- SSL/TLS encrypts traffic between webbrowser and webserver.
- 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.
Standalone Wekan only has password auth currently, there is work in progress to add
[oauth2](https://github.com/wekan/wekan/pull/1578), [Openid](https://github.com/wekan/wekan/issues/538),
[LDAP](https://github.com/wekan/wekan/issues/119) etc. If you need more login security for Standalone Wekan now,
it's possible add additional [Google Auth proxybouncer](https://github.com/wekan/wekan/wiki/Let's-Encrypt-and-Google-Auth) in front of password auth, and then use Google Authenticator for Google Auth. Standalone Wekan does have [brute force protection with eluck:accounts-lockout and browser-policy clickjacking protection](https://github.com/wekan/wekan/blob/devel/CHANGELOG.md#v080-2018-04-04-wekan-release). You can also optionally use some [WAF](https://en.wikipedia.org/wiki/Web_application_firewall)
like for example [AWS WAF](https://aws.amazon.com/waf/).
## XSS
[All Wekan Platforms](https://github.com/wekan/wekan/wiki/Platforms)
- 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 .
- 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/
- Reaction in comment is now checked, that it does not have extra added code
- https://wekan.fi/hall-of-fame/reactionbleed/
- https://github.com/wekan/wekan/blob/main/packages/markdown/src/template-integration.js#L76
### Sandstorm Wekan Security
## QA About PubSub
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.
Q:
Hello,
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?
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.
- 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.
- 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
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
- 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
- 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,
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
## PubSub: Fix that user can not change to Admin
- 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/
## 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.
- 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.
- 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()) {`
- Check is code running at server `if (Meteor.isServer()) {` or client `if Meteor.isClient()) {` .
- Here is some authentication code https://github.com/wekan/wekan/blob/main/server/authentication.js
## 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
```
Meteor.startup(() => {
if (process.env.HEADER_LOGIN_ID) {
Meteor.settings.public.attachmentsUploadMaxSize = process.env.ATTACHMENTS_UPLOAD_MAX_SIZE;
Meteor.settings.public.attachmentsUploadMimeTypes = process.env.ATTACHMENTS_UPLOAD_MIME_TYPES;
Meteor.settings.public.avatarsUploadMaxSize = process.env.AVATARS_UPLOAD_MAX_SIZE;
```
- 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
- 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.
- https://github.com/wekan/wekan/commit/167863d95711249e69bb3511175d73b34acbbdb3
- https://wekan.fi/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.
## 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
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.
[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 +101,29 @@ 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:
- Brute force password guessign. Currently there is
[brute force protection with eluck:accounts-lockout](https://github.com/wekan/wekan/blob/devel/CHANGELOG.md#v080-2018-04-04-wekan-release).
- Security issues related to that Wekan uses Meteor 1.6.0.1 related packages, and upgrading to newer
Meteor 1.6.1 is complicated process that requires lots of changes to many dependency packages.
Upgrading [has been tried many times, spending a lot of time](https://github.com/meteor/meteor/issues/9609)
but there still is issues. Helping with package upgrades is very welcome.
- [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 (at) 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.78.0"
appVersion: "v2.97.0"
files:
userUploads:
- README.md
-2041
View File
File diff suppressed because it is too large. Load diff
-180
View File
@@ -1,180 +0,0 @@
// 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 (_) {}
// Expose Meteor on window. Under the Meteor 3.5 + rspack build, bare `Meteor`
// references in app code are rewritten by rspack's ProvidePlugin into per-module
// imports, so `Meteor` is NOT placed on the browser `window` the way the classic
// Meteor linker did. Re-expose it so the browser console and the Playwright e2e
// tests (which call Meteor.loginWithToken / Meteor.userId / Meteor.subscribe etc.
// via page.evaluate in window scope) can reach it. This matches the long-standing
// classic-Meteor behaviour where `Meteor` is a global.
if (typeof window !== 'undefined') {
window.Meteor = Meteor;
}
// 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
if ('serviceWorker' in navigator) {
window.addEventListener('load', function() {
const swPath = new URL('pwa-service-worker.js', Meteor.absoluteUrl()).pathname;
navigator.serviceWorker.register(swPath);
});
}
// 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();
}
});
});
@@ -1,64 +0,0 @@
.activity-title {
margin: 0 0.7vw 1vh;
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);
text-align: center;
line-height: 1.3;
width: 5vw;
}
.reactions-popup .add-comment-reaction:hover {
background-color: #b0c4de;
}
.activities {
clear: both;
}
.activities .activity {
margin: 0.1vh 0;
padding: 0.8vh 0;
display: flex;
}
.activities .activity .member {
width: 4vw;
height: 4vw;
}
.activities .activity .activity-member {
font-weight: 700;
}
.activities .activity .activity-desc {
word-wrap: break-word;
overflow: hidden;
flex: 1;
align-self: center;
margin: 0;
margin-inline-start: 0.4vw;
overflow: hidden;
word-break: break-word;
}
.activities .activity .activity-desc .activity-comment {
display: block;
border-radius: 0.4vw;
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;
}
.activities .activity .activity-desc .activity-checklist {
display: block;
border-radius: 3px;
background: #fff;
text-decoration: none;
box-shadow: 0 1px 2px rgba(0,0,0,0.2);
margin-top: 5px;
padding: 5px;
}
.activities .activity .activity-desc .activity-meta {
font-size: 0.8em;
color: #999;
}
+202 -185
View File
@@ -1,206 +1,223 @@
template(name="activities")
if showActivities
.activities.js-sidebar-activities
//- We should use Template.dynamic here but there is a bug with
//- blaze-components: https://github.com/peerlibrary/meteor-blaze-components/issues/30
if $eq mode "board"
+boardActivities
else
+cardActivities
.activities.js-sidebar-activities
//- We should use Template.dynamic here but there is a bug with
//- blaze-components: https://github.com/peerlibrary/meteor-blaze-components/issues/30
if $eq mode "board"
+boardActivities
else
+cardActivities
template(name="boardActivities")
each activityData in boardActivitiesList
+activity(activity=activityData card=card mode=mode)
each currentBoard.activities
.activity
+userAvatar(userId=user._id)
p.activity-desc
+memberName(user=user)
if($eq activityType 'deleteAttachment')
| {{{_ 'activity-delete-attach' cardLink}}}.
if($eq activityType 'addAttachment')
| {{{_ 'activity-attached' attachmentLink cardLink}}}.
if($eq activityType 'addBoardMember')
| {{{_ 'activity-added' memberLink boardLabel}}}.
if($eq activityType 'addComment')
| {{{_ 'activity-on' cardLink}}}
a.activity-comment(href="{{ card.absoluteUrl }}")
+viewer
= comment.text
if($eq activityType 'addChecklist')
| {{{_ 'activity-checklist-added' cardLink}}}.
.activity-checklist(href="{{ card.absoluteUrl }}")
+viewer
= checklist.title
if($eq activityType 'removeChecklist')
| {{{_ 'activity-checklist-removed' cardLink}}}.
if($eq activityType 'checkedItem')
| {{{_ 'activity-checked-item' checkItem checklist.title cardLink}}}.
if($eq activityType 'uncheckedItem')
| {{{_ 'activity-unchecked-item' checkItem checklist.title cardLink}}}.
if($eq activityType 'checklistCompleted')
| {{{_ 'activity-checklist-completed' checklist.title cardLink}}}.
if($eq activityType 'checklistUncompleted')
| {{{_ 'activity-checklist-uncompleted' checklist.title cardLink}}}.
if($eq activityType 'addChecklistItem')
| {{{_ 'activity-checklist-item-added' checklist.title cardLink}}}.
.activity-checklist(href="{{ card.absoluteUrl }}")
+viewer
= checklistItem.title
if($eq activityType 'removedChecklistItem')
| {{{_ 'activity-checklist-item-removed' checklist.title cardLink}}}.
if($eq activityType 'archivedCard')
| {{{_ 'activity-archived' cardLink}}}.
if($eq activityType 'archivedList')
| {{_ 'activity-archived' list.title}}.
if($eq activityType 'archivedSwimlane')
| {{_ 'activity-archived' swimlane.title}}.
if($eq activityType 'createBoard')
| {{_ 'activity-created' boardLabel}}.
if($eq activityType 'createCard')
| {{{_ 'activity-added' cardLink boardLabel}}}.
if($eq activityType 'createCustomField')
| {{_ 'activity-customfield-created' customField}}.
if($eq activityType 'createList')
| {{_ 'activity-added' list.title boardLabel}}.
if($eq activityType 'createSwimlane')
| {{_ 'activity-added' swimlane.title boardLabel}}.
if($eq activityType 'removeList')
| {{_ 'activity-removed' title boardLabel}}.
if($eq activityType 'importBoard')
| {{{_ 'activity-imported-board' boardLabel sourceLink}}}.
if($eq activityType 'importCard')
| {{{_ 'activity-imported' cardLink boardLabel sourceLink}}}.
if($eq activityType 'importList')
| {{{_ 'activity-imported' listLabel boardLabel sourceLink}}}.
if($eq activityType 'joinMember')
if($eq user._id member._id)
| {{{_ 'activity-joined' cardLink}}}.
else
| {{{_ 'activity-added' memberLink cardLink}}}.
if($eq activityType 'moveCardBoard')
| {{{_ 'activity-moved' cardLink oldBoardName boardName}}}.
if($eq activityType 'moveCard')
| {{{_ 'activity-moved' cardLink oldList.title list.title}}}.
if($eq activityType 'removeBoardMember')
| {{{_ 'activity-excluded' memberLink boardLabel}}}.
if($eq activityType 'restoredCard')
| {{{_ 'activity-sent' cardLink boardLabel}}}.
if($eq activityType 'addedLabel')
| {{{_ 'activity-added-label' lastLabel cardLink}}}.
if($eq activityType 'removedLabel')
| {{{_ 'activity-removed-label' lastLabel cardLink}}}.
if($eq activityType 'setCustomField')
| {{{_ 'activity-set-customfield' lastCustomField lastCustomFieldValue cardLink}}}.
if($eq activityType 'unsetCustomField')
| {{{_ 'activity-unset-customfield' lastCustomField cardLink}}}.
if($eq activityType 'unjoinMember')
if($eq user._id member._id)
| {{{_ 'activity-unjoined' cardLink}}}.
else
| {{{_ 'activity-removed' memberLink cardLink}}}.
span(title=createdAt).activity-meta {{ moment createdAt }}
template(name="cardActivities")
each activityData in cardActivitiesList
+activity(activity=activityData card=card mode=mode)
each currentCard.activities
.activity
+userAvatar(userId=user._id)
p.activity-desc
+memberName(user=user)
if($eq activityType 'createCard')
| {{_ 'activity-added' cardLabel listName}}.
if($eq activityType 'importCard')
| {{{_ 'activity-imported' cardLabel list.title sourceLink}}}.
if($eq activityType 'joinMember')
if($eq user._id member._id)
| {{_ 'activity-joined' cardLabel}}.
else
| {{{_ 'activity-added' memberLink cardLabel}}}.
if($eq activityType 'unjoinMember')
if($eq user._id member._id)
| {{_ 'activity-unjoined' cardLabel}}.
else
| {{{_ 'activity-removed' cardLabel memberLink}}}.
if($eq activityType 'archivedCard')
| {{_ 'activity-archived' cardLabel}}.
template(name="activity")
.activity(data-id=activity._id)
+userAvatar(userId=activity.user._id)
p.activity-desc
span.activity-member
+memberName(user=activity.user)
if($eq activityType 'addedLabel')
| {{{_ 'activity-added-label-card' lastLabel }}}.
//- attachment activity -------------------------------------------------
if($eq activity.activityType 'deleteAttachment')
| {{{_ 'activity-delete-attach' cardLink}}}.
if($eq activityType 'removedLabel')
| {{{_ 'activity-removed-label-card' lastLabel }}}.
if($eq activity.activityType 'addAttachment')
| {{{_ 'activity-attached' attachmentLink cardLink}}}.
if($neq mode 'board')
if activity.attachment.isImage
img.attachment-image-preview(src=activity.attachment.url)
if($eq activityType 'removeChecklist')
| {{{_ 'activity-checklist-removed' cardLabel}}}.
//- board activity ------------------------------------------------------
if($eq activity.activityType 'createBoard')
| {{{_ 'activity-created' boardLabelLink}}}.
if($eq activityType 'checkedItem')
| {{{_ 'activity-checked-item-card' checkItem checklist.title }}}.
if($eq activity.activityType 'importBoard')
| {{{_ 'activity-imported-board' boardLabelLink sourceLink}}}.
if($eq activityType 'uncheckedItem')
| {{{_ 'activity-unchecked-item-card' checkItem checklist.title }}}.
if($eq activity.activityType 'addBoardMember')
| {{{_ 'activity-added' memberLink boardLabelLink}}}.
if($eq activityType 'checklistCompleted')
| {{{_ 'activity-checklist-completed-card' checklist.title }}}.
if($eq activity.activityType 'removeBoardMember')
| {{{_ 'activity-excluded' memberLink boardLabelLink}}}.
if($eq activityType 'checklistUncompleted')
| {{{_ 'activity-checklist-uncompleted-card' checklist.title }}}.
//- card activity -------------------------------------------------------
if($eq activity.activityType 'createCard')
if($eq mode 'card')
| {{{_ 'activity-added' cardLabelLink (sanitize activity.listName)}}}.
else
| {{{_ 'activity-added' cardLabelLink boardLabelLink}}}.
if($eq activityType 'restoredCard')
| {{_ 'activity-sent' cardLabel boardLabel}}.
if($eq activityType 'moveCard')
| {{_ 'activity-moved' cardLabel oldList.title list.title}}.
if($eq activity.activityType 'importCard')
| {{{_ 'activity-imported' cardLink boardLabelLink sourceLink}}}.
if($eq activityType 'moveCardBoard')
| {{{_ 'activity-moved' cardLink oldBoardName boardName}}}.
if($eq activity.activityType 'moveCard')
| {{{_ 'activity-moved' cardLabelLink (sanitize activity.oldList.title) (sanitize activity.list.title)}}}.
if($eq activity.activityType 'moveCardBoard')
| {{{_ 'activity-moved' cardLink (sanitize activity.oldBoardName) (sanitize activity.boardName)}}}.
if($eq activity.activityType 'archivedCard')
| {{{_ 'activity-archived' cardLink}}}.
if($eq activity.activityType 'restoredCard')
| {{{_ 'activity-sent' cardLink boardLabelLink}}}.
//- checklist activity --------------------------------------------------
if($eq activity.activityType 'addChecklist')
| {{{_ 'activity-checklist-added' cardLink}}}.
if($eq mode 'card')
if($eq activityType 'addAttachment')
| {{{_ 'activity-attached' attachmentLink cardLabel}}}.
if attachment.isImage
img.attachment-image-preview(src=attachment.url)
if($eq activityType 'deleteAttachment')
| {{{_ 'activity-delete-attach' cardLabel}}}.
if($eq activityType 'removedChecklist')
| {{{_ 'activity-checklist-removed' cardLabel}}}.
if($eq activityType 'addChecklist')
| {{{_ 'activity-checklist-added' cardLabel}}}.
.activity-checklist
+viewer
= activity.checklist.title
else
a.activity-checklist(href="{{ activity.card.originRelativeUrl }}")
= checklist.title
if($eq activityType 'addChecklistItem')
| {{{_ 'activity-checklist-item-added' checklist.title cardLink}}}.
.activity-checklist(href="{{ card.absoluteUrl }}")
+viewer
= activity.checklist.title
= checklistItem.title
if($eq activity.activityType 'removedChecklist')
| {{{_ 'activity-checklist-removed' cardLink}}}.
if($eq activityType 'addComment')
+inlinedForm(classNames='js-edit-comment')
+editor(autofocus=true)
= comment.text
.edit-controls
button.primary(type="submit") {{_ 'edit'}}
else
.activity-comment
+viewer
= comment.text
span(title=createdAt).activity-meta {{ moment createdAt }}
if ($eq currentUser._id comment.userId)
= ' - '
a.js-open-inlined-form {{_ "edit"}}
= ' - '
a.js-delete-comment {{_ "delete"}}
if($eq activity.activityType 'completeChecklist')
| {{{_ 'activity-checklist-completed' (sanitize activity.checklist.title) cardLink}}}.
if($eq activity.activityType 'uncompleteChecklist')
| {{{_ 'activity-checklist-uncompleted' (sanitize activity.checklist.title) cardLink}}}.
if($eq activity.activityType 'checkedItem')
| {{{_ 'activity-checked-item' (sanitize checkItem) (sanitize activity.checklist.title) cardLink}}}.
if($eq activity.activityType 'uncheckedItem')
| {{{_ 'activity-unchecked-item' (sanitize checkItem) (sanitize activity.checklist.title) cardLink}}}.
if($eq activity.activityType 'addChecklistItem')
| {{{_ 'activity-checklist-item-added' (sanitize activity.checklist.title) cardLink}}}.
.activity-checklist(href="{{ activity.card.originRelativeUrl }}")
+viewer
= activity.checklistItem.title
if($eq activity.activityType 'removedChecklistItem')
| {{{_ 'activity-checklist-item-removed' (sanitize activity.checklist.title) cardLink}}}.
//- comment activity ----------------------------------------------------
if($eq activity.activityType 'deleteComment')
| {{{_ 'activity-deleteComment' activity.commentId}}}.
if($eq activity.activityType 'editComment')
| {{{_ 'activity-editComment' activity.commentId}}}.
if($eq activity.activityType 'addComment')
| {{{_ 'activity-on' cardLink}}}
a.activity-comment(href="{{ activity.card.originRelativeUrl }}")
+viewer
= activity.comment.text
//- date activity ------------------------------------------------
if($eq activity.activityType 'a-receivedAt')
| {{{_ 'activity-receivedDate' (sanitize receivedDate) cardLink}}}.
if($eq activity.activityType 'a-startAt')
| {{{_ 'activity-startDate' (sanitize startDate) cardLink}}}.
if($eq activity.activityType 'a-dueAt')
| {{{_ 'activity-dueDate' (sanitize dueDate) cardLink}}}.
if($eq activity.activityType 'a-endAt')
| {{{_ 'activity-endDate' (sanitize endDate) cardLink}}}.
if($eq activity.activityType 'a-changedTitle')
| {{{_ 'activity-changedTitle' (sanitize activity.value) cardLink}}}.
//- customField activity ------------------------------------------------
if($eq activity.activityType 'createCustomField')
| {{_ 'activity-customfield-created' customField}}.
if($eq activity.activityType 'setCustomField')
| {{{_ 'activity-set-customfield' (sanitize lastCustomField) (sanitize lastCustomFieldValue) cardLink}}}.
if($eq activity.activityType 'unsetCustomField')
| {{{_ 'activity-unset-customfield' (sanitize lastCustomField) cardLink}}}.
//- label activity ------------------------------------------------------
if($eq activity.activityType 'addedLabel')
| {{{_ 'activity-added-label' (sanitize lastLabel) cardLink}}}.
if($eq activity.activityType 'removedLabel')
| {{{_ 'activity-removed-label' (sanitize lastLabel) cardLink}}}.
//- list activity -------------------------------------------------------
if($neq mode 'card')
if($eq activity.activityType 'createList')
| {{{_ 'activity-added' (sanitize listLabel) boardLabelLink}}}.
if($eq activity.activityType 'importList')
| {{{_ 'activity-imported' (sanitize listLabel) boardLabelLink sourceLink}}}.
if($eq activity.activityType 'removeList')
| {{{_ 'activity-removed' (sanitize activity.title) boardLabelLink}}}.
if($eq activity.activityType 'archivedList')
| {{_ 'activity-archived' (sanitize listLabel)}}.
if($eq activity.activityType 'changedListTitle')
| {{_ 'activity-changedListTitle' (sanitize listLabel) boardLabelLink}}
//- member activity ----------------------------------------------------
if($eq activity.activityType 'joinMember')
if($eq user._id activity.member._id)
| {{{_ 'activity-joined' cardLink}}}.
else
| {{{_ 'activity-added' memberLink cardLink}}}.
if($eq activity.activityType 'unjoinMember')
if($eq user._id activity.member._id)
| {{{_ 'activity-unjoined' cardLink}}}.
else
| {{{_ 'activity-removed' memberLink cardLink}}}.
//- swimlane activity --------------------------------------------------
if($eq activity.activityType 'createSwimlane')
| {{{_ 'activity-added' (sanitize activity.swimlane.title) boardLabelLink}}}.
if($eq activity.activityType 'archivedSwimlane')
| {{{_ 'activity-archived' (sanitize activity.swimlane.title)}}}.
//- I don't understand this part ----------------------------------------
if(currentData.timeKey)
| {{_ activity.activityType }}
= ' '
i(title=currentData.timeValue).activity-meta {{ displayDate currentData.timeValue 'LLL' }}
if (currentData.timeOldValue)
= ' '
| {{{_ "previous_as" }}}
= ' '
i(title=currentData.timeOldValue).activity-meta {{ displayDate currentData.timeOldValue 'LLL' }}
= ' @'
else if(currentData.timeValue)
| {{_ activity.activityType currentData.timeValue}}
if($neq mode 'none')
div(title=activity.createdAt).activity-meta {{ displayDate activity.createdAt }}
span(title=createdAt).activity-meta {{ moment createdAt }}
+116 -331
View File
@@ -1,250 +1,124 @@
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 = 20;
const activitiesPerPage = 500;
BlazeComponent.extendComponent({
onCreated() {
// XXX Should we use ReactiveNumber?
this.page = new ReactiveVar(1);
this.loadNextPageLocked = false;
const sidebar = this.parentComponent(); // XXX for some reason not working
sidebar.callFirstWith(null, 'resetNextPeak');
this.autorun(() => {
let mode = this.data().mode;
const capitalizedMode = Utils.capitalize(mode);
let thisId, searchId;
if (mode === 'linkedcard' || mode === 'linkedboard') {
thisId = Session.get('currentCard');
searchId = Cards.findOne({_id: thisId}).linkedId;
mode = mode.replace('linked', '');
} else {
thisId = Session.get(`current${capitalizedMode}`);
searchId = thisId;
}
const limit = this.page.get() * activitiesPerPage;
const user = Meteor.user();
const hideSystem = user ? user.hasHiddenSystemMessages() : false;
if (searchId === null)
return;
Template.activities.onCreated(function () {
// Register with sidebar so it can call loadNextPage on us
const Sidebar = getSidebarInstance();
if (Sidebar) {
Sidebar.activitiesInstance = this;
}
this.subscribe('activities', mode, searchId, limit, hideSystem, () => {
this.loadNextPageLocked = false;
// XXX Should we use ReactiveNumber?
this.page = new ReactiveVar(1);
this.loadNextPageLocked = false;
this.loadNextPage = () => {
// 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;
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', '');
} else if (mode === 'card') {
searchId = Utils.getCurrentCardId();
} else {
searchId = Session.get(`current${capitalizedMode}`);
}
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);
},
activities() {
const activities = this.card?.activities?.();
return 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({
checkItem() {
const checkItemId = this.activity.checklistItemId;
const checkItem = ReactiveCache.getChecklistItem(checkItemId);
return checkItem && checkItem.title;
checkItem(){
const checkItemId = this.currentData().checklistItemId;
const checkItem = ChecklistItems.findOne({_id:checkItemId});
return checkItem.title;
},
boardLabelLink() {
const currentBoardId = Session.get('currentBoard');
if (this.mode !== 'board') {
return createBoardLink(this.activity.board(), this.activity.listName ? this.activity.listName : null);
}
else if (currentBoardId != this.activity.boardId) {
return createBoardLink(this.activity.board(), this.activity.listName ? this.activity.listName : null);
}
boardLabel() {
return TAPi18n.__('this-board');
},
cardLabelLink() {
const currentBoardId = Session.get('currentBoard');
if (this.mode == 'card') {
return TAPi18n.__('this-card');
}
else if (this.mode !== 'board') {
return createCardLink(this.activity.card(), null);
}
else if (currentBoardId != this.activity.boardId) {
return createCardLink(this.activity.card(), this.activity.board().title);
}
return createCardLink(this.activity.card(), null);
cardLabel() {
return TAPi18n.__('this-card');
},
cardLink() {
const currentBoardId = Session.get('currentBoard');
if (this.mode !== 'board') {
return createCardLink(this.activity.card(), null);
}
else if (currentBoardId != this.activity.boardId) {
return createCardLink(this.activity.card(), this.activity.board().title);
}
return createCardLink(this.activity.card(), null);
const card = this.currentData().card();
return card && Blaze.toHTML(HTML.A({
href: card.absoluteUrl(),
'class': 'action-card',
}, card.title));
},
receivedDate() {
const card = this.activity.card();
if (!card) return null;
return card.receivedAt;
},
startDate() {
const card = this.activity.card();
if (!card) return null;
return card.startAt;
},
dueDate() {
const card = this.activity.card();
if (!card) return null;
return card.dueAt;
},
endDate() {
const card = this.activity.card();
if (!card) return null;
return card.endAt;
},
lastLabel() {
const lastLabelId = this.activity.labelId;
if (!lastLabelId) return null;
const lastLabel = ReactiveCache.getBoard(
this.activity.boardId,
).getLabelById(lastLabelId);
if (lastLabel && (lastLabel.name === undefined || lastLabel.name === '')) {
return lastLabel.color;
} else if (lastLabel.name !== undefined && lastLabel.name !== '') {
return lastLabel.name;
} else {
lastLabel(){
const lastLabelId = this.currentData().labelId;
if (!lastLabelId)
return null;
const lastLabel = Boards.findOne(Session.get('currentBoard')).getLabelById(lastLabelId);
if(lastLabel.name === undefined || lastLabel.name === ''){
return lastLabel.color;
}else{
return lastLabel.name;
}
},
lastCustomField() {
const lastCustomField = ReactiveCache.getCustomField(
this.activity.customFieldId,
);
if (!lastCustomField) return null;
lastCustomField(){
const lastCustomField = CustomFields.findOne(this.currentData().customFieldId);
if (!lastCustomField)
return null;
return lastCustomField.name;
},
lastCustomFieldValue() {
const lastCustomField = ReactiveCache.getCustomField(
this.activity.customFieldId,
);
if (!lastCustomField) return null;
const value = this.activity.value;
if (
lastCustomField.settings.dropdownItems &&
lastCustomField.settings.dropdownItems.length > 0
) {
const dropDownValue = lastCustomField.settings.dropdownItems.find(
item => {
return item._id === value;
},
);
if (dropDownValue) return dropDownValue.name;
lastCustomFieldValue(){
const lastCustomField = CustomFields.findOne(this.currentData().customFieldId);
if (!lastCustomField)
return null;
const value = this.currentData().value;
if (lastCustomField.settings.dropdownItems && lastCustomField.settings.dropdownItems.length > 0) {
const dropDownValue = _.find(lastCustomField.settings.dropdownItems, (item) => {
return item._id === value;
});
if (dropDownValue)
return dropDownValue.name;
}
return value;
},
listLabel() {
const activity = this.activity;
const list = activity.list();
return (list && list.title) || activity.title;
return this.currentData().list().title;
},
sourceLink() {
const source = this.activity.source;
if (source) {
if (source.url) {
return Blaze.toHTML(
HTML.A(
{
href: source.url,
},
sanitizeHTML(source.system),
),
);
const source = this.currentData().source;
if(source) {
if(source.url) {
return Blaze.toHTML(HTML.A({
href: source.url,
}, source.system));
} else {
return sanitizeHTML(source.system);
return source.system;
}
}
return null;
@@ -252,134 +126,45 @@ Template.activity.helpers({
memberLink() {
return Blaze.toHTMLWithData(Template.memberName, {
user: this.activity.member(),
user: this.currentData().member(),
});
},
attachmentLink() {
const attachment = this.activity.attachment();
const attachmentUrl = attachment && typeof attachment.link === 'function'
? attachment.link()
: '';
const attachment = this.currentData().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`,
target: '_blank',
},
sanitizeText(attachment.name),
),
)) ||
sanitizeText(this.activity.attachmentName)
);
return attachment && attachment.url({ download: true }) && Blaze.toHTML(HTML.A({
href: attachment.url({ download: true }),
target: '_blank',
}, attachment.name()));
},
customField() {
const customField = this.activity.customField();
if (!customField) return null;
const customField = this.currentData().customField();
if (!customField)
return null;
return customField.name;
},
});
Template.activity.helpers({
sanitize(value) {
return sanitizeHTML(value);
events() {
return [{
// XXX We should use Popup.afterConfirmation here
'click .js-delete-comment'() {
const commentId = this.currentData().commentId;
CardComments.remove(commentId);
},
'submit .js-edit-comment'(evt) {
evt.preventDefault();
const commentText = this.currentComponent().getValue().trim();
const commentId = Template.parentData().commentId;
if (commentText) {
CardComments.update(commentId, {
$set: {
text: commentText,
},
});
}
},
}];
},
});
Template.commentReactions.events({
'click .reaction'(event) {
const user = ReactiveCache.getCurrentUser();
if (user && user.isBoardMember()) {
const codepoint = event.currentTarget.dataset['codepoint'];
const commentId = Template.instance().data.commentId;
const cardComment = ReactiveCache.getCardComment(commentId);
cardComment.toggleReaction(codepoint);
}
},
'click .open-comment-reaction-popup': Popup.open('addReaction'),
})
Template.addReactionPopup.events({
'click .add-comment-reaction'(event) {
const user = ReactiveCache.getCurrentUser();
if (user && user.isBoardMember()) {
const codepoint = event.currentTarget.dataset['codepoint'];
const commentId = Template.instance().data.commentId;
const cardComment = ReactiveCache.getCardComment(commentId);
cardComment.toggleReaction(codepoint);
}
Popup.back();
},
})
Template.addReactionPopup.helpers({
codepoints() {
// Starting set of unicode codepoints as comment reactions
return [
'&#128077;',
'&#128078;',
'&#128064;',
'&#9989;',
'&#10060;',
'&#128591;',
'&#128079;',
'&#127881;',
'&#128640;',
'&#128522;',
'&#129300;',
'&#128532;'];
}
})
Template.commentReactions.helpers({
isSelected(userIds) {
return Meteor.userId() && userIds.includes(Meteor.userId());
},
userNames(userIds) {
const ret = ReactiveCache.getUsers({_id: {$in: userIds}})
.map(user => user.profile.fullname)
.join(', ');
return ret;
}
})
function createCardLink(card, board) {
if (!card) return '';
let text = card.title;
if (board) text = `${board} > ` + text;
return (
card &&
Blaze.toHTML(
HTML.A(
{
href: card.originRelativeUrl(),
class: 'action-card',
},
sanitizeHTML(text),
),
)
);
}
function createBoardLink(board, list) {
let text = board.title;
if (list) text += `: ${list}`;
return (
board &&
Blaze.toHTML(
HTML.A(
{
href: board.originRelativeUrl(),
class: 'action-board',
},
sanitizeHTML(text),
),
)
);
}
}).register('activities');
@@ -0,0 +1,48 @@
@import 'nib'
.activity-title
margin: 0 0.5em 0.8em
display: flex
justify-content:space-between
.activities
clear: both
.activity
margin: 10px 0
display: flex
.member
width: 24px
height: @width
.activity-desc
word-wrap: break-word
overflow: hidden
flex: 1
align-self: center
margin: 0
margin-left: 3px
overflow: hidden;
word-break: break-word;
.activity-comment
display: block
border-radius: 3px
background: white
text-decoration: none
box-shadow: 0 1px 2px rgba(0,0,0,.2)
margin-top: 5px
padding: 5px
.activity-checklist
display: block
border-radius: 3px
background: white
text-decoration: none
box-shadow: 0 1px 2px rgba(0,0,0,.2)
margin-top: 5px
padding: 5px
.activity-meta
font-size: 0.8em
color: darken(white, 40%)
-183
View File
@@ -1,183 +0,0 @@
.new-comment {
position: relative;
margin: 0 0 20px 38px;
}
.new-comment .member {
opacity: 0.7;
position: absolute;
top: 1px;
inset-inline-start: -38px;
}
.new-comment.is-open .member {
opacity: 1;
}
.new-comment.is-open .helper {
display: inline-block;
}
.new-comment.is-open textarea {
min-height: 100px;
color: #4d4d4d;
cursor: auto;
overflow: hidden;
word-wrap: break-word;
}
.new-comment .too-long {
margin-top: 8px;
}
.new-comment textarea {
background-color: #fff;
border: 0;
box-shadow: 0 1px 2px rgba(0,0,0,0.23);
height: 36px;
margin: 4px 4px 6px 0;
padding: 9px 11px;
width: 100%;
}
.new-comment textarea:hover,
.new-comment textarea:is-open {
background-color: #fff;
box-shadow: 0 1px 3px rgba(0,0,0,0.33);
border: 0;
cursor: pointer;
}
.new-comment textarea:is-open {
cursor: auto;
}
.comment-item {
background-color: #fff;
border: 0;
box-shadow: 0 1px 2px rgba(0,0,0,0.23);
color: #8c8c8c;
height: 36px;
margin: 4px 4px 6px 0;
width: 92%;
}
.comment-item:hover {
background: #e0e0e0;
}
.comment-item.add-comment {
display: flex;
margin: 5px;
}
.comment-item.add-comment a {
display: block;
margin: auto;
}
.comments {
clear: both;
}
.comments .comment {
margin: 0.5px 0;
padding: 6px 0;
display: flex;
}
.comments .comment .member {
width: 32px;
height: 32px;
}
.comments .comment .comment-member {
font-weight: 700;
}
.comments .comment .comment-desc {
word-wrap: break-word;
overflow: hidden;
flex: 1;
align-self: center;
margin: 0;
margin-inline-start: 3px;
overflow: hidden;
word-break: break-word;
}
.comments .comment .comment-desc .comment-text {
display: block;
border-radius: 3px;
background: #fff;
text-decoration: none;
box-shadow: 0 1px 2px rgba(0,0,0,0.2);
margin-top: 5px;
padding: 5px;
}
.comments .comment .comment-desc .reactions {
display: flex;
margin-top: 5px;
gap: 5px;
}
.comments .comment .comment-desc .reactions .open-comment-reaction-popup {
display: flex;
align-items: center;
text-decoration: none;
height: 24px;
}
.comments .comment .comment-desc .reactions .open-comment-reaction-popup span {
display: inline-block;
font-size: clamp(14px, 2vw, 18px);
font-weight: 500;
line-height: 1;
margin-inline-start: 4px;
}
.comments .comment .comment-desc .reactions .reaction {
cursor: pointer;
border: 1px solid #808080;
border-radius: 15px;
display: flex;
padding: 2px 5px;
}
.comments .comment .comment-desc .reactions .reaction.selected {
background-color: #b0c4de;
}
.comments .comment .comment-desc .reactions .reaction:hover {
background-color: #b0c4de;
}
.comments .comment .comment-desc .reactions .reaction .reaction-count {
font-size: 12px;
}
.comments .comment .comment-desc .comment-meta {
font-size: 0.8em;
color: #999;
}
/* Threaded replies (issue #5907): indent replies under their parent. */
.comments .comment.is-reply {
margin-inline-start: 24px;
border-inline-start: 2px solid #e0e0e0;
padding-inline-start: 8px;
}
.comments .comment .comment-desc .comment-in-reply-to {
font-size: 0.8em;
color: #999;
border-inline-start: 2px solid #ccc;
padding-inline-start: 6px;
margin: 3px 0;
display: block;
}
.comments .comment .comment-desc .comment-in-reply-to .comment-in-reply-to-label {
font-weight: 700;
margin-inline-end: 4px;
}
.comments .comment .comment-desc .comment-in-reply-to .comment-in-reply-to-text {
font-style: italic;
}
/* "In reply to" banner above the new-comment form. */
.new-comment .comment-reply-banner {
background: #f4f4f4;
border-inline-start: 2px solid #ccc;
padding: 4px 6px;
margin: 0 4px 6px 0;
font-size: 0.85em;
color: #666;
display: flex;
align-items: center;
gap: 6px;
}
.new-comment .comment-reply-banner .comment-reply-banner-label {
font-weight: 700;
}
.new-comment .comment-reply-banner .comment-reply-banner-text {
flex: 1;
font-style: italic;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.new-comment .comment-reply-banner a {
cursor: pointer;
}
+1 -76
View File
@@ -1,84 +1,9 @@
template(name="commentForm")
.new-comment.js-new-comment(
class="{{#if commentFormIsOpen}}is-open{{/if}}")
+userAvatar(userId=currentUser._id noRemove=true)
if replyToComment
.comment-reply-banner.js-reply-banner
span.comment-reply-banner-label {{_ 'comment-in-reply-to'}}:
span.comment-reply-banner-text
+viewer
= replyToComment.text
a.js-cancel-reply(title="{{_ 'close' }}")
i.fa.fa-times-thin
+userAvatar(userId=currentUser._id)
form.js-new-comment-form
+editor(class="js-new-comment-input")
| {{getUnsavedValue 'cardComment' currentCard._id}}
.add-controls
button.primary.confirm.clear.js-add-comment(type="submit") {{_ 'comment'}}
template(name="comments")
.comments
each commentData in getComments
+comment(commentData)
template(name="comment")
.comment(class="{{#if parentId}}is-reply{{/if}}")
+userAvatar(userId=userId)
p.comment-desc
span.comment-member
+memberName(user=user)
if parentComment
.comment-in-reply-to
span.comment-in-reply-to-label {{_ 'comment-in-reply-to'}}:
span.comment-in-reply-to-text
+viewer
= parentComment.text
+inlinedForm(classNames='js-edit-comment')
+editor(autofocus=true)
= text
.edit-controls
button.primary(type="submit") {{_ 'edit'}}
a.js-close-inlined-form(title="{{_ 'close' }}")
i.fa.fa-times-thin
else
.comment-text
+viewer
= text
+commentReactions(reactions=reactions commentId=_id)
span(title=createdAt).comment-meta {{ displayDate createdAt }}
if currentUser.isBoardMember
= ' - '
a.js-reply-comment {{_ "comment-reply"}}
if canEditThisComment
+editOrDeleteComment
template(name="editOrDeleteComment")
= ' - '
a.js-open-inlined-form {{_ "edit"}}
= ' - '
a.js-delete-comment {{_ "delete"}}
template(name="deleteCommentPopup")
p {{_ "comment-delete"}}
button.js-confirm.negate.full(type="submit") {{_ 'delete'}}
template(name="commentReactions")
.reactions
each reaction in reactions
span.reaction(class="{{#if isSelected reaction.userIds}}selected{{/if}}" data-codepoint="#{reaction.reactionCodepoint}" title="{{userNames reaction.userIds}}")
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' }}") ➕
template(name="addReactionPopup")
.reactions-popup
each codepoint in codepoints
span.add-comment-reaction(data-codepoint="#{codepoint}") !{codepoint}
+47 -133
View File
@@ -1,140 +1,58 @@
import { ReactiveCache } from '/imports/reactiveCache';
import CardComments, { canEditComment } 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);
// _id of the comment currently being replied to (threaded replies, issue #5907).
// Empty string means a new top-level comment.
const replyToCommentId = new ReactiveVar('');
Template.commentForm.onDestroyed(function () {
commentFormIsOpen.set(false);
$('.note-popover').hide();
});
BlazeComponent.extendComponent({
onDestroyed() {
commentFormIsOpen.set(false);
},
Template.commentForm.helpers({
commentFormIsOpen() {
return commentFormIsOpen.get();
},
// The comment being replied to, if any, so the form can show an
// "In reply to ..." banner with a way to cancel.
replyToComment() {
const id = replyToCommentId.get();
return id ? ReactiveCache.getCardComment(id) : undefined;
},
});
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) {
const parentId = replyToCommentId.get();
CardComments.insert({
text,
boardId,
cardId,
parentId: parentId || '',
});
replyToCommentId.set('');
resetCommentInput(input);
Tracker.flush();
autosize.update(input);
input.trigger('submitted');
}
evt.preventDefault();
getInput() {
return this.$('.js-new-comment-input');
},
'click .js-cancel-reply'(evt) {
evt.preventDefault();
replyToCommentId.set('');
},
// 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();
events() {
return [{
'click .js-new-comment:not(.focus)'() {
commentFormIsOpen.set(true);
},
'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 = Cards.findOne(card.linkedId).boardId;
cardId = card.linkedId;
}
if (text) {
CardComments.insert({
text,
boardId,
cardId,
});
resetCommentInput(input);
Tracker.flush();
autosize.update(input);
}
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();
}
},
}];
},
});
Template.comment.helpers({
// Whether the current user may edit/delete this comment, honouring the
// board's restrictCommentEditing setting (issue #5906). Mirrors the
// server-side enforcement so the UI hides buttons the server would reject.
canEditThisComment() {
const user = ReactiveCache.getCurrentUser();
if (!user) return false;
const isAuthor = user._id === this.userId;
const board = ReactiveCache.getBoard(this.boardId);
const isBoardAdmin = !!board && board.hasAdmin(user._id);
const restrictCommentEditing = !!board && !!board.restrictCommentEditing;
return canEditComment({ isAuthor, isBoardAdmin, restrictCommentEditing });
},
// The parent comment this one replies to, or undefined.
parentComment() {
return this.parentId ? ReactiveCache.getCardComment(this.parentId) : undefined;
},
});
Template.comment.events({
'click .js-reply-comment'(evt) {
evt.preventDefault();
replyToCommentId.set(this._id);
// Open and focus the new-comment form.
const input = $('.js-new-comment-input');
input.click();
Tracker.afterFlush(() => {
$('.js-new-comment-input').focus();
});
},
'click .js-delete-comment': Popup.afterConfirm('deleteComment', function () {
const commentId = this._id;
// #3252: only remove if the doc is still in the local cache. Under heavy
// archive/delete churn the comment can already be evicted from Minimongo,
// and CardComments.remove() of a missing _id throws "Removed nonexistent
// document" on the client.
if (commentId && CardComments.findOne(commentId)) {
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,
},
});
}
},
});
}).register('commentForm');
// XXX This should be a static method of the `commentForm` component
function resetCommentInput(input) {
input.val(''); // without manually trigger, input event won't be fired
input.val('');
input.blur();
commentFormIsOpen.set(false);
}
@@ -145,18 +63,17 @@ function resetCommentInput(input) {
// Tracker.autorun to register the component dependencies, and re-run when these
// dependencies are invalidated. A better component API would remove this hack.
Tracker.autorun(() => {
Utils.getCurrentCardId();
Session.get('currentCard');
Tracker.afterFlush(() => {
autosize.update($('.js-new-comment-input'));
});
});
EscapeActions.register(
'inlinedForm',
EscapeActions.register('inlinedForm',
() => {
const draftKey = {
fieldName: 'cardComment',
docId: Utils.getCurrentCardId(),
docId: Session.get('currentCard'),
};
const commentInput = $('.js-new-comment-input');
const draft = commentInput.val().trim();
@@ -167,10 +84,7 @@ EscapeActions.register(
}
resetCommentInput(commentInput);
},
() => {
return commentFormIsOpen.get();
},
{
() => { return commentFormIsOpen.get(); }, {
noClickEscapeOn: '.js-new-comment',
},
}
);
@@ -0,0 +1,48 @@
@import 'nib'
.new-comment
position: relative
margin: 0 0 20px 38px
.member
opacity: .7
position: absolute
top: 1px
left: -38px
&.is-open
.member
opacity: 1
.helper
display: inline-block
textarea
min-height: 100px
color: #4d4d4d
cursor: auto
overflow: hidden
word-wrap: break-word
.too-long
margin-top: 8px
textarea
background-color: #fff
border: 0
box-shadow: 0 1px 2px rgba(0, 0, 0, .23)
color: #8c8c8c
height: 36px
margin: 4px 4px 6px 0
padding: 9px 11px
width: 100%
&:hover,
&:is-open
background-color: #fff
box-shadow: 0 1px 3px rgba(0, 0, 0, .33)
border: 0
cursor: pointer
&:is-open
cursor: auto
@@ -1,184 +0,0 @@
/* Board Conversion Progress Styles */
.board-conversion-overlay {
position: fixed;
top: 0;
inset-inline-start: 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-inline-end: 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;
inset-inline-start: 0;
inset-inline-end: 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-inline-end: 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-inline-end: 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-inline-end: 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-inline-start: 16px;
padding-inline-end: 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();
});
});
+4 -6
View File
@@ -1,21 +1,19 @@
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' }}
else
li.no-items-message {{_ 'no-archived-boards'}}
+33 -48
View File
@@ -1,54 +1,39 @@
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');
});
Template.archivedBoards.helpers({
isBoardAdmin() {
return ReactiveCache.getCurrentUser()?.isBoardAdmin();
BlazeComponent.extendComponent({
onCreated() {
this.subscribe('archivedBoards');
},
archivedBoards() {
const ret = ReactiveCache.getBoards(
{ archived: true },
{
sort: { archivedAt: -1, modifiedAt: -1 },
},
);
return ret;
return Boards.find({ archived: true }, {
sort: ['title'],
});
},
});
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 && Session.get('currentBoard')) {
const currentBoard = Boards.findOne(Session.get('currentBoard'));
currentBoard.archive();
}
const board = this.currentData();
board.restore();
Utils.goBoardId(board._id);
},
'click .js-delete-board': Popup.afterConfirm('boardDelete', function() {
Popup.close();
const isSandstorm = Meteor.settings && Meteor.settings.public &&
Meteor.settings.public.sandstorm;
if (isSandstorm && Session.get('currentBoard')) {
const currentBoard = Boards.findOne(Session.get('currentBoard'));
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');
-573
View File
@@ -1,573 +0,0 @@
.board-wrapper {
position: absolute;
inset-inline-start: 0;
inset-inline-end: 0;
top: 0;
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;
inset-inline-start: 0;
inset-inline-end: 0;
top: 0;
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 - keep #content text at normal size (reverted #6426 oversizing) */
@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: 1em !important; /* Normal size (reverted #6426 oversizing) */
}
/* Keep all text elements at normal size */
#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: 1em !important; /* Normal size (reverted #6426 oversizing) */
}
.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: keep card details text/icons at normal size (reverted #6426 oversizing) */
body.mobile-mode.iphone-device .card-details {
font-size: 1em !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;
inset-inline-start: 0;
inset-inline-end: 0;
top: 0;
bottom: 0;
top: -100px;
inset-inline-end: -400px;
background: #000;
opacity: 0.33;
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;
inset-inline-start: 0 !important;
inset-inline-end: 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;
inset-inline-start: 0 !important;
inset-inline-end: 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;
inset-inline-start: 0 !important;
inset-inline-end: 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;
inset-inline-start: 0 !important;
inset-inline-end: 0 !important;
overflow-x: hidden !important;
overflow-y: auto !important;
}
.board-wrapper .board-canvas .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;
}
}
.calendar-event-green {
background: #3cb500 !important;
border-color: #2a8000;
color: #fff !important;
}
.calendar-event-yellow {
background: #fad900 !important;
border-color: #c7ac00;
color: #000 !important;
}
.calendar-event-orange {
background: #ff9f19 !important;
border-color: #cc7c14;
color: #000 !important;
}
.calendar-event-red {
background: #eb4646 !important;
border-color: #b83737;
color: #fff !important;
}
.calendar-event-purple {
background: #a632db !important;
border-color: #7d26a6;
color: #fff !important;
}
.calendar-event-blue {
background: #0079bf !important;
border-color: #005a8a;
color: #fff !important;
}
.calendar-event-pink {
background: #ff78cb !important;
border-color: #cc62a3;
color: #000 !important;
}
.calendar-event-sky {
background: #00c2e0 !important;
border-color: #0094ab;
color: #fff !important;
}
.calendar-event-black {
background: #4d4d4d !important;
border-color: #1a1a1a;
color: #fff !important;
}
.calendar-event-lime {
background: #51e898 !important;
border-color: #3eb375;
color: #000 !important;
}
.calendar-event-silver {
background: #c0c0c0 !important;
border-color: #8c8c8c;
color: #000 !important;
}
.calendar-event-peachpuff {
background: #ffdab9 !important;
border-color: #ccaf95;
color: #000 !important;
}
.calendar-event-crimson {
background: #dc143c !important;
border-color: #a8112f;
color: #fff !important;
}
.calendar-event-plum {
background: #dda0dd !important;
border-color: #a87ba8;
color: #000 !important;
}
.calendar-event-darkgreen {
background: #006400 !important;
border-color: #003000;
color: #fff !important;
}
.calendar-event-slateblue {
background: #6a5acd !important;
border-color: #4f4399;
color: #fff !important;
}
.calendar-event-magenta {
background: #f0f !important;
border-color: #c0c;
color: #fff !important;
}
.calendar-event-gold {
background: #ffd700 !important;
border-color: #ca0;
color: #000 !important;
}
.calendar-event-navy {
background: #000080 !important;
border-color: #003;
color: #fff !important;
}
.calendar-event-gray {
background: #808080 !important;
border-color: #333;
color: #fff !important;
}
.calendar-event-saddlebrown {
background: #8b4513 !important;
border-color: #572b0c;
color: #fff !important;
}
.calendar-event-paleturquoise {
background: #afeeee !important;
border-color: #8ababa;
color: #000 !important;
}
.calendar-event-mistyrose {
background: #ffe4e1 !important;
border-color: #ccb8b6;
color: #000 !important;
}
.calendar-event-indigo {
background: #4b0082 !important;
border-color: #2b004d;
color: #fff !important;
}
/* Modal Styles */
.modal {
display: none;
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 9999;
background-color: rgba(0, 0, 0, 0.5);
width: 100%;
height: 100%;
}
.modal-dialog {
display: flex;
justify-content: center;
align-items: center;
height: 25%; /* Adjust the height to make it smaller */
position: relative;
margin: 10% auto; /* This margin will help center the modal vertically */
max-width: 400px; /* Adjust the max-width to make it smaller */
background-color: #fff;
border-radius: 5px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.3);
}
.modal-header {
display: flex;
justify-content: center;
align-items: center;
padding-bottom: 1px;
border-bottom: 1px solid #ccc;
}
.modal-title {
display: flex;
justify-content: center;
align-items: center;
margin: 0;
font-size: 18px;
}
.modal-footer {
display: flex;
justify-content: center;
align-items: center;
padding-top: 4px;
border-top: 1px solid #ccc;
}
.close {
display: flex;
justify-content: center;
align-items: center;
position: absolute;
top: 5px;
inset-inline-end: 5px;
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;
}
+26 -57
View File
@@ -1,73 +1,42 @@
template(name="board")
if isConverting
+boardConversionProgress
else if isBoardReady
if isBoardReady.get
if currentBoard
if onlyShowCurrentCard
+cardDetails(currentCard)
else
+boardBody
else
//-- XXX We need a better error message in case the board has been archived
+message(label="board-not-found")
//-- | {{goHome}}
//- XXX We need a better error message in case the board has been archived
//+message(label="board-not-found")
{{goHome}}
else
+spinner
template(name="boardBody")
if notDisplayThisBoard
| {{_ 'tableVisibilityMode-allowPrivateOnly'}}
else
// Debug information (remove in production)
if debugBoardState
.debug-info(style="position: fixed; top: 0; inset-inline-start: 0; background: rgba(0,0,0,0.8); color: white; padding: 10px; z-index: 9999; font-size: 12px;")
| {{_ 'board'}}: {{currentBoard.title}} | {{_ 'view'}}: {{boardView}} | {{_ 'has-swimlanes'}}: {{hasSwimlanes}} | {{_ 'swimlanes'}}: {{currentBoard.swimlanes.length}}
.board-wrapper(class=currentBoard.colorClass class="{{#if isMiniScreen}}mobile-view{{/if}}")
.board-canvas.js-swimlanes.dragscroll(
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 currentBoard.showDependencies
+dependencyOverlay
if showOverlay
.board-overlay
if currentBoard.isTemplatesBoard
each currentBoard.swimlanes
+swimlane(this)
else if isViewSwimlanes
if hasSwimlanes
each currentBoard.swimlanes
+swimlane(this)
else
// Fallback: If no swimlanes exist, show lists instead of empty message
+listsGroup(currentBoard)
else if isViewLists
+listsGroup(currentBoard)
else if isViewCalendar
+calendarView
else if isViewGantt
+ganttView
else if isViewTable
+tableView
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)
+sidebar
.board-wrapper(class=currentBoard.colorClass)
+sidebar
.board-canvas.js-swimlanes.js-perfect-scrollbar(
class="{{#if Sidebar.isOpen}}is-sibling-sidebar-open{{/if}}"
class="{{#if MultiSelection.isActive}}is-multiselection-active{{/if}}"
class="{{#if draggingActive.get}}is-dragging-active{{/if}}")
if showOverlay.get
.board-overlay
if currentBoard.isTemplatesBoard
each currentBoard.swimlanes
+swimlane(this)
else if isViewSwimlanes
each currentBoard.swimlanes
+swimlane(this)
else if isViewLists
+listsGroup(currentBoard)
else if isViewCalendar
+calendarView
else
+listsGroup(currentBoard)
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
+55
View File
@@ -0,0 +1,55 @@
@import 'nib'
position()
if arguments[0] == cover || arguments[0] == fixed-cover
if arguments[0] == cover
position: absolute
else
position: fixed
left: 0
right: 0
top: 0
bottom: 0
else
position: arguments
.board-wrapper
position: cover
overflow-x: hidden
overflow-y: hidden
.board-canvas
position: cover
transition: margin .1s
overflow-y: auto
&.is-sibling-sidebar-open
margin-right: 248px
.board-overlay
position: fixed-cover
top: -100px
right: -400px
background: black
opacity: 0.33
animation: fadeIn 0.2s
z-index: 16
&.is-dragging-active
.open-minicard-composer,
.minicard-wrapper.is-checked
display: none
@media screen and (max-width: 800px)
.board-wrapper
.board-canvas
.swimlane
border-bottom: 1px solid #CCC
display: flex
flex-direction: column
margin: 0
padding: 0 0px 0px 0
overflow-x: hidden
overflow-y: auto
File diff suppressed because it is too large. Load diff
+231
View File
@@ -0,0 +1,231 @@
// We define a set of six board colors that we took from the FlatUI palette.
// http://flatuicolors.com
//
// XXX Centralizing all these properties in a single file just because their
// value is derived from the same color, doesn't make any sense. We should
// create a mixin/macro that would generate 6 versions of a given property and
// dispatch this list in the other stylus files.
setBoardColor(color)
&#header,
&.sk-spinner div,
.board-backgrounds-list &.background-box,
.board-list & a
background-color: color
.is-selected .minicard
border-left: 3px solid color
button[type=submit].primary, input[type=submit].primary
background-color: darken(color, 20%)
&.pop-over .pop-over-list li a:not(.disabled):hover,
.sidebar .sidebar-content .sidebar-btn:hover,
.sidebar-list li a:hover
background-color: lighten(color, 10%)
&#header ul li.current, &#header-quick-access ul li.current
border-bottom: 2px solid lighten(color, 10%)
&#header-quick-access
background: darken(color, 10%)
color: white
&#header #header-main-bar .board-header-btn.emphasis
background: complement(color)
&:hover,
.board-header-btn-close
background: darken(complement(color), 10%)
&:hover .board-header-btn-close
background: darken(complement(color), 20%)
.materialCheckBox.is-checked
border-bottom: 2px solid color
border-right: 2px solid color
.is-multiselection-active .multi-selection-checkbox
&.is-checked + .minicard
background: lighten(color, 90%)
&:not(.is-checked) + .minicard:hover:not(.minicard-composer)
background: lighten(color, 97%)
.toggle-label
&:after
background-color: darken(color, 20%)
.toggle-switch:checked ~ .toggle-label
background-color: lighten(color, 20%)
&:after
background-color: darken(color, 20%)
@media screen and (max-width: 800px)
&.pop-over .header
background: color
color: white
&#header ul li.current, &#header-quick-access ul li.current
border-bottom: 4px solid lighten(color, 20%)
.board-color-nephritis
setBoardColor(#27AE60)
.board-color-pomegranate
setBoardColor(#C0392B)
.board-color-belize
setBoardColor(#2980B9)
.board-color-wisteria
setBoardColor(#8E44AD)
.board-color-midnight
setBoardColor(#2C3E50)
.board-color-pumpkin
setBoardColor(#E67E22)
.board-color-moderatepink
setBoardColor(#CD5A91)
.board-color-strongcyan
setBoardColor(#00AECC)
.board-color-limegreen
setBoardColor(#4BBF6B)
.board-color-dark
setBoardColor(#2C3E51)
/* Not hidden in dark mode.
card fields: received, start, due, end, members, requested, assigned
.card-details-item.card-details-item-received,
.card-details-item.card-details-item-start,
.card-details-item.card-details-item-due,
.card-details-item.card-details-item-end,
.card-details-item.card-details-item-members,
.card-details-item.card-details-item-name { display:none; }
.card-details-items:empty { display:none; }
*/
// DARK MODE, when dark background mode selected.
// Modified version from https://github.com/wekan/wekan/wiki/Custom-CSS-themes#dark-theme
// In progress, please send pull requests to fix remaining visibility issues.
.ui-sortable,
.swimlane,
.swimlane >.swimlane-header-wrap,
.swimlane >.list.js-list,
.swimlane >.list-composer.js-list-composer,
.list-body,
.list,
.list-composer,
.sidebar-content,
.card-details
background-color:#2C3E50
.card-details h3,
.card-details-items,
.card-checklist-items .ui-sortable,
.card-subtasks-items,
.activities,
.material-toggle-switch
color:#bbbbbb
.list-header
background-color: #888888
.board-widget,
.board-widget-labels,
.board-widget-members
color: #aaaaaa
/* popup menu titles (boards, swimlanes, lists, cards, labels) */
.pop-over >.header
display:none;
/* HIDE UNTIL HOVER -------------------------------------------------- */
/* header "+" button */
#header-quick-access .fa-plus
display:none
#header-quick-access:hover .fa-plus
display:inherit
/* "add card" links (use visibility rather than display so items don't jump) */
.open-minicard-composer
visibility:hidden
.list.js-list:hover .open-minicard-composer
visibility:visible
.list-header-menu
visibility:hidden
.list.js-list:hover .list-header-menu
visibility:visible
/* "add list/swimlane" links (use visibility rather than display so items don't jump) */
.list.js-list-composer >.list-header
visibility:hidden
.list.js-list-composer:hover >.list-header
visibility:visible
/* headers */
#header-quick-access, #header
background-color:rgba(0,0,0,.75) !important
#header .board-header-btn:hover
background-color:rgba(255,255,255,0.3) !important
/* foregrounds: swimlanes, lists */
.list >.list-header, .swimlane-header
color:rgba(255,255,255,.7)
/* minicards */
.minicard
background-color:rgba(255,255,255,.4)
.minicard-wrapper.is-selected .minicard,
.minicard:hover,
.minicard-composer.js-composer,
.open-minicard-composer:hover
background-color:rgba(255,255,255,.8) !important
color:#000
.minicard, .minicard .badge
color:#fff
.minicard:hover .badge, .minicard-wrapper.is-selected .badge
color:#000
/* cards */
.card-details .card-details-header
background-color:#ccc
/* sidebar */
.sidebar-tongue, .sidebar-shadow
background-color:#666 !important
.sidebar-content h3, .sidebar-content h2, .sidebar-content
color:rgba(255,255,255,.7) !important
.board-color-relax
setBoardColor(#27AE61)
// RELAX MODE: light green backgroud, when green background color selected.
.ui-sortable
background-color:#a7e366
.list-header
background-color:#a7e366
border-bottom: 6px solid #a7e366
.list-body
background-color:#a7e366
.list
border-left: 1px dotted #000000
-956
View File
@@ -1,956 +0,0 @@
.integration-form {
padding: 0.7vh 0.7vw;
border-bottom: 1px solid #ccc;
}
.flex,
.option {
display: -webkit-box;
display: -moz-box;
display: -webkit-flex;
display: -moz-flex;
display: -ms-flexbox;
display: flex;
}
.option {
-webkit-border-radius: 0.4vw;
border-radius: 0.4vw;
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-inline-start: 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;
inset-inline-end: 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;
inset-inline-end: 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: inline-start !important;
margin-bottom: 0 !important;
border-inline-start: 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;
inset-inline-end: 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;
inset-inline-end: 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;
inset-inline-end: 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-inline-start: none !important;
border-inline-end: 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;
inset-inline-start: auto !important;
inset-inline-end: auto !important;
top: auto !important;
bottom: auto !important;
transform: none !important;
}
.mobile-mode .list:first-child {
margin-inline-start: 0 !important;
margin-top: 0 !important;
}
.mobile-mode .list:last-child {
margin-inline-end: 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-inline-start: none !important;
border-inline-end: 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-inline-start: none !important;
border-inline-end: 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-inline-start: none !important;
border-inline-end: 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;
inset-inline-start: auto !important;
inset-inline-end: 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-inline-start: none !important;
border-inline-end: 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;
inset-inline-start: auto !important;
inset-inline-end: 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;
}
/* #6419: make the board-header action buttons touch-friendly by VIEWPORT WIDTH,
not only when the mobile-mode class is toggled. The measured default at a phone
width was 28px tall / 13px font (too small to tap reliably). Enlarge vertically
+ bump the icon/font; width is left to flex so the existing horizontal layout
is unchanged. Applies on real phones whether or not mobile-mode auto-applied. */
@media screen and (max-width: 800px) {
.board-header-btns .board-header-btn {
min-height: 40px;
padding-top: 8px;
padding-bottom: 8px;
font-size: 15px;
}
.board-header-btns .board-header-btn i.fa {
font-size: 16px;
}
}
+123 -331
View File
@@ -1,184 +1,142 @@
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
a(class="{{#if currentUser.isBoardAdmin}}js-edit-board-title{{else}}is-disabled{{/if}}")
+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="js-sort-cards {{#if isSortActive }}emphasis{{/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
if currentUser
+boardButtons
.board-header-btns.right
.board-header-btns.left
unless isMiniScreen
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
if currentUser
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}}")
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
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="js-sort-cards {{#if isSortActive }}emphasis{{/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.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}}
else
a.board-header-btn.js-log-in(
title="{{_ 'log-in'}}")
i.fa.fa-sign-in
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
.board-header-btns.right
if currentBoard
if isMiniScreen
if currentUser
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
//if showSort
//
a.board-header-btn.js-open-sort-view(title="{{_ 'sort-desc'}}")
//
i.fa(class="{{directionClass}}")
//
span {{_ 'sort'}}{{_ listSortShortDesc}}
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-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'}}")
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}}
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'}}
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
if currentUser.isAdmin
a.board-header-btn.js-open-rules-view(title="{{_ 'rules'}}")
i.fa.fa-magic
span {{_ 'rules'}}
else if currentUser.isBoardAdmin
a.board-header-btn.js-open-rules-view(title="{{_ 'rules'}}")
i.fa.fa-magic
span {{_ 'rules'}}
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-th-large
span {{#if currentUser.profile.boardView}}{{_ currentUser.profile.boardView}}{{else}}{{_ 'board-view-lists'}}{{/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
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
if $eq boardView 'board-view-table'
i.fa.fa-table
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 $eq boardView 'board-view-table'
| {{_ 'board-view-table'}}
a.board-header-btn.js-toggle-dependencies(
title="{{#if currentBoard.showDependencies}}{{_ 'hide-dependencies'}}{{else}}{{_ 'show-dependencies'}}{{/if}}"
class="{{#if currentBoard.showDependencies}}emphasis{{/if}}")
i.fa.fa-link
span {{#if currentBoard.showDependencies}}{{_ 'hide-dependencies'}}{{else}}{{_ 'show-dependencies'}}{{/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-bars
.separator
a.board-header-btn.js-toggle-sidebar
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
span.sub-name {{_ 'private-desc'}}
if notAllowPrivateVisibilityOnly
li
with "public"
a.js-select-visibility
i.fa.fa-globe
| {{_ 'public'}}
if visibilityCheck
i.fa.fa-check
span.sub-name {{_ 'public-desc'}}
li
with "public"
a.js-select-visibility
i.fa.fa-globe.colorful
| {{_ 'public'}}
if visibilityCheck
i.fa.fa-check
span.sub-name {{_ 'public-desc'}}
template(name="boardChangeVisibilityPopup")
+boardVisibilityList
@@ -188,7 +146,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
@@ -196,7 +154,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
@@ -204,60 +162,22 @@ 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
span.sub-name {{_ 'muted-info'}}
template(name="boardChangeViewPopup")
ul.pop-over-list
li
with "board-view-swimlanes"
a.js-open-swimlanes-view
i.fa.fa-th-large
| {{_ '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
| {{_ '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
| {{_ '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
li
with "board-view-table"
a.js-open-table-view
i.fa.fa-table
| {{_ 'board-view-table'}}
if $eq Utils.boardView "board-view-table"
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'
if $eq visibility.get 'public'
span.fa.fa-globe.colorful
= " "
| {{{_ 'board-public-info'}}}
@@ -274,110 +194,6 @@ template(name="createBoard")
| /
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'}}.
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'}}.
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'
span.fa.fa-globe.colorful
= " "
| {{{_ 'board-public-info'}}}
else
span.fa.fa-lock.colorful
= " "
| {{{_ 'board-private-info'}}}
a.js-change-visibility {{_ 'change'}}.
input.primary.wide(type="submit" value="{{_ 'create'}}")
span.quiet
| {{_ 'or'}}
a.js-import-board {{_ 'import'}}
span.quiet
| /
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
template(name="boardChangeTitlePopup")
form
label
@@ -391,27 +207,3 @@ template(name="boardChangeTitlePopup")
template(name="boardCreateRulePopup")
p {{_ 'close-board-pop'}}
button.js-confirm.negate.full(type="submit") {{_ 'archive'}}
template(name="cardsSortPopup")
ul.pop-over-list
li
a.js-sort-due
i.fa.fa-calendar
| {{_ 'due-date'}}
hr
li
a.js-sort-title
i.fa.fa-sort-alpha-asc
| {{_ 'title-alphabetically'}}
hr
li
a.js-sort-created-desc
i.fa.fa-arrow-down
| {{_ 'created-at-newest-first'}}
hr
li
a.js-sort-created-asc
i.fa.fa-arrow-up
| {{_ 'created-at-oldest-first'}}
+202 -540
View File
@@ -1,585 +1,247 @@
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';
Template.boardMenuPopup.events({
'click .js-rename-board': Popup.open('boardChangeTitle'),
'click .js-custom-fields'() {
Sidebar.setView('customFields');
Popup.close();
},
'click .js-open-archives'() {
Sidebar.setView('archives');
Popup.close();
},
'click .js-change-board-color': Popup.open('boardChangeColor'),
'click .js-change-language': Popup.open('changeLanguage'),
'click .js-archive-board ': Popup.afterConfirm('archiveBoard', function() {
const currentBoard = Boards.findOne(Session.get('currentBoard'));
currentBoard.archive();
// XXX We should have some kind of notification on top of the page to
// confirm that the board was successfully archived.
FlowRouter.go('home');
}),
'click .js-delete-board': Popup.afterConfirm('deleteBoard', function() {
const currentBoard = Boards.findOne(Session.get('currentBoard'));
Popup.close();
Boards.remove(currentBoard._id);
FlowRouter.go('home');
}),
'click .js-outgoing-webhooks': Popup.open('outgoingWebhooks'),
'click .js-import-board': Popup.open('chooseBoardSource'),
'click .js-subtask-settings': Popup.open('boardSubtaskSettings'),
});
/*
const DOWNCLS = 'fa-sort-down';
const UPCLS = 'fa-sort-up';
*/
const sortCardsBy = new ReactiveVar('');
// Persist the card sort so it survives a page reload. Session is in-memory and
// is reset on reload, which is why "sort by due date" kept reverting to the
// default. See https://github.com/wekan/wekan/issues/5886
const CARDS_SORT_BY_STORAGE_KEY = 'wekan-cards-sortBy';
function setCardsSortBy(sortBy) {
Session.set('sortBy', sortBy);
try {
if (sortBy) {
window.localStorage.setItem(CARDS_SORT_BY_STORAGE_KEY, JSON.stringify(sortBy));
} else {
window.localStorage.removeItem(CARDS_SORT_BY_STORAGE_KEY);
}
} catch (e) {}
}
Template.boardMenuPopup.helpers({
exportUrl() {
const params = {
boardId: Session.get('currentBoard'),
};
const queryParams = {
authToken: Accounts._storedLoginToken(),
};
return FlowRouter.path('/api/boards/:boardId/export', params, queryParams);
},
exportFilename() {
const boardId = Session.get('currentBoard');
return `wekan-export-board-${boardId}.json`;
},
});
Template.boardChangeTitlePopup.events({
async submit(event, templateInstance) {
event.preventDefault();
const newTitle = templateInstance
.$('.js-board-name')
.val()
.trim();
const newDesc = templateInstance
.$('.js-board-desc')
.val()
.trim();
submit(evt, tpl) {
const newTitle = tpl.$('.js-board-name').val().trim();
const newDesc = tpl.$('.js-board-desc').val().trim();
if (newTitle) {
const board = Utils.getCurrentBoard();
if (board) {
await board.rename(newTitle);
await board.setDescription(newDesc);
}
Popup.back();
this.rename(newTitle);
this.setDescription(newDesc);
Popup.close();
}
evt.preventDefault();
},
});
Template.boardHeaderBar.onCreated(function () {
Meteor.subscribe('tableVisibilityModeSettings');
// Restore the persisted card sort after a reload (Session is in-memory), so
// both the sorting and the sort icon are remembered. See #5886.
if (!Session.get('sortBy')) {
try {
const stored = window.localStorage.getItem(CARDS_SORT_BY_STORAGE_KEY);
if (stored) {
Session.set('sortBy', JSON.parse(stored));
}
} catch (e) {}
}
});
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();
const currentBoard = Boards.findOne(Session.get('currentBoard'));
return currentBoard && currentBoard.getWatchLevel(Meteor.userId());
},
isStarred() {
const boardId = Session.get('currentBoard');
const user = ReactiveCache.getCurrentUser();
const user = Meteor.user();
return user && user.hasStarred(boardId);
},
// Only show the star counter if the number of star is greater than 2
showStarCounter() {
const currentBoard = Utils.getCurrentBoard();
const currentBoard = Boards.findOne(Session.get('currentBoard'));
return currentBoard && currentBoard.stars >= 2;
},
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();
events() {
return [{
'click .js-edit-board-title': Popup.open('boardChangeTitle'),
'click .js-star-board'() {
Meteor.user().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'() {
const currentUser = Meteor.user();
if ((currentUser.profile || {}).boardView === 'board-view-swimlanes') {
currentUser.setBoardView('board-view-cal');
} else if ((currentUser.profile || {}).boardView === 'board-view-lists') {
currentUser.setBoardView('board-view-swimlanes');
} else if ((currentUser.profile || {}).boardView === 'board-view-cal') {
currentUser.setBoardView('board-view-lists');
} else {
currentUser.setBoardView('board-view-swimlanes');
}
}
}
},
'click .js-toggle-sidebar'() {
Sidebar.toggle();
},
'click .js-open-filter-view'() {
Sidebar.setView('filter');
},
'click .js-filter-reset'(evt) {
evt.stopPropagation();
Sidebar.setView();
Filter.reset();
},
'click .js-open-search-view'() {
Sidebar.setView('search');
},
'click .js-open-rules-view'() {
Modal.openWide('rulesMain');
},
'click .js-multiselection-activate'() {
const currentCard = Session.get('currentCard');
MultiSelection.activate();
if (currentCard) {
MultiSelection.add(currentCard);
}
},
'click .js-multiselection-reset'(evt) {
evt.stopPropagation();
MultiSelection.disable();
},
'click .js-log-in'() {
FlowRouter.go('atSignIn');
},
}];
},
'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'() {
setCardsSortBy('');
},
'click .js-open-search-view'() {
const sidebar = getSidebarInstance();
if (sidebar) {
sidebar.setView('search');
} else {
console.warn('Sidebar not available for setView');
}
},
'click .js-toggle-dependencies'() {
const currentBoard = Utils.getCurrentBoard();
if (currentBoard) {
currentBoard.setShowDependencies(!currentBoard.showDependencies);
}
},
'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({
canModifyBoard() {
return Meteor.user() && Meteor.user().isBoardMember() && !Meteor.user().isCommentOnly();
},
});
Template.boardChangeViewPopup.events({
'click .js-open-lists-view'() {
Utils.setBoardView('board-view-lists');
Popup.back();
const CreateBoard = BlazeComponent.extendComponent({
template() {
return 'createBoard';
},
'click .js-open-swimlanes-view'() {
Utils.setBoardView('board-view-swimlanes');
Popup.back();
onCreated() {
this.visibilityMenuIsOpen = new ReactiveVar(false);
this.visibility = new ReactiveVar('private');
this.boardId = new ReactiveVar('');
},
'click .js-open-cal-view'() {
Utils.setBoardView('board-view-cal');
Popup.back();
},
'click .js-open-gantt-view'() {
Utils.setBoardView('board-view-gantt');
Popup.back();
},
'click .js-open-table-view'() {
Utils.setBoardView('board-view-table');
Popup.back();
},
});
// Shared setup for all create board popups
function setupCreateBoardState(tpl) {
tpl.visibilityMenuIsOpen = new ReactiveVar(false);
tpl.visibility = new ReactiveVar('private');
tpl.boardId = new ReactiveVar('');
// #5850: default to creating a regular board; the dedicated
// createTemplateContainerPopup sets this true in its onRendered.
Session.set('createBoardAsTemplate', false);
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';
// #5850: template boards are created via the dedicated "Add Template Board"
// flow (createTemplateContainerPopup), signalled by this Session flag, rather
// than a checkbox on the generic Create Board popup. Consume it immediately.
const addTemplateContainer = Session.get('createBoardAsTemplate') === true;
Session.set('createBoardAsTemplate', false);
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', role: 'card' },
{ title: 'List Templates', sort: 2, type: 'template-container', role: 'list' },
{ title: 'Board Templates', sort: 3, type: 'template-container', role: 'board' },
],
}),
);
// 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());
},
async 'submit'(event, tpl) {
await createBoardSubmit(tpl, event);
},
'click .js-import-board': Popup.open('chooseBoardSource'),
'click .js-board-template': Popup.open('searchElement'),
};
}
// 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 () {
// #5850: this dedicated popup always creates a template board.
Session.set('createBoardAsTemplate', true);
});
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);
},
'click .js-change-visibility'(event, tpl) {
tpl.visibilityMenuIsOpen.set(!tpl.visibilityMenuIsOpen.get());
},
async submit(event, tpl) {
await createBoardSubmit(tpl, event);
// Immediately star boards created with the headerbar popup.
await ReactiveCache.getCurrentUser().toggleBoardStar(tpl.boardId.get());
},
'click .js-import-board': Popup.open('chooseBoardSource'),
'click .js-board-template': Popup.open('searchElement'),
});
Template.boardVisibilityList.helpers({
notAllowPrivateVisibilityOnly() {
return !TableVisibilityModeSettings.findOne('tableVisibilityMode-allowPrivateOnly')?.booleanValue;
},
});
Template.boardChangeVisibilityPopup.onCreated(function () {
Meteor.subscribe('tableVisibilityModeSettings');
});
Template.boardChangeVisibilityPopup.helpers({
notAllowPrivateVisibilityOnly(){
return !TableVisibilityModeSettings.findOne('tableVisibilityMode-allowPrivateOnly')?.booleanValue;
},
visibilityCheck() {
const currentBoard = Utils.getCurrentBoard();
return this === currentBoard.permission;
return this.currentData() === this.visibility.get();
},
});
Template.boardChangeVisibilityPopup.events({
'click .js-select-visibility'() {
const currentBoard = Utils.getCurrentBoard();
const visibility = this;
if (typeof visibility === 'string') {
currentBoard.setVisibility(visibility);
Popup.back();
}
setVisibility(visibility) {
this.visibility.set(visibility);
this.visibilityMenuIsOpen.set(false);
},
});
Template.boardChangeWatchPopup.helpers({
toggleVisibilityMenu() {
this.visibilityMenuIsOpen.set(!this.visibilityMenuIsOpen.get());
},
onSubmit(evt) {
evt.preventDefault();
const title = this.find('.js-new-board-title').value;
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());
},
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'),
}];
},
}).register('createBoardPopup');
(class HeaderBarCreateBoard extends CreateBoard {
onSubmit(evt) {
super.onSubmit(evt);
// Immediately star boards crated with the headerbar popup.
Meteor.user().toggleBoardStar(this.boardId.get());
}
}).register('headerBarCreateBoardPopup');
BlazeComponent.extendComponent({
visibilityCheck() {
const currentBoard = Boards.findOne(Session.get('currentBoard'));
return this.currentData() === currentBoard.permission;
},
selectBoardVisibility() {
const currentBoard = Boards.findOne(Session.get('currentBoard'));
const visibility = this.currentData();
currentBoard.setVisibility(visibility);
Popup.close();
},
events() {
return [{
'click .js-select-visibility': this.selectBoardVisibility,
}];
},
}).register('boardChangeVisibilityPopup');
BlazeComponent.extendComponent({
watchLevel() {
const currentBoard = Utils.getCurrentBoard();
const currentBoard = Boards.findOne(Session.get('currentBoard'));
return currentBoard.getWatchLevel(Meteor.userId());
},
watchCheck() {
const currentBoard = Utils.getCurrentBoard();
return this === currentBoard.getWatchLevel(Meteor.userId());
},
});
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();
},
);
}
},
});
/*
// BlazeComponent.extendComponent was removed - this code is unused.
// Original listsortPopup component:
// {
onCreated() {
//this.sortBy = new ReactiveVar();
////this.sortDirection = new ReactiveVar();
//this.setSortBy();
this.downClass = DOWNCLS;
this.upClass = UPCLS;
},
allowedSortValues() {
const types = [];
const pushed = {};
ReactiveCache.getCurrentUser()
.getListSortTypes()
.forEach(type => {
const key = type.replace(/^-/, '');
if (pushed[key] === undefined) {
types.push({
name: key,
label: `list-label-${key}`,
shortLabel: `list-label-short-${key}`,
});
pushed[key] = 1;
}
});
return types;
},
Direction() {
return ReactiveCache.getCurrentUser().getListSortByDirection() === -1
? this.downClass
: this.upClass;
},
sortby() {
return ReactiveCache.getCurrentUser().getListSortBy();
},
setSortBy(type = null) {
const user = ReactiveCache.getCurrentUser();
if (type === null) {
type = user._getListSortBy();
} else {
let value = '';
if (type.map) {
// is an array
value = (type[1] === -1 ? '-' : '') + type[0];
}
Meteor.call('setListSortBy', value);
}
//this.sortBy.set(type[0]);
//this.sortDirection.set(type[1]);
return this.currentData() === this.watchLevel();
},
events() {
return [
{
'click .js-sort-by'(evt) {
evt.preventDefault();
const target = evt.target;
const sortby = target.getAttribute('name');
const down = !!target.querySelector(`.${this.upClass}`);
const direction = down ? -1 : 1;
this.setSortBy([sortby, direction]);
if (Utils.isMiniScreen) {
Popup.back();
}
},
return [{
'click .js-select-watch'() {
const level = this.currentData();
Meteor.call('watch', 'board', Session.get('currentBoard'), level, (err, ret) => {
if (!err && ret) Popup.close();
});
},
];
}];
},
// }.register('listsortPopup');
*/
Template.cardsSortPopup.events({
'click .js-sort-due'() {
const sortBy = {
dueAt: 1,
};
setCardsSortBy(sortBy);
sortCardsBy.set(TAPi18n.__('due-date'));
Popup.back();
},
'click .js-sort-title'() {
const sortBy = {
title: 1,
};
setCardsSortBy(sortBy);
sortCardsBy.set(TAPi18n.__('title'));
Popup.back();
},
'click .js-sort-created-asc'() {
const sortBy = {
createdAt: 1,
};
setCardsSortBy(sortBy);
sortCardsBy.set(TAPi18n.__('date-created-newest-first'));
Popup.back();
},
'click .js-sort-created-desc'() {
const sortBy = {
createdAt: -1,
};
setCardsSortBy(sortBy);
sortCardsBy.set(TAPi18n.__('date-created-oldest-first'));
Popup.back();
},
});
}).register('boardChangeWatchPopup');
+22
View File
@@ -0,0 +1,22 @@
.integration-form
padding: 5px
border-bottom: 1px solid #ccc
.flex
display: -webkit-box
display: -moz-box
display: -webkit-flex
display: -moz-flex
display: -ms-flexbox
display: flex
.option
@extends .flex
-webkit-border-radius: 3px;
border-radius: 3px;
background: #fff;
text-decoration: none;
-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;
@@ -1,42 +0,0 @@
/**
* Pure helpers for building the option labels shown in board / list /
* swimlane selectors (e.g. the move-card and copy-card popups).
*
* Regression #6135 ("numbering boards, list, swim caused some issues"):
* a previous change prepended a sequential number to every option label
* (e.g. "3. My Board"). That broke two things:
*
* 1. Boards whose name itself starts with a digit became hard to read.
* 2. First-letter keyboard navigation in the native <select> dropdown
* stopped working, because every option started with a number instead
* of the board name.
*
* The fix: numbering is OPTIONAL and OFF by default. By default the label is
* exactly the entity name, so its first character is the name's first
* character and first-letter navigation works again. When numbering is
* explicitly enabled, the number is appended as a trailing visual suffix so
* the label still STARTS with the name.
*/
/**
* Build the label for a selectable board/list/swimlane option.
*
* @param {string} name The entity (board/list/swimlane) name/title.
* @param {number} index Zero-based position in the list.
* @param {Object} [opts]
* @param {boolean} [opts.numbered=false] When true, append the 1-based
* number as a trailing suffix. When false (default), return the bare
* name so first-letter keyboard navigation keeps working.
* @returns {string} The option label.
*/
export function boardOptionLabel(name, index, { numbered = false } = {}) {
const label = name == null ? '' : String(name);
if (!numbered) {
return label;
}
const position = Number(index);
const number = Number.isFinite(position) ? position + 1 : index;
return `${label} (${number})`;
}
export default boardOptionLabel;
File diff suppressed because it is too large. Load diff
+39 -297
View File
@@ -1,307 +1,49 @@
template(name="boardList")
.wrapper
.board-list-header
.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'}}
if isSelectedMenu 'templates'
if shareTargets.length
li.menu-section-title {{_ 'share-template-with'}}
each shareTargets
li.menu-item.js-share-target(data-share-type="{{type}}", data-share-id="{{id}}", data-share-name="{{name}}", title="{{_ 'drag-template-here-to-share'}}")
span.menu-label
span.emoji-icon
i.fa.fa-share-alt
| {{name}}
.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)
// 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.AllBoardOrgs
if userHasOrgs
select.js-AllBoardOrgs#jsAllBoardOrgs("multiple")
option(value="-1") {{_ 'organizations'}} :
each orgsDatas
option(value="{{orgId}}") {{orgDisplayName}}
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
.board-search
span.emoji-icon
i.fa.fa-search
input.js-board-search-input(type="text" placeholder="{{_ 'search-boards'}}" value="{{boardSearch}}" autocomplete="off")
if boardSearch
a.board-search-clear.js-board-search-clear(title="{{_ 'filter-clear'}}")
span.emoji-icon
i.fa.fa-times
a.board-header-btn.js-open-boards-sort(title="{{_ 'sort-boards'}}" class="{{#unless isBoardsSort 'custom'}}emphasis{{/unless}}")
span.emoji-icon
i.fa.fa-sort-alpha-asc
span {{_ 'sort'}}
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
if boardsPaginationActive
.boards-pagination
button.js-boards-prev-page(class="{{#if hasBoardsPrevPage}}{{else}}disabled{{/if}}")
| &lt;
span.boards-page-info {{boardsCurrentPage}} / {{boardsTotalPages}}
button.js-boards-next-page(class="{{#if hasBoardsNextPage}}{{else}}disabled{{/if}}")
| &gt;
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'}}
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
//- #5157: reuse the board's existing backgroundImageURL as the
//- tile background on the board list page.
.board-list-item(class="{{#if backgroundImageURL}}has-background-image{{/if}}" style="{{#if backgroundImageURL}}background-image:url('{{backgroundImageURL}}');{{/if}}")
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}}")
template(name="boardsSortPopup")
ul.pop-over-list
li
a.js-boards-sort(data-sort="custom" class="{{#if isBoardsSort 'custom'}}active{{/if}}")
span.emoji-icon
i.fa.fa-arrows
| &nbsp;{{_ 'sort-boards-custom'}}
li
a.js-boards-sort(data-sort="title-asc" class="{{#if isBoardsSort 'title-asc'}}active{{/if}}")
span.emoji-icon
i.fa.fa-sort-alpha-asc
| &nbsp;{{_ 'sort-boards-title-asc'}}
li
a.js-boards-sort(data-sort="title-desc" class="{{#if isBoardsSort 'title-desc'}}active{{/if}}")
span.emoji-icon
i.fa.fa-sort-alpha-desc
| &nbsp;{{_ 'sort-boards-title-desc'}}
ul.board-list.clearfix
li.js-add-board
a.board-list-item.label {{_ 'add-board'}}
each boards
li(class="{{#if isStarred}}starred{{/if}}" class=colorClass)
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
a.js-open-board.board-list-item(href="{{pathFor 'board' id=_id slug=slug}}")
span.details
span.board-list-item-name
+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}}")
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
//
h1 {{_ 'my-boards'}}
.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'}}
File diff suppressed because it is too large. Load diff
+198
View File
@@ -0,0 +1,198 @@
@import 'nib'
$spaceBetweenTiles = 16px
.board-list
margin: 0 ($spaceBetweenTiles/2)
li
float: left
width: 25%
box-sizing: border-box
position: relative
&.starred
.fa-star,
.fa-star-o
opacity: 1
.board-list-item
overflow: hidden;
background-color: #999
color: #f6f6f6
height: 90px
font-size: 16px
line-height: 22px
border-radius: 3px
display: block
font-weight: 700
min-height: 18px
padding: 8px
margin: ($spaceBetweenTiles/2)
position: relative
text-decoration: none
&.tile
background-size: auto
background-repeat: repeat
.board-list-item-sub-name
color: rgba(255, 255, 255, .5)
display: block
font-size: 14px
font-weight: 400
line-height: 22px
.board-list-item-desc
color: #fff
display: block
font-size: 14px
font-weight: 400
line-height: 18px
.js-add-board
text-align:center
.label
font-weight: normal
line-height:90px
:hover
background-color:#939393
.fa-star,
.fa-star-o
bottom: 0
font-size: 14px
height: 18px
line-height: 18px
opacity: 0
padding: 9px 9px
position: absolute
right: 0
top: 0
transition-duration: .15s
transition-property: color, font-size, background
.fa-circle
bottom: 0;
font-size: 10px;
height: 10px;
line-height: 10px;
padding: 9px 9px;
position: absolute;
right: 0;
transition-duration: .15s
transition-property: color, font-size, background
.has-overtime-card-active
color: #eb4646 !important
.no-overtime-card-active
color: #3cb500 !important
.is-star-active
color: white
.fa-clone
position: absolute;
bottom: 0
font-size: 14px
height: 18px
line-height: 18px
opacity: 0
right: 0
padding: 9px 9px
transition-duration: .15s
transition-property: color, font-size, background
.fa-archive
position: absolute;
bottom: 0
font-size: 14px
height: 18px
line-height: 18px
opacity: 0
left: 0
padding: 9px 9px
transition-duration: .15s
transition-property: color, font-size, background
li:hover a
&:hover
.fa-star,
.fa-clone,
.fa-archive,
.fa-star-o
color: white
.fa-star,
.fa-clone,
.fa-archive,
.fa-star-o
color: white
opacity: .75
&:hover
font-size: 18px
opacity: 1
&.is-star-active
opacity: 1
.board-backgrounds-list
.board-background-select
box-sizing: border-box
display: block
float: left
width: 50%
padding-top: 12px
position: relative
z-index: 1
&:nth-child(-n + 2)
padding-top: 0
&:nth-child(2n)
padding-left: 6px
&:nth-child(2n+1)
padding-right: 6px
.background-box
color: white
border-radius: 3px
background-size: cover
display: block
height: 74px
position: relative
width: 100%
cursor: pointer
display: flex
align-items: center
justify-content: center
i.fa-check
font-size: 25px
color: white
@media screen and (max-width: 800px)
.board-list
height: 100%
overflow: scroll
li
width: 33.3%
.board-list-item
overflow: hidden
.board-list-item-sub-name
position: relative
top: -100px
left: -100px
@media screen and (max-width: 360px)
li
width: 50%
-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,129 +0,0 @@
.dependency-overlay {
position: absolute;
top: 0;
inset-inline-start: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 100;
overflow: visible;
}
/* #3392: the overlay SVG stays click-through, but each line is clickable (to
open the edit/delete popup) even though its container is pointer-events:none. */
.dependency-line {
pointer-events: stroke;
cursor: pointer;
}
.dependency-temp-line {
pointer-events: none;
}
/* #3392: drag-to-connect handle on the minicard (no mode — cards stay
clickable). Shown on hover; drag it onto another card to create a link. */
.minicard .dependency-connect-handle {
position: absolute;
top: 50%;
inset-inline-end: -7px;
right: -7px;
transform: translateY(-50%);
width: 18px;
height: 18px;
display: flex;
align-items: center;
justify-content: center;
color: #eb144c;
background: #fff;
border-radius: 50%;
box-shadow: 0 0 2px rgba(0, 0, 0, 0.4);
cursor: crosshair;
opacity: 0;
z-index: 101;
}
.minicard:hover .dependency-connect-handle {
opacity: 1;
}
.dependency-line-popup .dependency-field {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.dependency-line-popup .card-dependency-icons li a.is-selected {
border-color: #eb144c;
border-width: 2px;
}
.dependency-line-delete {
display: inline-flex;
align-items: center;
gap: 6px;
color: #eb144c;
}
/* #3392: card detail "Dependencies" rows */
.card-details-dependencies-list .card-dependency {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
}
.card-details-dependencies-list .card-dependency-link {
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-details-dependencies-list .js-dependency-type {
flex: 0 0 auto;
max-width: 130px;
}
.card-details-dependencies-list .js-dependency-color {
flex: 0 0 auto;
width: 28px;
height: 24px;
padding: 0;
border: none;
background: none;
cursor: pointer;
}
.card-dependencies-popup .dependency-field {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 6px;
}
.card-dependency-icons {
display: flex;
flex-wrap: wrap;
gap: 8px;
list-style: none;
margin: 0;
padding: 4px;
}
.card-dependency-icons li a {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px solid #ddd;
border-radius: 4px;
}
.minicard .badge.minicard-dependencies .badge-icon {
font-weight: bold;
}
@@ -1,35 +0,0 @@
template(name="dependencyOverlay")
svg.dependency-overlay.js-dependency-overlay(xmlns="http://www.w3.org/2000/svg")
defs
each dependencyMarkers
marker(id="{{markerId}}" markerWidth="8" markerHeight="8" refX="7" refY="3" orient="auto" markerUnits="userSpaceOnUse")
path(d="M0,0 L7,3 L0,6 Z" fill="{{color}}")
each dependencyLines
path.dependency-line(d="{{path}}" fill="none" stroke="{{color}}" stroke-width="2" marker-end="{{markerUrl}}")
if tempLine
path.dependency-temp-line(d="{{tempLine}}" fill="none" stroke="#eb144c" stroke-width="2" stroke-dasharray="6,4")
template(name="dependencyLinePopup")
.dependency-line-popup
label.dependency-field
span {{_ 'dependency-type'}}
select.js-line-type
each typeOption
if selected
option(value="{{id}}" selected="selected") {{_ label}}
else
option(value="{{id}}") {{_ label}}
label.dependency-field
span {{_ 'dependency-color'}}
input.js-line-color(type="color" value="{{color}}")
.dependency-field
span {{_ 'dependency-icon'}}
ul.card-dependency-icons
each dependencyIcons
li
a.js-line-icon(data-icon="{{name}}" title="{{name}}" class="{{#if selected}}is-selected{{/if}}")
i.fa(class="fa-{{name}}")
hr
a.js-line-delete.dependency-line-delete
i.fa.fa-times
| {{_ 'remove-dependency'}}
@@ -1,300 +0,0 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { ReactiveVar } from 'meteor/reactive-var';
import { Utils } from '/client/lib/utils';
import {
DEPENDENCY_ICON_CHOICES,
DEPENDENCY_TYPES,
dependencyTypeMeta,
normalizeDependencies,
} from '/models/metadata/dependencies';
// #3392: PI Program Board "Red Strings".
// Draw colored, typed connection lines (SVG paths) between cards that declare
// cardDependencies. The board stays fully usable (cards clickable) — drawing a
// new link is initiated from a small connect handle on each minicard (drag it
// onto another card, piplanning.io / Kendis / Miro style), and a link can be
// edited/deleted by clicking its line. The overlay SVG itself stays
// pointer-events:none; only the lines (and the minicard handles) capture events.
//
// Coordinates are computed from the live DOM (getBoundingClientRect of
// [data-card-id] minicards) translated into the overlay SVG's own coordinate
// space, so scrolling/resizing is handled by recomputing relative to the SVG's
// current bounding rect.
// Stable, css-safe marker id for an arrowhead of a given color.
function markerIdForColor(color) {
return `dependency-arrowhead-${String(color).replace(/[^a-zA-Z0-9]/g, '')}`;
}
Template.dependencyOverlay.onCreated(function () {
this.lines = new ReactiveVar([]);
this.markers = new ReactiveVar([]);
this.tempLine = new ReactiveVar(null); // line being drawn while dragging
// Guard against deferred (rAF / setTimeout) callbacks touching the DOM after
// the overlay is destroyed ("Can't select in removed DomRange").
this._overlayDestroyed = false;
// Rect of a card relative to the overlay SVG, or null if not rendered.
this.rectOf = (cardId, svgRect) => {
const el = document.querySelector(`[data-card-id="${cardId}"]`);
if (!el) return null;
const r = el.getBoundingClientRect();
if (r.width === 0 && r.height === 0) return null;
return { x: r.left - svgRect.left, y: r.top - svgRect.top, w: r.width, h: r.height };
};
this.recompute = () => {
if (this._overlayDestroyed || !this.view || this.view.isDestroyed) {
return;
}
const svg = this.find('.js-dependency-overlay');
if (!svg) {
return;
}
const board = Utils.getCurrentBoard();
if (!board) {
this.lines.set([]);
return;
}
const svgRect = svg.getBoundingClientRect();
const centerOf = cardId => this.rectOf(cardId, svgRect);
const cards = ReactiveCache.getCards({
boardId: board._id,
archived: false,
});
const lines = [];
const markerColors = new Set();
cards.forEach(card => {
const deps = normalizeDependencies(card.cardDependencies);
if (deps.length === 0) {
return;
}
const sourceRect = centerOf(card._id);
if (!sourceRect) {
return;
}
deps.forEach(dep => {
const targetRect = centerOf(dep.cardId);
if (!targetRect) {
return;
}
const meta = dependencyTypeMeta(dep.type);
// Draw the arrow from the prerequisite card's right edge to the
// dependent card's left edge. For forward relations (blocks/fixes/
// related-to) the source card is the prerequisite; for the reverse
// relations (is-blocked-by/is-fixed-by) the target is.
const prereq = meta.forward ? sourceRect : targetRect;
const dependent = meta.forward ? targetRect : sourceRect;
const x1 = prereq.x + prereq.w;
const y1 = prereq.y + prereq.h / 2;
const x2 = dependent.x;
const y2 = dependent.y + dependent.h / 2;
const dx = Math.max(40, Math.abs(x2 - x1) / 2);
const path = `M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`;
const markerId = markerIdForColor(dep.color);
if (meta.directed) {
markerColors.add(dep.color);
}
lines.push({
path,
color: dep.color,
type: dep.type,
icon: dep.icon,
directed: meta.directed,
markerId: meta.directed ? markerId : '',
// Source card (owns the dependency) and target card.
fromId: card._id,
toId: dep.cardId,
});
});
});
this.lines.set(lines);
this.markers.set(
[...markerColors].map(color => ({
color,
markerId: markerIdForColor(color),
})),
);
};
this.scheduleRecompute = () => {
if (this._overlayDestroyed) return;
if (this._raf) {
window.cancelAnimationFrame(this._raf);
}
this._raf = window.requestAnimationFrame(() => {
this._raf = null;
this.recompute();
});
};
});
Template.dependencyOverlay.onRendered(function () {
const instance = this;
const svg = this.find('.js-dependency-overlay');
this.scrollEl = svg ? this.firstNode.closest('.board-canvas') : null;
this.autorun(() => {
const board = Utils.getCurrentBoard();
if (board) {
ReactiveCache.getCards({ boardId: board._id, archived: false });
}
instance.scheduleRecompute();
});
this.onScroll = () => instance.scheduleRecompute();
this.onResize = () => instance.scheduleRecompute();
if (this.scrollEl) {
this.scrollEl.addEventListener('scroll', this.onScroll, { passive: true });
}
window.addEventListener('scroll', this.onScroll, { passive: true });
window.addEventListener('resize', this.onResize);
// --- drag-to-connect (from a minicard's connect handle) -----------------
// Find the card under a viewport point. The overlay is pointer-events:none,
// so elementsFromPoint returns the minicards directly.
const cardIdAtPoint = (clientX, clientY) => {
const els = document.elementsFromPoint(clientX, clientY);
for (const el of els) {
if (svg && svg.contains(el)) continue;
const card = el.closest && el.closest('[data-card-id]');
if (card) return card.getAttribute('data-card-id');
}
return null;
};
this.onMouseDown = e => {
if (e.button !== 0) return;
const handle =
e.target.closest && e.target.closest('.js-dependency-connect-handle');
if (!handle) return;
const cardEl = handle.closest('[data-card-id]');
if (!cardEl) return;
// Beat the card drag-sort: capture-phase + stop propagation.
e.preventDefault();
e.stopPropagation();
instance.dragSourceId = cardEl.getAttribute('data-card-id');
};
this.onMouseMove = e => {
if (!instance.dragSourceId || !svg) return;
const svgRect = svg.getBoundingClientRect();
const from = instance.rectOf(instance.dragSourceId, svgRect);
if (!from) return;
const x1 = from.x + from.w;
const y1 = from.y + from.h / 2;
const x2 = e.clientX - svgRect.left;
const y2 = e.clientY - svgRect.top;
const dx = Math.max(40, Math.abs(x2 - x1) / 2);
instance.tempLine.set(
`M ${x1} ${y1} C ${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`,
);
};
this.onMouseUp = e => {
if (!instance.dragSourceId) return;
const sourceId = instance.dragSourceId;
instance.dragSourceId = null;
instance.tempLine.set(null);
const targetId = cardIdAtPoint(e.clientX, e.clientY);
if (targetId && targetId !== sourceId) {
const card = ReactiveCache.getCard(sourceId);
if (card && typeof card.addDependency === 'function') {
card.addDependency(targetId);
}
}
};
// Capture phase so the connect drag starts before the sortable grabs the card.
document.addEventListener('mousedown', this.onMouseDown, true);
document.addEventListener('mousemove', this.onMouseMove);
document.addEventListener('mouseup', this.onMouseUp);
this._initTimeout = Meteor.setTimeout(() => instance.scheduleRecompute(), 300);
});
Template.dependencyOverlay.onDestroyed(function () {
this._overlayDestroyed = true;
if (this.scrollEl) {
this.scrollEl.removeEventListener('scroll', this.onScroll);
}
window.removeEventListener('scroll', this.onScroll);
window.removeEventListener('resize', this.onResize);
if (this.onMouseDown) document.removeEventListener('mousedown', this.onMouseDown, true);
if (this.onMouseMove) document.removeEventListener('mousemove', this.onMouseMove);
if (this.onMouseUp) document.removeEventListener('mouseup', this.onMouseUp);
if (this._raf) {
window.cancelAnimationFrame(this._raf);
}
if (this._initTimeout) {
Meteor.clearTimeout(this._initTimeout);
}
});
Template.dependencyOverlay.helpers({
dependencyLines() {
return Template.instance().lines.get();
},
dependencyMarkers() {
return Template.instance().markers.get();
},
tempLine() {
return Template.instance().tempLine.get();
},
markerUrl() {
return this.markerId ? `url(#${this.markerId})` : '';
},
});
Template.dependencyOverlay.events({
'click .dependency-line'(event) {
event.preventDefault();
event.stopPropagation();
// `this` is the line data object (fromId/toId/type/color/icon).
Popup.open('dependencyLine').call(this, event);
},
});
// #3392: popup to edit or delete an on-board dependency line. Data context is the
// line object { fromId, toId, type, color, icon }.
Template.dependencyLinePopup.helpers({
typeOption() {
const current = this.type;
return DEPENDENCY_TYPES.map(t => ({
id: t.id,
label: `dependency-type-${t.id}`,
selected: t.id === current,
}));
},
color() {
return this.color;
},
dependencyIcons() {
const current = this.icon;
return DEPENDENCY_ICON_CHOICES.map(name => ({ name, selected: name === current }));
},
});
Template.dependencyLinePopup.events({
'change .js-line-type'(event) {
const card = ReactiveCache.getCard(this.fromId);
if (card) card.setDependencyProps(this.toId, { type: event.currentTarget.value });
},
'change .js-line-color'(event) {
const card = ReactiveCache.getCard(this.fromId);
if (card) card.setDependencyProps(this.toId, { color: event.currentTarget.value });
},
'click .js-line-icon'(event) {
const card = ReactiveCache.getCard(this.fromId);
if (card) card.setDependencyProps(this.toId, { icon: event.currentTarget.dataset.icon });
},
'click .js-line-delete'() {
const card = ReactiveCache.getCard(this.fromId);
if (card) card.removeDependency(this.toId);
Popup.back();
},
});
+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-inline-end: 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-inline-start: 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-inline-start: 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);
},
});
-143
View File
@@ -1,143 +0,0 @@
/* Board Table view: search + pagination controls, styled to match the
Admin Panel / People page (see client/components/settings/peopleBody.css).
The table itself reuses the My Cards table styling (.my-cards-board-table). */
.table-view-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin: 1vh 0;
}
.table-view-search-box {
display: flex;
flex-direction: row;
height: 34px;
}
.table-view-search-box input {
border: 1px solid #ccc;
border-radius: 0.4vw;
padding: 0 8px;
}
.table-view-search-box button {
min-width: 90px;
margin-inline-start: 6px;
background: #01628c;
color: #fff;
border: none;
border-radius: 0.4vw;
cursor: pointer;
padding: 0 12px;
}
.table-view-search-box button:hover {
background: #005377;
}
.table-view-pagination {
display: flex;
align-items: center;
gap: 6px;
}
.table-view-pagination button {
min-width: 32px;
height: 30px;
border: 1px solid #bbb;
background: #fff;
border-radius: 0.4vw;
cursor: pointer;
}
.table-view-pagination button.disabled {
opacity: 0.5;
pointer-events: none;
}
.table-view-page-info {
min-width: 72px;
text-align: center;
color: #333;
}
/* Sortable column headers (Excel-like): clickable, with a sort-direction
arrow appended by the sortIndicator helper. */
.my-cards-board-table th.js-table-view-sort {
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.my-cards-board-table th.js-table-view-sort:hover {
color: #01628c;
}
/* Leftmost "Edit" cell: an Edit link (pencil icon + text) styled like the one
on the Admin Panel / People page, opening the card when clicked. */
.my-cards-board-table td.table-view-cell-edit {
white-space: nowrap;
}
.my-cards-board-table td.table-view-cell-edit .edit-card {
cursor: pointer;
}
.my-cards-board-table td.table-view-cell-edit .edit-card i.fa {
margin-inline-end: 4px;
}
/* Word-wrap the Card / List / Swimlane cells so long text breaks onto the next
line instead of overflowing across the other columns. */
.my-cards-board-table td.table-view-cell-wrap {
max-width: 250px;
white-space: normal;
overflow-wrap: break-word;
word-break: break-word;
}
/* The +viewer template wraps its markdown in a block-level .viewer div. Inside
a table cell it should not add extra vertical margins, and inside a label it
must stay inline so the label keeps its pill shape. */
.my-cards-board-table .viewer {
margin: 0;
}
.my-cards-board-table .viewer p {
margin: 0;
}
.my-cards-board-table .card-label .viewer,
.my-cards-board-table .card-label .viewer p {
display: inline;
}
/* Word-wrap the Labels cell so each label pill wraps onto the next line instead
of stretching the column or overflowing across the date columns. */
.my-cards-board-table td.table-view-cell-labels {
max-width: 250px;
}
.my-cards-board-table td.table-view-cell-labels .div {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.my-cards-board-table td.table-view-cell-labels .card-label {
white-space: normal;
overflow-wrap: break-word;
word-break: break-word;
}
/* Keep each date in a single column; the clickable badge already styles itself
like the card detail / Gantt date badges. */
.my-cards-board-table td.table-view-cell-date {
white-space: nowrap;
}
.my-cards-board-table td.table-view-cell-date .card-date {
cursor: pointer;
}
-107
View File
@@ -1,107 +0,0 @@
template(name="tableView")
if currentBoard
.wrapper
.table-view-controls
.table-view-search-box
input.js-table-view-search(type="text" placeholder="{{_ 'search'}}" aria-label="{{_ 'search'}}")
button.js-table-view-search-button(type="button")
i.fa.fa-search
| {{_ 'search'}}
.table-view-pagination
button.js-table-view-prev-page(type="button" class="{{#unless hasPrevPage}}disabled{{/unless}}" aria-label="{{_ 'previous'}}")
i.fa.fa-chevron-left
span.table-view-page-info {{currentPage}} / {{totalPages}}
button.js-table-view-next-page(type="button" class="{{#unless hasNextPage}}disabled{{/unless}}" aria-label="{{_ 'next'}}")
i.fa.fa-chevron-right
table.my-cards-board-table
caption.sr-only {{currentBoard.title}}
thead
tr
th(scope="col")
th.js-table-view-sort(scope="col" data-sort="card") {{_ "card"}} {{sortIndicator 'card'}}
th.js-table-view-sort(scope="col" data-sort="list") {{_ "list"}} {{sortIndicator 'list'}}
th.js-table-view-sort(scope="col" data-sort="swimlane") {{_ "swimlane"}} {{sortIndicator 'swimlane'}}
unless isMiniScreen
th(scope="col") {{_ "assignees"}}
th(scope="col") {{_ "members"}}
th(scope="col") {{_ "labels"}}
if showReceivedColumn
th.js-table-view-sort(scope="col" data-sort="received") {{_ "card-received"}} {{sortIndicator 'received'}}
if showStartColumn
th.js-table-view-sort(scope="col" data-sort="start") {{_ "card-start"}} {{sortIndicator 'start'}}
if showDueColumn
th.js-table-view-sort(scope="col" data-sort="due") {{_ "card-due"}} {{sortIndicator 'due'}}
if showEndColumn
th.js-table-view-sort(scope="col" data-sort="end") {{_ "card-end"}} {{sortIndicator 'end'}}
tbody
each row in rows
tr
td.table-view-cell-edit
a.edit-card.js-table-view-edit-card(href=row.card.originRelativeUrl data-card-id=row.card._id title="{{_ 'edit'}}")
i.fa.fa-pencil-square-o
| {{_ 'edit'}}
td.table-view-cell-wrap
unless isMiniScreen
.my-cards-board-badge(class=row.colorClass)
.my-cards-card-title-table
+viewer
= row.title
td.table-view-cell-wrap
+viewer
= row.listTitle
td.table-view-cell-wrap
+viewer
= row.swimlaneTitle
unless isMiniScreen
td
.div
each assignee in row.card.getAssignees
a.name
+userAvatar(userId=assignee noRemove=true)
td
.div
each member in row.card.getMembers
a.name
+userAvatar(userId=member noRemove=true)
td.table-view-cell-labels
.div
each label in row.labels
span.card-label(class="card-label-{{label.color}}" title="{{label.name}}")
+viewer
= label.name
if showReceivedColumn
td.table-view-cell-date
with row.card
if getReceived
+cardReceivedDate
else
if canModifyCard
a.card-label.add-label.js-received-date(title="{{_ 'card-received'}}")
i.fa.fa-plus
if showStartColumn
td.table-view-cell-date
with row.card
if getStart
+cardStartDate
else
if canModifyCard
a.card-label.add-label.js-start-date(title="{{_ 'card-start'}}")
i.fa.fa-plus
if showDueColumn
td.table-view-cell-date
with row.card
if getDue
+cardDueDate
else
if canModifyCard
a.card-label.add-label.js-due-date(title="{{_ 'card-due'}}")
i.fa.fa-plus
if showEndColumn
td.table-view-cell-date
with row.card
if getEnd
+cardEndDate
else
if canModifyCard
a.card-label.add-label.js-end-date(title="{{_ 'card-end'}}")
i.fa.fa-plus
-254
View File
@@ -1,254 +0,0 @@
import { ReactiveCache } from '/imports/reactiveCache';
import { Utils } from '/client/lib/utils';
// Board "Table" view: lists every card of the current board in a table that
// reuses the My Cards table styling (the .my-cards-board-table CSS classes in
// client/components/main/myCards.css). It is the per-board counterpart of the
// My Cards table view, which spans all boards.
//
// Search, column sorting (Excel-like) and pagination all run client-side: the
// board's cards are already loaded reactively via board.cards(), so there is no
// need for the server-side limit/skip publication the Admin People page uses.
const rowsPerPage = 25;
Template.tableView.onCreated(function () {
this.searchQuery = new ReactiveVar('');
this.sortField = new ReactiveVar('card'); // card | list | swimlane | due
this.sortDirection = new ReactiveVar(1); // 1 ascending, -1 descending
this.page = new ReactiveVar(1);
this.filteredRows = new ReactiveVar([]);
// Recompute the flat, filtered and sorted row list whenever the board cards,
// search query or sort order change. Pagination is applied separately in the
// rows() helper so paging does not rebuild the whole list.
this.autorun(() => {
const board = Utils.getCurrentBoard();
if (!board) {
this.filteredRows.set([]);
return;
}
const query = this.searchQuery.get().trim().toLowerCase();
const field = this.sortField.get();
const direction = this.sortDirection.get();
const rows = [];
board.cards().forEach(card => {
const swimlane = card.getSwimlane();
const list = card.getList();
if (!swimlane || swimlane.archived || !list || list.archived) return;
const labels = (card.labelIds || [])
.map(labelId => {
const label = board.getLabelById(labelId);
return label ? { name: label.name || '', color: label.color } : null;
})
.filter(Boolean);
rows.push({
card,
title: card.title || '',
listTitle: list.title || '',
swimlaneTitle: swimlane.title || '',
colorClass: board.colorClass(),
receivedAt: card.getReceived() || null,
startAt: card.getStart() || null,
dueAt: card.getDue() || null,
endAt: card.getEnd() || null,
labels,
});
});
let filtered = rows;
if (query) {
filtered = rows.filter(row => {
const haystack = [
row.title,
row.listTitle,
row.swimlaneTitle,
...row.labels.map(label => label.name),
]
.join(' ')
.toLowerCase();
return haystack.indexOf(query) !== -1;
});
}
// Map a date sort field to the matching row property.
const dateFieldProp = {
received: 'receivedAt',
start: 'startAt',
due: 'dueAt',
end: 'endAt',
};
filtered = filtered.slice().sort((a, b) => {
const dateProp = dateFieldProp[field];
if (dateProp) {
// Cards without the date sort last, regardless of direction.
const av = a[dateProp] ? new Date(a[dateProp]).getTime() : Infinity;
const bv = b[dateProp] ? new Date(b[dateProp]).getTime() : Infinity;
return (av - bv) * direction;
}
let av;
let bv;
if (field === 'list') {
av = a.listTitle;
bv = b.listTitle;
} else if (field === 'swimlane') {
av = a.swimlaneTitle;
bv = b.swimlaneTitle;
} else {
av = a.title;
bv = b.title;
}
return (
av.localeCompare(bv, undefined, { numeric: true, sensitivity: 'base' }) *
direction
);
});
this.filteredRows.set(filtered);
});
});
Template.tableView.helpers({
currentBoard() {
return Utils.getCurrentBoard();
},
rows() {
const tpl = Template.instance();
const all = tpl.filteredRows.get();
const totalPages = Math.max(1, Math.ceil(all.length / rowsPerPage));
// Clamp on read so a shrinking list (deleted cards) never shows an empty
// page; no write here, to avoid a reactive loop.
const page = Math.min(tpl.page.get(), totalPages);
const start = (page - 1) * rowsPerPage;
return all.slice(start, start + rowsPerPage);
},
currentPage() {
return Template.instance().page.get();
},
totalPages() {
const count = Template.instance().filteredRows.get().length;
return Math.max(1, Math.ceil(count / rowsPerPage));
},
hasPrevPage() {
return Template.instance().page.get() > 1;
},
hasNextPage() {
const tpl = Template.instance();
const totalPages = Math.max(
1,
Math.ceil(tpl.filteredRows.get().length / rowsPerPage),
);
return tpl.page.get() < totalPages;
},
// A date column is shown unless BOTH its "Show at Card" (allowsXxxDate) and
// "Show at Minicard" (allowsXxxDateOnMinicard) board settings are unchecked.
showReceivedColumn() {
const board = Utils.getCurrentBoard();
return !!board && (board.allowsReceivedDate || board.allowsReceivedDateOnMinicard);
},
showStartColumn() {
const board = Utils.getCurrentBoard();
return !!board && (board.allowsStartDate || board.allowsStartDateOnMinicard);
},
showDueColumn() {
const board = Utils.getCurrentBoard();
return !!board && (board.allowsDueDate || board.allowsDueDateOnMinicard);
},
showEndColumn() {
const board = Utils.getCurrentBoard();
return !!board && (board.allowsEndDate || board.allowsEndDateOnMinicard);
},
// Excel-like sort arrow shown on the active sort column header.
sortIndicator(field) {
const tpl = Template.instance();
if (tpl.sortField.get() !== field) return '';
return tpl.sortDirection.get() === 1 ? '▲' : '▼';
},
});
Template.tableView.events({
'click .js-table-view-search-button'(event, tpl) {
event.preventDefault();
tpl.searchQuery.set(tpl.$('.js-table-view-search').val() || '');
tpl.page.set(1);
},
'keydown .js-table-view-search'(event, tpl) {
if (event.keyCode === 13) {
event.preventDefault();
tpl.searchQuery.set(tpl.$('.js-table-view-search').val() || '');
tpl.page.set(1);
}
},
'click .js-table-view-prev-page'(event, tpl) {
event.preventDefault();
const current = tpl.page.get();
if (current > 1) tpl.page.set(current - 1);
},
'click .js-table-view-next-page'(event, tpl) {
event.preventDefault();
const totalPages = Math.max(
1,
Math.ceil(tpl.filteredRows.get().length / rowsPerPage),
);
const current = tpl.page.get();
if (current < totalPages) tpl.page.set(current + 1);
},
'click .js-table-view-sort'(event, tpl) {
event.preventDefault();
const field = event.currentTarget.dataset.sort;
if (!field) return;
if (tpl.sortField.get() === field) {
tpl.sortDirection.set(tpl.sortDirection.get() * -1);
} else {
tpl.sortField.set(field);
tpl.sortDirection.set(1);
}
tpl.page.set(1);
},
// Clicking the leftmost "Edit" link opens the Card Details popup on top of the
// Board Table view (same mechanism as opening a card from search results).
'click .js-table-view-edit-card'(event) {
event.preventDefault();
const cardId = event.currentTarget.dataset.cardId;
if (!cardId) return;
const board = Utils.getCurrentBoard();
Meteor.subscribe('popupCardData', cardId, {
onReady() {
Session.set('popupCardId', cardId);
if (board) Session.set('popupCardBoardId', board._id);
if (!Popup.isOpen()) {
Popup.open('cardDetails')(event);
}
},
});
},
// Adding a date to a card that has none. The data context of each add button
// is the card (set with `with row.card` in the template), so the popup edits
// the right card. Editing an existing date is handled by the cardXxxDate
// badge templates themselves (their own .js-edit-date click handlers).
'click .js-received-date': Popup.open('editCardReceivedDate'),
'click .js-start-date': Popup.open('editCardStartDate'),
'click .js-due-date': Popup.open('editCardDueDate'),
'click .js-end-date': Popup.open('editCardEndDate'),
});
-374
View File
@@ -1,374 +0,0 @@
.hidden {
display: none;
}
.attachment-upload {
text-align: center;
font-weight: bold;
}
.attachment-gallery {
display: flex;
flex-direction: column;
}
.attachment-item {
display: flex;
flex-direction: row;
align-items: center;
margin-top: 16px;
}
.attachment-item:hover {
background: #e0e0e0;
}
.attachment-thumbnail-container {
display: block;
width: 150px;
min-width: 150px;
max-height: 150px;
padding-inline-end: 16px;
}
.attachment-thumbnail {
max-width: 150px;
max-height: 150px;
min-height: 2em;
cursor: pointer;
}
.attachment-thumbnail-text {
min-height: 2em;
display: flex;
align-items: center;
justify-content: center;
font-size: 2em;
cursor: pointer;
border: 1px solid #ccc;
border-radius: 5px;
}
.attachment-details-container {
display: block;
flex-grow: 1;
}
.attachment-details {
display: flex;
justify-content: space-between;
margin-inline-end: 25px; /* Make sure the icons are not to far to the right */
}
.attachment-actions {
display: flex;
flex-direction: row;
align-items: center;
}
.attachment-actions a {
margin-inline-start: 16px;
}
.attachment-actions a:first-child {
margin-inline-start: 0;
}
.add-attachment {
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed #555;
border-radius: 5px;
padding: 10px;
cursor: pointer;
margin-top: 16px;
}
.icon {
font-size: 1.5em;
cursor: pointer;
margin-inline-start: 10px;
}
.icon:hover {
color: #666;
}
#viewer-overlay {
width: 100%;
height: 100vh;
position: fixed;
top: 0;
inset-inline-start: 0;
z-index: 9999 !important;
background: rgba(13, 13, 13, 0.95);
}
#viewer-container {
display: flex;
flex-direction: row;
justify-content: space-between;
height: 100%;
}
#viewer-top-bar {
display: flex;
flex-direction: row;
justify-content: space-between;
width: 100%;
padding: 16px;
}
#attachment-name {
color: white;
font-size: 1.5em;
max-width: calc(
100% - 50px
); /* Make sure the name does not overlap the close button */
}
#viewer-close {
color: white;
cursor: pointer;
font-size: 4em;
position: absolute;
inset-inline-end: 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-inline-end: 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-inline-end: 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;
color: white;
cursor: pointer;
align-self: center;
margin: 0 20px;
}
#prev-attachment {
font-size: 4em;
color: white;
cursor: pointer;
align-self: center;
margin-inline-start: 70px;
}
#next-attachment {
font-size: 4em;
color: white;
cursor: pointer;
align-self: center;
margin-inline-end: 70px;
}
#viewer-content {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: calc(100% - 50px);
}
#image-viewer {
background: repeating-conic-gradient(#808080 0% 25%, transparent 0% 50%) 50% /
20px 20px; /* Checkerboard background for transparent images */
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%;
}
#txt-viewer {
background-color: white;
width: 40vw;
height: 100%;
}
.pdf-preview-error {
margin-top: 20vh;
display: block;
font-size: 2em;
color: white;
}
@media screen and (max-width: 1600px) {
#pdf-viewer {
width: 60vw;
}
}
@media screen and (max-width: 800px) {
#viewer-container {
display: block;
}
.attachment-arrow {
position: absolute;
bottom: 2.2em;
font-size: 1.6em;
padding: 16px;
}
#prev-attachment {
inset-inline-start: 0;
position: absolute;
bottom: 2.2em;
font-size: 1.6em;
padding: 16px;
margin-inline-start: 0;
}
#next-attachment {
inset-inline-end: 0;
position: absolute;
bottom: 2.2em;
font-size: 1.6em;
padding: 16px;
margin-inline-end: 0;
}
#pdf-viewer {
width: 100%;
height: calc(
100vh - 155px
); /* Full height - height of top and bottom bars */
}
#txt-viewer {
width: 100%;
height: calc(
100vh - 155px
); /* Full height - height of top and bottom bars */
}
#audio-viewer {
margin-top: 20%;
width: 100%;
}
.attachment-thumbnail-container {
width: 100px;
min-width: 100px;
}
.attachment-thumbnail {
max-width: 100px;
}
.attachment-details {
flex-direction: column;
margin-inline-end: 0px;
}
.attachment-actions {
flex-direction: row;
margin-top: 10px;
}
}
+39 -117
View File
@@ -1,131 +1,53 @@
template(name="cardAttachmentsPopup")
if $gt uploads.length 0
.attachment-upload {{_ 'uploading'}}
table
tr
th.upload-file-name-descr {{_ 'name'}}
th.upload-progress-descr {{_ 'progress'}}
th.upload-remaining-descr {{_ 'remaining_time'}}
th.upload-speed-descr {{_ 'speed'}}
each upload in uploads
tr
td.upload-file-name-value {{upload.file.name}}
td.upload-progress-value {{upload.progress.get}}%
td.upload-remaining-value {{getEstimateTime upload}}
td.upload-speed-value {{getEstimateSpeed upload}}
else
ul.pop-over-list
li
input.js-attach-file.hide(type="file" name="file" multiple)
a.js-computer-upload {{_ 'computer'}}
li
a.js-upload-clipboard-image {{_ 'clipboard'}}
ul.pop-over-list
li
input.js-attach-file.hide(type="file" name="file" multiple)
a.js-computer-upload {{_ 'computer'}}
li
a.js-upload-clipboard-image {{_ 'clipboard'}}
template(name="previewClipboardImagePopup")
p <kbd>Ctrl</kbd>+<kbd>V</kbd> {{_ "paste-or-dragdrop"}}
img.preview-clipboard-image()
button.primary.js-upload-pasted-image {{_ 'upload'}}
template(name="previewAttachedImagePopup")
img.preview-large-image.js-large-image-clicked(src="{{url}}")
template(name="attachmentDeletePopup")
p {{_ "attachment-delete-pop"}}
button.js-confirm.negate.full(type="submit") {{_ 'delete'}}
template(name="attachmentViewer")
#viewer-overlay.hidden
#viewer-top-bar
span#attachment-name
a#viewer-close
i.fa.fa-times-thin
#viewer-container
i.fa.fa-caret-left#prev-attachment
#viewer-content
img#image-viewer.hidden
video#video-viewer.hidden(controls="true")
audio#audio-viewer.hidden(controls="true")
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
template(name="attachmentGallery")
.attachment-gallery
if canModifyCard
a.attachment-item.add-attachment.js-add-attachment
i.fa.fa-plus
template(name="attachmentsGalery")
.attachments-galery
each attachments
.attachment-item(class="{{#if isAttachmentMigrating _id}}migrating{{/if}}")
.attachment-thumbnail-container.open-preview(data-attachment-id="{{_id}}" data-card-id="{{ meta.cardId }}")
if link
if(isImage)
img.attachment-thumbnail(src="{{link}}" title="{{sanitize name}}")
else if($eq extension 'svg')
img.attachment-thumbnail(src="{{link}}" title="{{sanitize name}}" type="image/svg+xml")
else if($eq extension 'mp3')
video.attachment-thumbnail(title="{{sanitize name}}")
source(src="{{link}}" type="audio/mpeg")
else if($eq extension 'ogg')
video.attachment-thumbnail(title="{{sanitize name}}")
source(src="{{link}}" type="video/ogg")
else if($eq extension 'webm')
video.attachment-thumbnail(title="{{sanitize name}}")
source(src="{{link}}" type="video/webm")
else if($eq extension 'mp4')
video.attachment-thumbnail(title="{{sanitize name}}")
source(src="{{link}}" type="video/mp4")
.attachment-item
a.attachment-thumbnail.swipebox(href="{{url}}" title="{{name}}")
if isUploaded
if isImage
img.attachment-thumbnail-img(src="{{url}}")
else
span.attachment-thumbnail-text= extension
span.attachment-thumbnail-ext= extension
else
+spinner
p.attachment-details
= name
span.attachment-details-actions
a.js-download(href="{{url download=true}}")
i.fa.fa-download
| {{_ 'download'}}
if currentUser.isBoardMember
if isImage
a(class="{{#if $eq ../coverId _id}}js-remove-cover{{else}}js-add-cover{{/if}}")
i.fa.fa-thumb-tack
if($eq ../coverId _id)
| {{_ 'remove-cover'}}
else
| {{_ 'add-cover'}}
a.js-confirm-delete
i.fa.fa-close
| {{_ 'delete'}}
.attachment-details-container
.attachment-details
div
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
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'}}
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-picture-o
| {{#if isCover}}{{_ 'remove-cover'}}{{else}}{{_ 'add-cover'}}{{/if}}
if currentUser.isBoardAdmin
if isImage
a(class="{{#if isBackgroundImage}}js-remove-background-image{{else}}js-add-background-image{{/if}}")
i.fa.fa-picture-o
if isBackgroundImage
| {{_ 'remove-background-image'}}
else
| {{_ 'add-background-image'}}
template(name="attachmentRenamePopup")
input.js-edit-attachment-name(type='text' autofocus value="{{getNameWithoutExtension}}" dir="auto")
.edit-controls.clearfix
button.primary.confirm.js-submit-edit-attachment-name(type="submit") {{_ 'save'}}
if currentUser.isBoardMember
li.attachment-item.add-attachment
a.js-add-attachment {{_ 'add-attachment' }}
+97 -598
View File
@@ -1,635 +1,134 @@
import { ReactiveCache } from '/imports/reactiveCache';
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';
// 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
// when the user clicks on the prev/next button in the attachment viewer.
let cardId = null;
let openAttachmentId = null;
// Used to store the start and end coordinates of a touch event for attachment swiping
let touchStartCoords = null;
let touchEndCoords = null;
// Stores link to the attachment for which attachment actions popup was opened
let attachmentActionsLink = null;
Template.attachmentGallery.events({
'click .open-preview'(event) {
openAttachmentId = $(event.currentTarget).attr("data-attachment-id");
cardId = $(event.currentTarget).attr("data-card-id");
openAttachmentViewer(openAttachmentId);
},
Template.attachmentsGalery.events({
'click .js-add-attachment': Popup.open('cardAttachments'),
'click .js-confirm-delete': Popup.afterConfirm('attachmentDelete',
function() {
Attachments.remove(this._id);
Popup.close();
}
),
// If we let this event bubble, FlowRouter will handle it and empty the page
// content, see #101.
'click .js-download'(event) {
event.stopPropagation();
},
'click .js-open-attachment-menu': Popup.open('attachmentActions'),
'mouseover .js-open-attachment-menu'(event) { // For some reason I cannot combine handlers for "click .js-open-attachment-menu" and "mouseover .js-open-attachment-menu" events so this is a quick workaround.
attachmentActionsLink = event.currentTarget.getAttribute("data-attachment-link");
'click .js-add-cover'() {
Cards.findOne(this.cardId).setCover(this._id);
},
'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();
'click .js-remove-cover'() {
Cards.findOne(this.cardId).unsetCover();
},
'click .js-preview-image'(evt) {
Popup.open('previewAttachedImage').call(this, evt);
// when multiple thumbnails, if click one then another very fast,
// we might get a wrong width from previous img.
// when popup reused, onRendered() won't be called, so we cannot get there.
// here make sure to get correct size when this img fully loaded.
const img = $('img.preview-large-image')[0];
if (!img) return;
const rePosPopup = () => {
const w = img.width;
const h = img.height;
// if the image is too large, we resize & center the popup.
if (w > 300) {
$('div.pop-over').css({
width: (w + 20),
position: 'absolute',
left: (window.innerWidth - w)/2,
top: (window.innerHeight - h)/2,
});
}
await Attachments.removeAsync(this._id);
Popup.back();
}),
});
function getNextAttachmentId(currentAttachmentId, offset = 0) {
const attachments = ReactiveCache.getAttachments({'meta.cardId': cardId});
let i = 0;
for (; i < attachments.length; i++) {
if (attachments[i]._id === currentAttachmentId) {
break;
}
}
return attachments[(i + offset + 1 + attachments.length) % attachments.length]._id;
}
function getPrevAttachmentId(currentAttachmentId, offset = 0) {
const attachments = ReactiveCache.getAttachments({'meta.cardId': cardId});
let i = 0;
for (; i < attachments.length; i++) {
if (attachments[i]._id === currentAttachmentId) {
break;
}
}
return attachments[(i + offset - 1 + attachments.length) % attachments.length]._id;
}
function attachmentCanBeOpened(attachment) {
return (
attachment.isImage ||
attachment.isPDF ||
attachment.isText ||
attachment.isJSON ||
attachment.isVideo ||
attachment.isAudio
);
}
function getAttachmentUrl(attachment) {
return attachment && typeof attachment.link === 'function' ? attachment.link() : '';
}
function openAttachmentViewer(attachmentId) {
const attachment = ReactiveCache.getAttachment(attachmentId);
// Check if we can open the attachment (if we have a viewer for it) and exit if not
if (!attachmentCanBeOpened(attachment)) {
return;
}
/*
Instructions for adding a new viewer:
- add a new case to the switch statement below
- implement cleanup in the closeAttachmentViewer() function, if necessary
- mark attachment type as openable by adding a new condition to the attachmentCanBeOpened function
*/
switch(true){
case (attachment.isImage):
$("#image-viewer").attr("src", getAttachmentUrl(attachment));
$("#image-viewer").removeClass("hidden");
break;
case (attachment.isPDF):
$("#pdf-viewer").attr("data", getAttachmentUrl(attachment));
$("#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));
$("#video-viewer").append(videoSource);
$("#video-viewer").removeClass("hidden");
break;
case (attachment.isAudio):
// 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));
$("#audio-viewer").append(audioSource);
$("#audio-viewer").removeClass("hidden");
break;
case (attachment.isText):
case (attachment.isJSON):
$("#txt-viewer").attr("data", getAttachmentUrl(attachment));
$("#txt-viewer").removeClass("hidden");
break;
}
$('#attachment-name').text(attachment.name);
$('#viewer-overlay').removeClass('hidden');
}
function closeAttachmentViewer() {
$("#viewer-overlay").addClass("hidden");
// We need to reset the viewers to avoid showing previous attachments
$("#image-viewer").attr("src", "");
$("#image-viewer").addClass("hidden");
$("#pdf-viewer").attr("data", "");
$("#pdf-viewer").addClass("hidden");
$("#txt-viewer").attr("data", "");
$("#txt-viewer").addClass("hidden");
$("#video-viewer").get(0).pause(); // Stop playback
$("#video-viewer").get(0).currentTime = 0;
$("#video-viewer").empty();
$("#video-viewer").addClass("hidden");
$("#audio-viewer").get(0).pause(); // Stop playback
$("#audio-viewer").get(0).currentTime = 0;
$("#audio-viewer").empty();
$("#audio-viewer").addClass("hidden");
}
function openNextAttachment() {
closeAttachmentViewer();
let i = 0;
// Find an attachment that can be opened
while (true) {
const id = getNextAttachmentId(openAttachmentId, i);
const attachment = ReactiveCache.getAttachment(id);
if (attachmentCanBeOpened(attachment)) {
openAttachmentId = id;
openAttachmentViewer(id);
break;
}
i++;
}
}
function openPrevAttachment() {
closeAttachmentViewer();
let i = 0;
// Find an attachment that can be opened
while (true) {
const id = getPrevAttachmentId(openAttachmentId, i);
const attachment = ReactiveCache.getAttachment(id);
if (attachmentCanBeOpened(attachment)) {
openAttachmentId = id;
openAttachmentViewer(id);
break;
}
i--;
}
}
function processTouch(){
xDist = touchEndCoords.x - touchStartCoords.x;
yDist = touchEndCoords.y - touchStartCoords.y;
// Left swipe
if (Math.abs(xDist) > Math.abs(yDist) && xDist < 0) {
openNextAttachment();
}
// Right swipe
if (Math.abs(xDist) > Math.abs(yDist) && xDist > 0) {
openPrevAttachment();
}
// Up swipe
if (Math.abs(yDist) > Math.abs(xDist) && yDist < 0) {
closeAttachmentViewer();
}
}
Template.attachmentViewer.events({
'touchstart #viewer-container'(event) {
touchStartCoords = {
x: event.changedTouches[0].screenX,
y: event.changedTouches[0].screenY
}
},
'touchend #viewer-container'(event) {
touchEndCoords = {
x: event.changedTouches[0].screenX,
y: event.changedTouches[0].screenY
}
processTouch();
},
'click #viewer-container'(event) {
// Make sure the click was on #viewer-container and not on any of its children
if(event.target !== event.currentTarget) {
event.stopPropagation();
return;
}
closeAttachmentViewer();
},
'click #viewer-content'(event) {
// Make sure the click was on #viewer-content and not on any of its children
if(event.target !== event.currentTarget) {
event.stopPropagation();
return;
}
closeAttachmentViewer();
},
'click #viewer-close'() {
closeAttachmentViewer();
},
'click #next-attachment'() {
openNextAttachment();
},
'click #prev-attachment'() {
openPrevAttachment();
};
const url = $(evt.currentTarget).attr('src');
if (img.src === url && img.complete)
rePosPopup();
else
img.onload = rePosPopup;
},
});
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();
Template.previewAttachedImagePopup.events({
'click .js-large-image-clicked'(){
Popup.close();
},
isBoardAdmin() {
return ReactiveCache.getCurrentUser()?.isBoardAdmin();
},
fileSize(size) {
const ret = filesize(size);
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 '';
},
});
Template.cardAttachmentsPopup.onCreated(function() {
this.uploads = new ReactiveVar([]);
});
Template.cardAttachmentsPopup.helpers({
getEstimateTime(upload) {
const ret = prettyMilliseconds(upload.estimateTime.get());
return ret;
},
getEstimateSpeed(upload) {
const ret = filesize(upload.estimateSpeed.get(), {round: 0}) + "/s";
return ret;
},
uploads() {
return Template.instance().uploads.get();
}
});
Template.cardAttachmentsPopup.events({
async 'change .js-attach-file'(event, templateInstance) {
event.stopPropagation();
'change .js-attach-file'(evt) {
const card = this;
const files = event.currentTarget.files;
if (files) {
let uploads = [];
const uploaders = await handleFileUpload(card, files);
FS.Utility.eachFile(evt, (f) => {
const file = new FS.File(f);
if (card.isLinkedCard()) {
file.boardId = Cards.findOne(card.linkedId).boardId;
file.cardId = card.linkedId;
} else {
file.boardId = card.boardId;
file.swimlaneId = card.swimlaneId;
file.listId = card.listId;
file.cardId = card._id;
}
file.userId = Meteor.userId();
uploaders.forEach(uploader => {
uploader.on('start', function() {
uploads.push(this);
templateInstance.uploads.set(uploads);
});
uploader.on('end', (error, fileRef) => {
uploads = uploads.filter(_upload => _upload.config.fileId != fileRef._id);
templateInstance.uploads.set(uploads);
if (uploads.length == 0 ) {
Popup.back();
}
});
});
}
const attachment = Attachments.insert(file);
if (attachment && attachment._id && attachment.isImage()) {
card.setCover(attachment._id);
}
Popup.close();
});
},
'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();
'click .js-computer-upload'(evt, tpl) {
tpl.find('.js-attach-file').click();
evt.preventDefault();
},
'click .js-upload-clipboard-image': Popup.open('previewClipboardImage'),
});
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 => {
$(document.body).pasteImageReader((results) => {
if (results.dataURL.startsWith('data:image/')) {
const direct = results => {
$('img.preview-clipboard-image').attr('src', results.dataURL);
pastedResults = results;
};
if (MAX_IMAGE_PIXEL) {
// if has size limitation on image we shrink it before uploading
Utils.shrinkImage({
dataurl: results.dataURL,
maxSize: MAX_IMAGE_PIXEL,
ratio: COMPRESS_RATIO,
callback(changed) {
if (changed !== false && !!changed) {
results.dataURL = changed;
}
direct(results);
},
});
} else {
direct(results);
}
$('img.preview-clipboard-image').attr('src', results.dataURL);
pastedResults = results;
}
};
$(document.body).pasteImageReader(handle);
});
// we can also drag & drop image file to it
$(document.body).dropImageReader(handle);
$(document.body).dropImageReader((results) => {
if (results.dataURL.startsWith('data:image/')) {
$('img.preview-clipboard-image').attr('src', results.dataURL);
pastedResults = results;
}
});
});
Template.previewClipboardImagePopup.events({
async '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 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(
config,
false,
);
uploader.on('uploaded', (error, fileRef) => {
if (!error) {
if (fileRef.isImage) {
card.setCover(fileRef._id);
}
'click .js-upload-pasted-image'() {
const results = pastedResults;
if (results && results.file) {
const card = this;
const file = new FS.File(results.file);
if (!results.name) {
// if no filename, it's from clipboard. then we give it a name, with ext name from MIME type
if (typeof results.file.type === 'string') {
file.name(results.file.type.replace('image/', 'clipboard.'));
}
});
uploader.on('end', (error, fileRef) => {
pastedResults = null;
$(document.body).pasteImageReader(() => {});
Popup.back();
});
uploader.start();
}
file.updatedAt(new Date());
file.boardId = card.boardId;
file.cardId = card._id;
file.userId = Meteor.userId();
const attachment = Attachments.insert(file);
if (attachment && attachment._id && attachment.isImage()) {
card.setCover(attachment._id);
}
pastedResults = null;
$(document.body).pasteImageReader(() => {});
Popup.close();
}
},
});
Template.attachmentActionsPopup.helpers({
isCover() {
const ret = ReactiveCache.getCard(this.meta.cardId).coverId == this._id;
return ret;
},
isBackgroundImage() {
//const currentBoard = Utils.getCurrentBoard();
//return currentBoard.backgroundImageURL === $(".attachment-thumbnail-img").attr("src");
return false;
},
});
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({
getNameWithoutExtension() {
const ret = this.name.replace(new RegExp("\\." + this.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();
});
+85
View File
@@ -0,0 +1,85 @@
@import 'nib'
.attachments-galery
display: flex
flex-wrap: wrap
.attachment-item
width: 33.33% - 2%
margin: 10px 1% 0
text-align: center
border-radius: 3px
overflow: hidden
background: darken(white, 7%)
min-height: 120px
&:hover
background: darken(white, 12%)
&.add-attachment
display: flex
align-items: center
a
display: block
margin: auto
.attachment-thumbnail
height: 80px
display: flex
align-items: center
justify-content: center
position: relative
.attachment-thumbnail-img
max-height: 100%
max-width: 100%
.attachment-thumbnail-ext
text-transform: uppercase
font-size: 1.6em
.attachment-details
font-size: 0.75em
margin: 3px
.attachment-details-actions a
display: block
.attachment-image-preview
max-width: 100px
display: block
box-shadow: 0 1px 2px rgba(0,0,0,.2)
.preview-large-image
max-width: 1000px
display: block
box-shadow: 0 1px 2px rgba(0,0,0,.2)
.preview-clipboard-image
width: 280px
max-width: 100%;
height: 200px
display: block
border: 1px solid black
box-shadow: 0 1px 2px rgba(0,0,0,.2)
@media screen and (max-width: 800px)
.attachments-galery
flex-direction
row
.attachment-item
width: 50% - 2%
.attachment-thumbnail
height: 130px
.attachment-details
font-size: 1.1em
@media screen and (max-width: 360px)
.attachments-galery
.attachment-item
width: 100%
.attachment-thumbnail
height: 200px
+13 -95
View File
@@ -4,7 +4,8 @@ template(name="cardCustomFieldsPopup")
li.item(class="")
a.name.js-select-field(href="#")
span.full-name
= name
+viewer
= name
if hasCustomField
i.fa.fa-check
hr
@@ -22,7 +23,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
@@ -30,10 +31,6 @@ template(name="cardCustomField-text")
= value
else
| {{_ 'edit'}}
else
+viewer
= value
template(name="cardCustomField-number")
if canModifyCard
@@ -41,77 +38,23 @@ template(name="cardCustomField-number")
input(type="number" value=data.value)
.edit-controls.clearfix
button.primary(type="submit") {{_ 'save'}}
a.js-close-inlined-form
else
a.js-open-inlined-form
if formattedValue
= formattedValue
else
| {{_ 'edit'}}
else
if formattedValue
= formattedValue
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}}")
else
.materialCheckBox(class="{{#if data.value}}is-checked{{/if}}")
template(name="cardCustomField-currency")
if canModifyCard
+inlinedForm(classNames="js-card-customfield-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
= formattedValue
= value
else
| {{_ 'edit'}}
else
if value
= formattedValue
template(name="cardCustomField-date")
if canModifyCard
a.js-edit-date(title="{{showTitle}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
if value
div.card-date
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
b
| {{showWeek}}
else
| {{_ 'edit'}}
else
if value
div.card-date
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
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'}}
a.js-edit-date(title="{{showTitle}}" class="{{classes}}")
if value
div.card-date
time(datetime="{{showISODate}}")
| {{showDate}}
else
| {{_ 'edit'}}
template(name="cardCustomField-dropdown")
if canModifyCard
@@ -128,7 +71,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
@@ -136,28 +79,3 @@ template(name="cardCustomField-dropdown")
= selectedItem
else
| {{_ 'edit'}}
else
if value
+viewer
= selectedItem
template(name="cardCustomField-stringtemplate")
if canModifyCard
+inlinedForm(classNames="js-card-customfield-stringtemplate")
each item in stringtemplateItems.get
input.js-card-customfield-stringtemplate-item(type="text" value=item placeholder="")
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
else
a.js-open-inlined-form
if value
+viewer
= formattedValue
else
| {{_ 'edit'}}
else
if value
+viewer
= formattedValue
+130 -299
View File
@@ -1,348 +1,179 @@
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 { CustomFieldStringTemplate } from '/client/lib/customFields'
import { getCurrentCardFromContext } from '/client/lib/currentCard';
import { formatNumberValue } from '/imports/lib/customNumberFormat';
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 = Cards.findOne(Session.get('currentCard'));
const customFieldId = this._id;
return card.customFieldIndex(customFieldId) > -1;
},
});
Template.cardCustomFieldsPopup.events({
'click .js-select-field'(event) {
const card = getCurrentCardFromContext();
if (!card) return;
'click .js-select-field'(evt) {
const card = Cards.findOne(Session.get('currentCard'));
const customFieldId = this._id;
card.toggleCustomField(customFieldId);
event.preventDefault();
evt.preventDefault();
},
'click .js-settings'(event) {
'click .js-settings'(evt) {
EscapeActions.executeUpTo('detailsPane');
const sidebar = getSidebarInstance();
if (sidebar) {
sidebar.setView('customFields');
}
event.preventDefault();
Sidebar.setView('customFields');
evt.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 = Cards.findOne(Session.get('currentCard'));
self.customFieldId = this.data()._id;
},
canModifyCard() {
return Meteor.user() && Meteor.user().isBoardMember() && !Meteor.user().isCommentOnly();
},
});
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 {
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);
},
});
onCreated() {
super.onCreated();
}
events() {
return [{
'submit .js-card-customfield-text'(evt) {
evt.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 {
Template['cardCustomField-number'].helpers({
// Render blank / cleared / non-numeric values as empty instead of "NaN" (#2091).
formattedValue() {
return formatNumberValue(this.value);
},
});
onCreated() {
super.onCreated();
}
Template['cardCustomField-number'].events({
'submit .js-card-customfield-number'(event, tpl) {
event.preventDefault();
const rawValue = tpl.find('input').value;
// A cleared/blank input parses to NaN; store '' instead so it renders empty
// rather than as "NaN" (#2091).
const parsed = parseInt(rawValue, 10);
const value = Number.isNaN(parsed) ? '' : parsed;
tpl.card.setCustomField(tpl.customFieldId, value);
},
});
events() {
return [{
'submit .js-card-customfield-number'(evt) {
evt.preventDefault();
const value = parseInt(this.find('input').value, 10);
this.card.setCustomField(this.customFieldId, value);
},
}];
}
// cardCustomField-checkbox
Template['cardCustomField-checkbox'].onCreated(function () {
this.card = getCurrentCardFromContext();
this.customFieldId = Template.currentData()._id;
});
Template['cardCustomField-checkbox'].events({
'click .js-checklist-item .check-box-container'(event, tpl) {
tpl.card.setCustomField(tpl.customFieldId, !Template.currentData().value);
},
});
// cardCustomField-currency
Template['cardCustomField-currency'].onCreated(function () {
this.card = getCurrentCardFromContext();
this.customFieldId = Template.currentData()._id;
this.currencyCode = Template.currentData().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);
},
});
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);
},
});
}).register('cardCustomField-number');
// 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 {
self.autorun(() => {
self.date.set(new Date(Template.currentData().value));
});
});
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(moment(self.data().value));
});
}
Template['cardCustomField-date'].helpers({
showWeek() {
return getISOWeek(Template.instance().date.get()).toString();
},
showWeekOfYear() {
const user = ReactiveCache.getCurrentUser();
if (!user) {
return false;
}
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);
},
// 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')
) {
if (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 = Cards.findOne(Session.get('currentCard'));
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 {
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;
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'),
});
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);
},
});
selectedItem() {
const selected = this._items.find((item) => {
return item._id === this.data().value;
});
return (selected) ? selected.name : TAPi18n.__('custom-field-dropdown-unknown');
}
// 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 ?? []);
});
events() {
return [{
'submit .js-card-customfield-dropdown'(evt) {
evt.preventDefault();
const value = this.find('select').value;
this.card.setCustomField(this.customFieldId, value);
},
}];
}
Template['cardCustomField-stringtemplate'].helpers({
formattedValue() {
const tpl = Template.instance();
const ret = tpl.customField.getFormattedValue(this.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);
},
'keydown .js-card-customfield-stringtemplate-item'(event, tpl) {
if (event.keyCode === 13) {
event.preventDefault();
if (event.target.value.trim() || event.metaKey || event.ctrlKey) {
const inputLast = tpl.find('input.last');
let items = Array.from(tpl.findAll('input'))
.map(input => input.value)
.filter(value => !!value.trim());
if (event.target === inputLast) {
inputLast.value = '';
} else if (event.target.nextSibling === inputLast) {
inputLast.focus();
} else {
event.target.blur();
const idx = Array.from(tpl.findAll('input')).indexOf(
event.target,
);
items.splice(idx + 1, 0, '');
Tracker.afterFlush(() => {
const element = tpl.findAll('input')[idx + 1];
element.focus();
element.value = '';
});
}
tpl.stringtemplateItems.set(items);
}
if (event.metaKey || event.ctrlKey) {
tpl.find('button[type=submit]').click();
}
}
},
'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 = '';
}
},
'click .js-close-inlined-form'(event, tpl) {
tpl.stringtemplateItems.set(Template.currentData().value ?? []);
},
});
}).register('cardCustomField-dropdown');
-148
View File
@@ -1,148 +0,0 @@
.card-date {
display: block;
border-radius: 4px;
padding: 1px 3px;
background-color: #dbdbdb;
}
.card-date:hover,
.card-date.is-active {
background-color: #b3b3b3;
}
/* Date status colors - red = overdue, amber = due soon, no shade = not due.
* These status rules use !important so they override the date-type base
* colours below (e.g. .card-date.due-date yellow). Without this, an overdue
* due-date badge in the card detail showed yellow/amber instead of red, while
* the minicard (which already used !important) showed red - issue #5965. */
.card-date.overdue {
background-color: #ff4444 !important; /* Red for overdue */
color: #fff !important;
}
.card-date.overdue:hover,
.card-date.overdue.is-active {
background-color: #cc3333 !important;
}
.card-date.due-soon {
background-color: #ffaa00 !important; /* Amber for due soon */
color: #000 !important;
}
.card-date.due-soon:hover,
.card-date.due-soon.is-active {
background-color: #e69900 !important;
}
.card-date.not-due {
/* No special background - uses default date type colors */
}
.card-date.current {
background-color: #5ba639 !important; /* Green for current/active */
color: #fff !important;
}
.card-date.current:hover,
.card-date.current.is-active {
background-color: #46802c !important;
}
.card-date.completed {
background-color: #90ee90 !important; /* Light green for completed */
color: #000 !important;
}
.card-date.completed:hover,
.card-date.completed.is-active {
background-color: #7dd87d !important;
}
.card-date.completed-early {
background-color: #4caf50 !important; /* Green for completed early */
color: #fff !important;
}
.card-date.completed-early:hover,
.card-date.completed-early.is-active {
background-color: #45a049 !important;
}
.card-date.completed-late {
background-color: #ff9800 !important; /* Orange for completed late */
color: #fff !important;
}
.card-date.completed-late:hover,
.card-date.completed-late.is-active {
background-color: #f57c00 !important;
}
.card-date.completed-on-time {
background-color: #2196f3 !important; /* Blue for completed on time */
color: #fff !important;
}
.card-date.completed-on-time:hover,
.card-date.completed-on-time.is-active {
background-color: #1976d2 !important;
}
/* 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.end-date time::before {
content: "🏁"; /* Finish flag - represents end/completion */
}
.card-date.due-date time::before {
content: "⏰"; /* Alarm clock - represents due/deadline */
}
.card-date.start-date time::before {
content: "🚀"; /* Rocket - represents start/launch */
}
.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 */
}
.card-date time::before {
font-size: inherit;
margin-inline-end: 0.3em;
display: inline-block;
}
.customfield-date {
display: block;
border-radius: 4px;
padding: 1px 3px;
}
+2 -281
View File
@@ -1,289 +1,10 @@
template(name="dateBadge")
if canModifyCard
a.js-edit-date.card-date(title="{{showTitle}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
a.js-edit-date.card-date(title="{{showTitle}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
b
| {{showWeek}}
else
a.card-date(title="{{showTitle}} {{_ 'predicate-week'}} {{#if showWeekOfYear}}{{showWeek}}{{/if}}" class="{{classes}}")
a.card-date(title="{{showTitle}}" class="{{classes}}")
time(datetime="{{showISODate}}")
| {{showDate}}
if showWeekOfYear
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}}")
| {{showDate}}
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'}}
+328 -553
View File
@@ -1,608 +1,383 @@
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
} from '/imports/lib/dateUtils';
import { dueDateClass } from '/client/lib/dueDateColor';
// Edit received, start, due & end dates
BlazeComponent.extendComponent({
template() {
return 'editCardDate';
},
// --- DatePicker popups (edit date forms) ---
onCreated() {
this.error = new ReactiveVar('');
this.card = this.data();
this.date = new ReactiveVar(moment.invalid());
},
onRendered() {
const $picker = this.$('.js-datepicker').datepicker({
todayHighlight: true,
todayBtn: 'linked',
language: TAPi18n.getLanguage(),
}).on('changeDate', function(evt) {
this.find('#date').value = moment(evt.date).format('L');
this.error.set('');
this.find('#time').focus();
}.bind(this));
if (this.date.get().isValid()) {
$picker.datepicker('update', this.date.get().toDate());
}
},
showDate() {
if (this.date.get().isValid())
return this.date.get().format('L');
return '';
},
showTime() {
if (this.date.get().isValid())
return this.date.get().format('LT');
return '';
},
dateFormat() {
return moment.localeData().longDateFormat('L');
},
timeFormat() {
return moment.localeData().longDateFormat('LT');
},
events() {
return [{
'keyup .js-date-field'() {
// parse for localized date format in strict mode
const dateMoment = moment(this.find('#date').value, 'L', true);
if (dateMoment.isValid()) {
this.error.set('');
this.$('.js-datepicker').datepicker('update', dateMoment.toDate());
}
},
'keyup .js-time-field'() {
// parse for localized time format in strict mode
const dateMoment = moment(this.find('#time').value, 'LT', true);
if (dateMoment.isValid()) {
this.error.set('');
}
},
'submit .edit-date'(evt) {
evt.preventDefault();
// if no time was given, init with 12:00
const time = evt.target.time.value || moment(new Date().setHours(12, 0, 0)).format('LT');
const dateString = `${evt.target.date.value} ${time}`;
const newDate = moment(dateString, 'L LT', true);
if (newDate.isValid()) {
this._storeDate(newDate.toDate());
Popup.close();
}
else {
this.error.set('invalid-date');
evt.target.date.focus();
}
},
'click .js-delete-date'(evt) {
evt.preventDefault();
this._deleteDate();
Popup.close();
},
}];
},
});
Template.dateBadge.helpers({
canModifyCard() {
return Meteor.user() && Meteor.user().isBoardMember() && !Meteor.user().isCommentOnly();
},
});
// 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();
this.data().getReceived() && this.date.set(moment(this.data().getReceived()));
}
Template.editCardReceivedDatePopup.onRendered(function () {
datePickerRendered(this);
});
_storeDate(date) {
this.card.setReceived(date);
}
Template.editCardReceivedDatePopup.helpers(datePickerHelpers());
_deleteDate() {
this.card.setReceived(null);
}
}).register('editCardReceivedDatePopup');
Template.editCardReceivedDatePopup.events(datePickerEvents({
storeDate(date) {
this.datePicker.card.setReceived(date);
},
deleteDate() {
this.datePicker.card.unsetReceived();
},
}));
// 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();
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(date);
}
Template.editCardStartDatePopup.events(datePickerEvents({
storeDate(date) {
this.datePicker.card.setStart(date);
},
deleteDate() {
this.datePicker.card.unsetStart();
},
}));
_deleteDate() {
this.card.setStart(null);
}
}).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();
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(date);
}
Template.editCardDueDatePopup.events(datePickerEvents({
storeDate(date) {
this.datePicker.card.setDue(date);
},
deleteDate() {
this.datePicker.card.unsetDue();
},
}));
_deleteDate() {
this.card.setDue(null);
}
}).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();
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(date);
}
_deleteDate() {
this.card.setEnd(null);
}
}).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;
},
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}`;
},
}));
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();
const data = Template.currentData();
const endAt = data.getEnd();
const theDate = tpl.date.get();
const nowVal = tpl.now.get();
return `due-date ${dueDateClass(theDate, nowVal, endAt)}`;
},
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();
if (
(startAt && isAfter(theDate, startAt)) ||
(endAt && isAfter(theDate, endAt)) ||
(dueAt && isAfter(theDate, dueAt))
) {
classes += 'overdue';
} 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-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);
},
}));
Template.minicardReceivedDate.events({
'click .js-edit-date': Popup.open('editCardReceivedDate'),
});
// minicardStartDate
Template.minicardStartDate.onCreated(function () {
cardDateOnCreated(this);
const self = this;
self.autorun(() => {
self.date.set(new Date(Template.currentData().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 {
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) && (theDate.isAfter(dueAt))) ||
((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-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-received-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('editCardReceivedDate'),
});
}
}
CardReceivedDate.register('cardReceivedDate');
// minicardDueDate
Template.minicardDueDate.onCreated(function () {
cardDateOnCreated(this);
const self = this;
self.autorun(() => {
self.date.set(new Date(Template.currentData().getDue()));
});
});
class CardStartDate extends CardDate {
onCreated() {
super.onCreated();
const self = this;
self.autorun(() => {
self.date.set(moment(self.data().getStart()));
});
}
Template.minicardDueDate.helpers(cardDateHelpers({
classes() {
const tpl = Template.instance();
const data = Template.currentData();
const endAt = data.getEnd();
const theDate = tpl.date.get();
const nowVal = tpl.now.get();
return `due-date ${dueDateClass(theDate, nowVal, endAt)}`;
},
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);
},
}));
Template.minicardDueDate.events({
'click .js-edit-date': Popup.open('editCardDueDate'),
});
// minicardEndDate
Template.minicardEndDate.onCreated(function () {
cardDateOnCreated(this);
const self = this;
self.autorun(() => {
self.date.set(new Date(Template.currentData().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 = '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.isBefore(now, 'minute'))
classes += 'almost-due';
else
classes += 'current';
return classes;
},
}
showTitle() {
const tpl = Template.instance();
return `${TAPi18n.__('card-end-on')} ${format(tpl.date.get(), 'LLLL')}`;
},
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.minicardEndDate.events({
'click .js-edit-date': Popup.open('editCardEndDate'),
});
events() {
return super.events().concat({
'click .js-edit-date': Popup.open('editCardStartDate'),
});
}
}
CardStartDate.register('cardStartDate');
// minicardCustomFieldDate
Template.minicardCustomFieldDate.onCreated(function () {
cardDateOnCreated(this);
const self = this;
self.autorun(() => {
self.date.set(new Date(Template.currentData().value));
});
});
class CardDueDate extends CardDate {
onCreated() {
super.onCreated();
const self = this;
self.autorun(() => {
self.date.set(moment(self.data().getDue()));
});
}
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}`;
},
classes() {
return 'customfield-date';
},
}));
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;
}
// --- Vote and Poker end date badge templates ---
// 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 ';
},
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 `${TAPi18n.__('card-due-on')} ${this.date.get().format('LLLL')}`;
}
Template.voteEndDate.events({
'click .js-edit-date': Popup.open('editVoteEndDate'),
});
events() {
return super.events().concat({
'click .js-edit-date': Popup.open('editCardDueDate'),
});
}
}
CardDueDate.register('cardDueDate');
// pokerEndDate
Template.pokerEndDate.onCreated(function () {
cardDateOnCreated(this);
const self = this;
self.autorun(() => {
self.date.set(new Date(Template.currentData().getPokerEnd()));
});
});
class CardEndDate extends CardDate {
onCreated() {
super.onCreated();
const self = this;
self.autorun(() => {
self.date.set(moment(self.data().getEnd()));
});
}
Template.pokerEndDate.helpers(cardDateHelpers({
classes() {
return 'end-date ';
},
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')}`;
},
}));
let classes = 'end-date' + ' ';
const dueAt = this.data().getDue();
const theDate = this.date.get();
if (theDate.diff(dueAt, 'days') >= 2)
classes += 'long-overdue';
else if (theDate.diff(dueAt, 'days') >= 0)
classes += 'due';
else if (theDate.diff(dueAt, 'days') >= -2)
classes += 'almost-due';
return classes;
}
Template.pokerEndDate.events({
'click .js-edit-date': Popup.open('editPokerEndDate'),
});
showTitle() {
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 extends CardReceivedDate {
showDate() {
return this.date.get().format('l');
}
}).register('minicardReceivedDate');
(class extends CardStartDate {
showDate() {
return this.date.get().format('l');
}
}).register('minicardStartDate');
(class extends CardDueDate {
showDate() {
return this.date.get().format('l');
}
}).register('minicardDueDate');
(class extends CardEndDate {
showDate() {
return this.date.get().format('l');
}
}).register('minicardEndDate');
+59
View File
@@ -0,0 +1,59 @@
.card-date
display: block
border-radius: 4px
padding: 1px 3px
background-color: #dbdbdb
&:hover, &.is-active
background-color: #b3b3b3
&.current, &.almost-due, &.due, &.long-overdue
color: #fff
&.current
background-color: #5ba639
&:hover, &.is-active
background-color: darken(#5ba639, 10)
&.almost-due
background-color: #edc909
&:hover, &.is-active
background-color: darken(#edc909, 10)
&.due
background-color: #fa3f00
&:hover, &.is-active
background-color: darken(#fa3f00, 10)
&.long-overdue
background-color: #fd5d47
&:hover, &.is-active
background-color: darken(#fd5d47, 7)
&.end-date
time
&::before
content: "\f253" // symbol: fa-hourglass-end
&.due-date
time
&::before
content: "\f090" // symbol: fa-sign-in
&.start-date
time
&::before
content: "\f251" // symbol: fa-hourglass-start
&.received-date
time
&::before
content: "\f08b" // symbol: fa-sign-out
time
&::before
font: normal normal normal 14px/1 FontAwesome
font-size: inherit
-webkit-font-smoothing: antialiased
margin-right: 0.3em
Loaded 100 of 2779 files, more files were not shown because too many files have changed in this diff. Show more