36 |
--------------------------------------------------------------------------------
/src/frontend/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | QuickRetro
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/homepage/assets/index.md.DUOif5wB.js:
--------------------------------------------------------------------------------
1 | import{_ as e,c as t,o as i}from"./chunks/framework.CTVYQtO4.js";const m=JSON.parse(`{"title":"QuickRetro - Free and Open-Source Sprint Retrospective Meeting App","description":"","frontmatter":{"layout":"home","title":"QuickRetro - Free and Open-Source Sprint Retrospective Meeting App","hero":{"name":"QuickRetro","text":"Sprint Retrospective Meeting App for Remote Agile Teams","tagline":"Free, Open-Source & Self-hosted","actions":[{"theme":"brand","text":"Live Demo","link":"https://demo.quickretro.app"},{"theme":"alt","text":"Getting Started","link":"/guide/getting-started"}],"image":{"light":"/logo_large_light.png","dark":"/logo_large_dark.png","alt":"QuickRetro"}},"features":[{"icon":"🙅♂️","title":"No Signups","details":"That's right! No need to signup or login"},{"icon":"♾️","title":"No Board Limits","details":"Create Boards or Invite Users without limits"},{"icon":"📱","title":"Mobile Friendly UI","details":"Easily participate from your mobile phone"},{"icon":"📝","title":"Customize Columns","details":"Choose upto 5 columns with any name in any order"},{"icon":"🙈","title":"Mask/Blur messages","details":"Avoid revealing messages of other participants"},{"icon":"👤","title":"Anonymous Messages","details":"Post messages without revealing your name"},{"icon":"⬇️","title":"Print as PDF","details":"Print to save messages as PDF"},{"icon":"⏱️","title":"Countdown Timer","details":"Stopwatch with max 1 hour limit"},{"icon":"🔒","title":"Board Lock","details":"Lock to stop addition/updation of messages"},{"icon":"💬","title":"Comments","details":"Add comments to discuss ideas directly on each card"},{"icon":"🌙","title":"Dark Theme","details":"Easily switch to use a Dark theme"},{"icon":"🔦","title":"Focussed View","details":"Highlight cards just for a User at a time"},{"icon":"🤖","title":"Smart CAPTCHA Integration","details":"Built-in integration with Cloudflare Turnstile"},{"icon":"👥","title":"Online Presence Display","details":"See participants present in the meeting"},{"icon":"🗑️","title":"Auto-Delete data","details":"Auto-delete data with configurable retention duration"}]},"headers":[],"relativePath":"index.md","filePath":"index.md","lastUpdated":1763824771000}`),o={name:"index.md"};function a(n,s,r,l,d,c){return i(),t("div")}const u=e(o,[["render",a]]);export{m as __pageData,u as default};
2 |
--------------------------------------------------------------------------------
/homepage/assets/index.md.DUOif5wB.lean.js:
--------------------------------------------------------------------------------
1 | import{_ as e,c as t,o as i}from"./chunks/framework.CTVYQtO4.js";const m=JSON.parse(`{"title":"QuickRetro - Free and Open-Source Sprint Retrospective Meeting App","description":"","frontmatter":{"layout":"home","title":"QuickRetro - Free and Open-Source Sprint Retrospective Meeting App","hero":{"name":"QuickRetro","text":"Sprint Retrospective Meeting App for Remote Agile Teams","tagline":"Free, Open-Source & Self-hosted","actions":[{"theme":"brand","text":"Live Demo","link":"https://demo.quickretro.app"},{"theme":"alt","text":"Getting Started","link":"/guide/getting-started"}],"image":{"light":"/logo_large_light.png","dark":"/logo_large_dark.png","alt":"QuickRetro"}},"features":[{"icon":"🙅♂️","title":"No Signups","details":"That's right! No need to signup or login"},{"icon":"♾️","title":"No Board Limits","details":"Create Boards or Invite Users without limits"},{"icon":"📱","title":"Mobile Friendly UI","details":"Easily participate from your mobile phone"},{"icon":"📝","title":"Customize Columns","details":"Choose upto 5 columns with any name in any order"},{"icon":"🙈","title":"Mask/Blur messages","details":"Avoid revealing messages of other participants"},{"icon":"👤","title":"Anonymous Messages","details":"Post messages without revealing your name"},{"icon":"⬇️","title":"Print as PDF","details":"Print to save messages as PDF"},{"icon":"⏱️","title":"Countdown Timer","details":"Stopwatch with max 1 hour limit"},{"icon":"🔒","title":"Board Lock","details":"Lock to stop addition/updation of messages"},{"icon":"💬","title":"Comments","details":"Add comments to discuss ideas directly on each card"},{"icon":"🌙","title":"Dark Theme","details":"Easily switch to use a Dark theme"},{"icon":"🔦","title":"Focussed View","details":"Highlight cards just for a User at a time"},{"icon":"🤖","title":"Smart CAPTCHA Integration","details":"Built-in integration with Cloudflare Turnstile"},{"icon":"👥","title":"Online Presence Display","details":"See participants present in the meeting"},{"icon":"🗑️","title":"Auto-Delete data","details":"Auto-delete data with configurable retention duration"}]},"headers":[],"relativePath":"index.md","filePath":"index.md","lastUpdated":1763824771000}`),o={name:"index.md"};function a(n,s,r,l,d,c){return i(),t("div")}const u=e(o,[["render",a]]);export{m as __pageData,u as default};
2 |
--------------------------------------------------------------------------------
/src/frontend/src/components/LanguageSelector.vue:
--------------------------------------------------------------------------------
1 |
7 |
8 |
9 |
10 |
39 |
40 |
--------------------------------------------------------------------------------
/docs/docs/index.md:
--------------------------------------------------------------------------------
1 | ---
2 | # https://vitepress.dev/reference/default-theme-home-page
3 | layout: home
4 |
5 | title: "QuickRetro - Free and Open-Source Sprint Retrospective Meeting App"
6 |
7 | hero:
8 | name: "QuickRetro"
9 | text: "Sprint Retrospective Meeting App for Remote Agile Teams"
10 | tagline: Free, Open-Source & Self-hosted
11 | actions:
12 | - theme: brand
13 | text: Live Demo
14 | link: https://demo.quickretro.app
15 | - theme: alt
16 | text: Getting Started
17 | link: /guide/getting-started
18 | image:
19 | light: /logo_large_light.png
20 | dark: /logo_large_dark.png
21 | # src: /logo.png
22 | alt: QuickRetro
23 |
24 | features:
25 | - icon: 🙅♂️
26 | title: No Signups
27 | details: That's right! No need to signup or login
28 | - icon: ♾️
29 | title: No Board Limits
30 | details: Create Boards or Invite Users without limits
31 | - icon: 📱
32 | title: Mobile Friendly UI
33 | details: Easily participate from your mobile phone
34 | - icon: 📝
35 | title: Customize Columns
36 | details: Choose upto 5 columns with any name in any order
37 | - icon: 🙈
38 | title: Mask/Blur messages
39 | details: Avoid revealing messages of other participants
40 | - icon: 👤
41 | title: Anonymous Messages
42 | details: Post messages without revealing your name
43 | - icon: ⬇️
44 | title: Print as PDF
45 | details: Print to save messages as PDF
46 | - icon: ⏱️
47 | title: Countdown Timer
48 | details: Stopwatch with max 1 hour limit
49 | - icon: 🔒
50 | title: Board Lock
51 | details: Lock to stop addition/updation of messages
52 | - icon: 💬
53 | title: Comments
54 | details: Add comments to discuss ideas directly on each card
55 | - icon: 🌙
56 | title: Dark Theme
57 | details: Easily switch to use a Dark theme
58 | - icon: 🔦
59 | title: Focussed View
60 | details: Highlight cards just for a User at a time
61 | - icon: 🤖
62 | title: Smart CAPTCHA Integration
63 | details: Built-in integration with Cloudflare Turnstile
64 | - icon: 👥
65 | title: Online Presence Display
66 | details: See participants present in the meeting
67 | - icon: 🗑️
68 | title: Auto-Delete data
69 | details: Auto-delete data with configurable retention duration
70 | ---
--------------------------------------------------------------------------------
/docs/docs/guide/create-board.md:
--------------------------------------------------------------------------------
1 | # Create Board
2 |
3 | The first thing you do is create/setup a board.\
4 | Enter a name for the Board and an optional Team name.
5 |
6 | ::: info NOTE
7 | The board creator is also the board owner and can perform multiple actions not available to others.\
8 | We'll soon see it in [Dashboard](dashboard) section.
9 | :::
10 |
11 | ## Configuring Board Columns
12 | ::: tip
13 | Since the introduction of multi-language support with , default column names can be automatically translated to other
14 | languages.\
15 | ***Custom column names are not automatically translated.***\
16 | It is recommended to use the defaults, if any of your team members use the app in a different language.
17 | :::
18 |
19 | A max of 5 columns are allowed. The first 3 columns are always enabled by default.\
20 | You can choose which columns you want and name them accordingly.
21 |
22 |
23 |
24 | Click the coloured dot (***present towards left of each column name***) to enable/disable a column.\
25 | Click the column name text to type any custom name.
26 |
27 | ### Changing column order
28 | Available from
29 | Drag-and-Drop columns vertically to change the column order.
30 |
31 | When a Board is created, the user is taken to the [Dashboard](dashboard).
32 |
33 | ## Quick video
34 |
35 |
39 |
40 | ## Cloudflare Turnstile Integration
41 |
42 | Available from
43 |
44 |
45 |
46 | Cloudflare Turnstile is a CAPTCHA alternative provided by Cloudflare. The integration can be enabled/disabled in a configurable way. It is disabled by default.
47 |
48 | Details to enable it provided in [Configurations](configurations#enable-cloudflare-turnstile)
49 |
50 |
--------------------------------------------------------------------------------
/src/frontend/src/components/NewCard.vue:
--------------------------------------------------------------------------------
1 |
62 |
63 |
64 |
65 |
66 |
67 |
69 |
70 |
71 |
72 |
--------------------------------------------------------------------------------
/homepage/assets/guide_getting-started.md.C9__6HHp.lean.js:
--------------------------------------------------------------------------------
1 | import{_ as o,C as i,c as n,o as l,j as e,a as r,G as s,ae as d}from"./chunks/framework.CTVYQtO4.js";const T=JSON.parse('{"title":"Getting Started","description":"","frontmatter":{},"headers":[],"relativePath":"guide/getting-started.md","filePath":"guide/getting-started.md","lastUpdated":1766227945000}'),u={name:"guide/getting-started.md"},p={id:"latest-version",tabindex:"-1"},g={class:"danger custom-block"};function m(b,t,f,k,v,h){const a=i("Badge");return l(),n("div",null,[t[8]||(t[8]=e("h1",{id:"getting-started",tabindex:"-1"},[r("Getting Started "),e("a",{class:"header-anchor",href:"#getting-started","aria-label":'Permalink to "Getting Started"'},"")],-1)),t[9]||(t[9]=e("p",null,[r("This guide gives a quick and easy functional walkthrough of QuickRetro app."),e("br"),r(" To start, visit the site and type in a name to join as guest. There is no signup/login process.")],-1)),e("h3",p,[t[0]||(t[0]=r("Latest version ")),s(a,{type:"tip",text:"v1.6.1"}),t[1]||(t[1]=r()),t[2]||(t[2]=e("a",{class:"header-anchor",href:"#latest-version","aria-label":'Permalink to "Latest version "'},"",-1))]),t[10]||(t[10]=e("h2",{id:"try-the-demo",tabindex:"-1"},[r("Try the Demo "),e("a",{class:"header-anchor",href:"#try-the-demo","aria-label":'Permalink to "Try the Demo"'},"")],-1)),t[11]||(t[11]=e("p",null,[r("Try out the "),e("a",{href:"https://demo.quickretro.app",target:"_blank",rel:"noreferrer"},"live demo"),r(". It is recommended to self-host.")],-1)),e("div",g,[t[5]||(t[5]=e("p",{class:"custom-block-title"},"DATA CLEANUP",-1)),t[6]||(t[6]=e("p",null,[r("All data in "),e("a",{href:"https://demo.quickretro.app",target:"_blank",rel:"noreferrer"},"demo"),r(" site is auto-deleted in "),e("strong",null,"2 days"),r(".")],-1)),e("p",null,[t[3]||(t[3]=r("In versions prior to ")),s(a,{type:"danger",text:"v1.5.2"}),t[4]||(t[4]=r(", data is auto-deleted in 2 hours."))])]),t[12]||(t[12]=e("div",{class:"info custom-block"},[e("p",{class:"custom-block-title"},"NOTE"),e("p",null,"The name you enter initially is saved in your browser cache. It will be auto-filled the next time you visit. You can change it if needed.")],-1)),t[13]||(t[13]=e("h2",{id:"supported-languages",tabindex:"-1"},[r("Supported Languages "),e("a",{class:"header-anchor",href:"#supported-languages","aria-label":'Permalink to "Supported Languages"'},"")],-1)),e("p",null,[t[7]||(t[7]=d("",29)),s(a,{type:"tip",text:"v1.5.5^"})])])}const x=o(u,[["render",m]]);export{T as __pageData,x as default};
2 |
--------------------------------------------------------------------------------
/src/frontend/src/components/NewComment.vue:
--------------------------------------------------------------------------------
1 |
66 |
67 |
68 |
71 |
--------------------------------------------------------------------------------
/homepage/assets/guide_getting-started.md.C9__6HHp.js:
--------------------------------------------------------------------------------
1 | import{_ as o,C as i,c as n,o as l,j as e,a as r,G as s,ae as d}from"./chunks/framework.CTVYQtO4.js";const T=JSON.parse('{"title":"Getting Started","description":"","frontmatter":{},"headers":[],"relativePath":"guide/getting-started.md","filePath":"guide/getting-started.md","lastUpdated":1766227945000}'),u={name:"guide/getting-started.md"},p={id:"latest-version",tabindex:"-1"},g={class:"danger custom-block"};function m(b,t,f,k,v,h){const a=i("Badge");return l(),n("div",null,[t[8]||(t[8]=e("h1",{id:"getting-started",tabindex:"-1"},[r("Getting Started "),e("a",{class:"header-anchor",href:"#getting-started","aria-label":'Permalink to "Getting Started"'},"")],-1)),t[9]||(t[9]=e("p",null,[r("This guide gives a quick and easy functional walkthrough of QuickRetro app."),e("br"),r(" To start, visit the site and type in a name to join as guest. There is no signup/login process.")],-1)),e("h3",p,[t[0]||(t[0]=r("Latest version ")),s(a,{type:"tip",text:"v1.6.1"}),t[1]||(t[1]=r()),t[2]||(t[2]=e("a",{class:"header-anchor",href:"#latest-version","aria-label":'Permalink to "Latest version "'},"",-1))]),t[10]||(t[10]=e("h2",{id:"try-the-demo",tabindex:"-1"},[r("Try the Demo "),e("a",{class:"header-anchor",href:"#try-the-demo","aria-label":'Permalink to "Try the Demo"'},"")],-1)),t[11]||(t[11]=e("p",null,[r("Try out the "),e("a",{href:"https://demo.quickretro.app",target:"_blank",rel:"noreferrer"},"live demo"),r(". It is recommended to self-host.")],-1)),e("div",g,[t[5]||(t[5]=e("p",{class:"custom-block-title"},"DATA CLEANUP",-1)),t[6]||(t[6]=e("p",null,[r("All data in "),e("a",{href:"https://demo.quickretro.app",target:"_blank",rel:"noreferrer"},"demo"),r(" site is auto-deleted in "),e("strong",null,"2 days"),r(".")],-1)),e("p",null,[t[3]||(t[3]=r("In versions prior to ")),s(a,{type:"danger",text:"v1.5.2"}),t[4]||(t[4]=r(", data is auto-deleted in 2 hours."))])]),t[12]||(t[12]=e("div",{class:"info custom-block"},[e("p",{class:"custom-block-title"},"NOTE"),e("p",null,"The name you enter initially is saved in your browser cache. It will be auto-filled the next time you visit. You can change it if needed.")],-1)),t[13]||(t[13]=e("h2",{id:"supported-languages",tabindex:"-1"},[r("Supported Languages "),e("a",{class:"header-anchor",href:"#supported-languages","aria-label":'Permalink to "Supported Languages"'},"")],-1)),e("p",null,[t[7]||(t[7]=d("English 简体中文 (zh-CN) Español Deutsch Français Português (Brasil) Русский (ru) 日本語 (ja) Português Nederlands 한국어 (ko) Українська (uk) Italiano Français (Canada) Polski ",29)),s(a,{type:"tip",text:"v1.5.5^"})])])}const x=o(u,[["render",m]]);export{T as __pageData,x as default};
2 |
--------------------------------------------------------------------------------
/homepage/assets/guide_create-board.md.CidzqPom.lean.js:
--------------------------------------------------------------------------------
1 | import{v as i,C as d,c as s,o as u,ae as n,j as a,a as o,G as l}from"./chunks/framework.CTVYQtO4.js";const m="/createboard.png",b="/videos/create-board.mp4",p="/createboard_turnstile.png",g={class:"tip custom-block"},v=JSON.parse('{"title":"Create Board","description":"","frontmatter":{},"headers":[],"relativePath":"guide/create-board.md","filePath":"guide/create-board.md","lastUpdated":1762606700000}'),f={name:"guide/create-board.md"},C=Object.assign(f,{setup(c){return i(()=>{const t=document.getElementById("createBoardVideo");t&&(t.playbackRate=2.5)}),(t,e)=>{const r=d("Badge");return u(),s("div",null,[e[11]||(e[11]=n("",4)),a("div",g,[e[6]||(e[6]=a("p",{class:"custom-block-title"},"TIP",-1)),a("p",null,[e[0]||(e[0]=o("Since the introduction of multi-language support with ")),l(r,{type:"tip",text:"v1.3.0"}),e[1]||(e[1]=o(", default column names can be automatically translated to other languages.")),e[2]||(e[2]=a("br",null,null,-1)),e[3]||(e[3]=a("em",null,[a("strong",null,"Custom column names are not automatically translated.")],-1)),e[4]||(e[4]=a("br",null,null,-1)),e[5]||(e[5]=o(" It is recommended to use the defaults, if any of your team members use the app in a different language."))])]),e[12]||(e[12]=a("p",null,[o("A max of 5 columns are allowed. The first 3 columns are always enabled by default."),a("br"),o(" You can choose which columns you want and name them accordingly.")],-1)),e[13]||(e[13]=a("img",{src:m,class:"shadow-img",alt:"Create Board",width:"360",loading:"lazy"},null,-1)),e[14]||(e[14]=a("p",null,[o("Click the coloured dot ("),a("em",null,[a("strong",null,"present towards left of each column name")]),o(") to enable/disable a column."),a("br"),o(" Click the column name text to type any custom name.")],-1)),e[15]||(e[15]=a("h3",{id:"changing-column-order",tabindex:"-1"},[o("Changing column order "),a("a",{class:"header-anchor",href:"#changing-column-order","aria-label":'Permalink to "Changing column order"'},"")],-1)),a("p",null,[e[7]||(e[7]=o("Available from ")),l(r,{type:"tip",text:"v1.5.4"}),e[8]||(e[8]=a("br",null,null,-1)),e[9]||(e[9]=o(" Drag-and-Drop columns vertically to change the column order."))]),e[16]||(e[16]=n("",4)),a("p",null,[e[10]||(e[10]=o("Available from ")),l(r,{type:"tip",text:"v1.4.0"})]),e[17]||(e[17]=a("img",{src:p,class:"shadow-img",alt:"Cloudflare Turnstile",width:"360",loading:"lazy"},null,-1)),e[18]||(e[18]=a("p",null,"Cloudflare Turnstile is a CAPTCHA alternative provided by Cloudflare. The integration can be enabled/disabled in a configurable way. It is disabled by default.",-1)),e[19]||(e[19]=a("p",null,[o("Details to enable it provided in "),a("a",{href:"./configurations#enable-cloudflare-turnstile"},"Configurations")],-1))])}}});export{v as __pageData,C as default};
2 |
--------------------------------------------------------------------------------
/src/frontend/src/i18n/zh-CN.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | langName: '简体中文 (zh-CN)',
3 | common: {
4 | anonymous: '匿名',
5 | minutes: '分钟',
6 | seconds: '秒',
7 | start: '开始',
8 | stop: '停止',
9 | copy: '复制',
10 | board: '看板',
11 | toolTips: {
12 | darkTheme: '启用深色主题',
13 | lightTheme: '启用浅色主题'
14 | },
15 | contentOverloadError: '内容超过允许限制',
16 | contentStrippingError: '内容超出限制,多余文字已被删除',
17 | invalidColumnSelection: '请选择列'
18 | },
19 | join: {
20 | label: '以访客加入',
21 | namePlaceholder: '在此输入姓名!',
22 | nameRequired: '请输入姓名',
23 | button: '加入'
24 | },
25 | createBoard: {
26 | label: '创建看板',
27 | namePlaceholder: '输入看板名称!',
28 | nameRequired: '请输入看板名称',
29 | teamNamePlaceholder: '输入团队名称!',
30 | invalidColumnSelection: '请选择列',
31 | button: '创建',
32 | buttonProgress: '创建中..',
33 | captchaInfo: '请完成验证码以继续',
34 | boardCreationError: '创建看板时出错'
35 | },
36 | dashboard: {
37 | timer: {
38 | oneMinuteLeft: '剩余一分钟',
39 | timeCompleted: '时间到!',
40 | title: '开始/停止计时器',
41 | helpTip: '使用+ -或方向键调整时间,最长1小时',
42 | invalid: '无效时间,允许范围:1秒至60分钟',
43 | tooltip: '倒计时器'
44 | },
45 | share: {
46 | title: '复制并分享链接',
47 | linkCopied: '链接已复制!',
48 | linkCopyError: '复制失败,请手动复制',
49 | toolTip: '分享看板'
50 | },
51 | mask: {
52 | maskTooltip: '隐藏消息',
53 | unmaskTooltip: '显示消息'
54 | },
55 | lock: {
56 | lockTooltip: '锁定看板',
57 | unlockTooltip: '解锁看板',
58 | message: '看板已被锁定',
59 | discardChanges: '看板已锁定!未保存的消息已丢弃'
60 | },
61 | spotlight: {
62 | noCardsToFocus: '没有可聚焦的卡片',
63 | tooltip: '聚焦卡片'
64 | },
65 | print: {
66 | tooltip: '打印'
67 | },
68 | language: {
69 | tooltip : '更改语言'
70 | },
71 | delete: {
72 | title: '确认删除',
73 | text: '数据删除后无法恢复。确定要继续吗?',
74 | tooltip: '删除此看板',
75 | continueDelete: '是',
76 | cancelDelete: '否'
77 | },
78 | columns: {
79 | col01: '做得好的',
80 | col02: '挑战',
81 | col03: '行动计划',
82 | col04: '感谢',
83 | col05: '改进建议',
84 | cannotDisable: "无法禁用包含卡片的列",
85 | update: "更新",
86 | discardNewMessages: '您的草稿已被丢弃,因为该列已被禁用。'
87 | },
88 | printFooter: '创建于',
89 | offline: '离线状态',
90 | notExists: '看板已被自动删除,或由创建者手动删除。',
91 | autoDeleteScheduleBase: '该看板将于 {date} 自动清理',
92 | autoDeleteScheduleAddon: ',因此您无需担心手动删除它。'
93 | }
94 | }
--------------------------------------------------------------------------------
/src/frontend/src/i18n/ja.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | langName: '日本語 (ja)',
3 | common: {
4 | anonymous: '匿名',
5 | minutes: '分',
6 | seconds: '秒',
7 | start: '開始',
8 | stop: '停止',
9 | copy: 'コピー',
10 | board: 'ボード',
11 | toolTips: {
12 | darkTheme: 'ダークテーマ',
13 | lightTheme: 'ライトテーマ'
14 | },
15 | contentOverloadError: 'コンテンツ制限超過',
16 | contentStrippingError: '末尾のテキストが削除されました',
17 | invalidColumnSelection: '列を選択してください'
18 | },
19 | join: {
20 | label: 'ゲスト参加',
21 | namePlaceholder: '名前を入力してください!',
22 | nameRequired: '名前を入力してください',
23 | button: '参加'
24 | },
25 | createBoard: {
26 | label: 'ボード作成',
27 | namePlaceholder: 'ボード名を入力!',
28 | nameRequired: 'ボード名を入力してください',
29 | teamNamePlaceholder: 'チーム名を入力!',
30 | button: '作成',
31 | buttonProgress: '作成中..',
32 | captchaInfo: 'CAPTCHAを完了してください',
33 | boardCreationError: 'ボードの作成中にエラーが発生しました'
34 | },
35 | dashboard: {
36 | timer: {
37 | oneMinuteLeft: '残り1分',
38 | timeCompleted: '時間切れです!',
39 | title: 'タイマー開始/停止',
40 | helpTip: '+/-または矢印キーで調整 最大1時間',
41 | invalid: '無効な時間です(1秒~60分)',
42 | tooltip: 'カウントダウンタイマー'
43 | },
44 | share: {
45 | title: 'URLを共有',
46 | linkCopied: 'コピーしました!',
47 | linkCopyError: 'コピー失敗 手動でコピーしてください',
48 | toolTip: 'ボードを共有'
49 | },
50 | mask: {
51 | maskTooltip: 'メッセージを非表示',
52 | unmaskTooltip: 'メッセージを表示'
53 | },
54 | lock: {
55 | lockTooltip: 'ボードをロック',
56 | unlockTooltip: 'ロック解除',
57 | message: 'ボードがロックされています',
58 | discardChanges: 'ボードがロックされました!保存されていないメッセージは破棄されました'
59 | },
60 | spotlight: {
61 | noCardsToFocus: 'カードがありません',
62 | tooltip: 'カードをフォーカス'
63 | },
64 | print: {
65 | tooltip: '印刷'
66 | },
67 | language: {
68 | tooltip : '言語を変更'
69 | },
70 | delete: {
71 | title: '削除の確認',
72 | text: '削除後はデータを復元できません。続行してもよろしいですか?',
73 | tooltip: 'このボードを削除',
74 | continueDelete: 'はい',
75 | cancelDelete: 'いいえ'
76 | },
77 | columns: {
78 | col01: '良かった点',
79 | col02: '課題',
80 | col03: 'アクション項目',
81 | col04: '感謝',
82 | col05: '改善点',
83 | cannotDisable: "カードがある列は無効にできません",
84 | update: "更新",
85 | discardNewMessages: '列が無効化されたため、下書きは破棄されました。'
86 | },
87 | printFooter: '作成者',
88 | offline: 'オフライン',
89 | notExists: 'ボードは自動的に削除されたか、作成者によって手動で削除されました。',
90 | autoDeleteScheduleBase: 'このボードは {date} に自動的にクリーンアップされます',
91 | autoDeleteScheduleAddon: 'ので、手動で削除する必要はありません。'
92 | }
93 | }
--------------------------------------------------------------------------------
/src/frontend/src/i18n/ko.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | langName: '한국어 (ko)',
3 | common: {
4 | anonymous: '익명',
5 | minutes: '분',
6 | seconds: '초',
7 | start: '시작',
8 | stop: '중지',
9 | copy: '복사',
10 | board: '보드',
11 | toolTips: {
12 | darkTheme: '다크 모드 켜기',
13 | lightTheme: '라이트 모드 켜기'
14 | },
15 | contentOverloadError: '허용된 내용 초과',
16 | contentStrippingError: '초과된 텍스트가 삭제되었습니다',
17 | invalidColumnSelection: '열을 선택해 주세요'
18 | },
19 | join: {
20 | label: '게스트로 참여',
21 | namePlaceholder: '이름을 입력하세요!',
22 | nameRequired: '이름을 입력해 주세요',
23 | button: '참여'
24 | },
25 | createBoard: {
26 | label: '보드 생성',
27 | namePlaceholder: '보드 이름 입력!',
28 | nameRequired: '보드 이름을 입력해 주세요',
29 | teamNamePlaceholder: '팀 이름 입력!',
30 | button: '생성',
31 | buttonProgress: '생성 중..',
32 | captchaInfo: '계속하려면 CAPTCHA를 완료하세요',
33 | boardCreationError: '보드 생성 중 오류 발생'
34 | },
35 | dashboard: {
36 | timer: {
37 | oneMinuteLeft: '1분 남음',
38 | timeCompleted: '시간 종료!',
39 | title: '타이머 시작/중지',
40 | helpTip: '+ - 또는 방향키로 시간 조절. 최대 1시간.',
41 | invalid: '유효하지 않은 시간 (1초 ~ 60분)',
42 | tooltip: '카운트다운 타이머'
43 | },
44 | share: {
45 | title: '링크 공유',
46 | linkCopied: '링크 복사됨!',
47 | linkCopyError: '복사 실패. 직접 복사해 주세요.',
48 | toolTip: '보드 공유'
49 | },
50 | mask: {
51 | maskTooltip: '메시지 숨기기',
52 | unmaskTooltip: '메시지 표시'
53 | },
54 | lock: {
55 | lockTooltip: '보드 잠금',
56 | unlockTooltip: '잠금 해제',
57 | message: '보드가 잠겨 있습니다',
58 | discardChanges: '보드 잠김! 저장되지 않은 메시지가 삭제되었습니다'
59 | },
60 | spotlight: {
61 | noCardsToFocus: '포커스할 카드 없음',
62 | tooltip: '카드 강조'
63 | },
64 | print: {
65 | tooltip: '인쇄'
66 | },
67 | language: {
68 | tooltip : '언어 변경'
69 | },
70 | delete: {
71 | title: '삭제 확인',
72 | text: '삭제 후에는 데이터를 복구할 수 없습니다. 계속 진행하시겠습니까?',
73 | tooltip: '이 보드 삭제',
74 | continueDelete: '예',
75 | cancelDelete: '아니오'
76 | },
77 | columns: {
78 | col01: '잘된 점',
79 | col02: '어려운 점',
80 | col03: '액션 항목',
81 | col04: '감사한 점',
82 | col05: '개선점',
83 | cannotDisable: "카드가 있는 열은 비활성화할 수 없습니다",
84 | update: "업데이트",
85 | discardNewMessages: '열이 비활성화되어 임시 작성 내용이 삭제되었습니다.'
86 | },
87 | printFooter: '생성 도구',
88 | offline: '오프라인 상태',
89 | notExists: '보드는 자동으로 삭제되었거나 생성자가 수동으로 삭제했습니다.',
90 | autoDeleteScheduleBase: '{date}에 이 보드는 자동으로 정리됩니다',
91 | autoDeleteScheduleAddon: ', 따라서 직접 삭제할 필요가 없습니다.'
92 | }
93 | }
--------------------------------------------------------------------------------
/src/event.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "encoding/json"
5 | "log/slog"
6 | )
7 |
8 | type Event struct {
9 | Type string `json:"typ"` // Values can be one of "reg", "msg", "del", "delall", "like", "mask", "timer", "catchng". "closing" is not initiated from UI.
10 | Payload json.RawMessage `json:"pyl"`
11 | }
12 |
13 | // Handle event
14 | func (e *Event) Handle(h *Hub) {
15 | payload := e.ParsePayload()
16 | if payload == nil {
17 | return
18 | }
19 | // Call individual handlers
20 | switch e.Type {
21 | case "mask":
22 | payload.(*MaskEvent).Handle(e, h)
23 | case "lock":
24 | payload.(*LockEvent).Handle(e, h)
25 | case "reg":
26 | payload.(*RegisterEvent).Handle(e, h)
27 | case "msg":
28 | payload.(*MessageEvent).Handle(e, h)
29 | case "like":
30 | payload.(*LikeMessageEvent).Handle(e, h)
31 | case "del":
32 | payload.(*DeleteMessageEvent).Handle(e, h)
33 | case "delall":
34 | payload.(*DeleteAllEvent).Handle(e, h)
35 | case "catchng":
36 | payload.(*CategoryChangeEvent).Handle(e, h)
37 | case "timer":
38 | payload.(*TimerEvent).Handle(e, h)
39 | case "colreset":
40 | payload.(*ColumnsChangeEvent).Handle(e, h)
41 | }
42 | }
43 |
44 | // Broadcast event. This is executed when Redis pubsub sends message/data. Hub gets the message first, which is forwarded here.
45 | func (e *Event) Broadcast(m *Message, h *Hub) {
46 | payload := e.ParsePayload()
47 | if payload == nil {
48 | return
49 | }
50 | // Call individual broadcasters
51 | switch e.Type {
52 | case "mask":
53 | payload.(*MaskEvent).Broadcast(h)
54 | case "lock":
55 | payload.(*LockEvent).Broadcast(h)
56 | case "reg":
57 | payload.(*RegisterEvent).Broadcast(h)
58 | case "msg":
59 | payload.(*MessageEvent).Broadcast(m, h)
60 | case "like":
61 | payload.(*LikeMessageEvent).Broadcast(m, h)
62 | case "del":
63 | payload.(*DeleteMessageEvent).Broadcast(m, h)
64 | case "delall":
65 | payload.(*DeleteAllEvent).Broadcast(h)
66 | case "catchng":
67 | payload.(*CategoryChangeEvent).Broadcast(h)
68 | case "timer":
69 | payload.(*TimerEvent).Broadcast(h)
70 | case "colreset":
71 | payload.(*ColumnsChangeEvent).Broadcast(h)
72 | case "closing":
73 | payload.(*UserClosingEvent).Broadcast(h)
74 | }
75 | }
76 |
77 | func (e *Event) ParsePayload() interface{} {
78 | // Todo: Check allocations.
79 | payloadMap := map[string]interface{}{
80 | "mask": &MaskEvent{},
81 | "lock": &LockEvent{},
82 | "reg": &RegisterEvent{},
83 | "msg": &MessageEvent{},
84 | "like": &LikeMessageEvent{},
85 | "del": &DeleteMessageEvent{},
86 | "delall": &DeleteAllEvent{},
87 | "catchng": &CategoryChangeEvent{},
88 | "timer": &TimerEvent{},
89 | "colreset": &ColumnsChangeEvent{},
90 | "closing": &UserClosingEvent{},
91 | }
92 | payload, ok := payloadMap[e.Type]
93 | if !ok {
94 | slog.Error("Unsupported command type", "commandType", e.Type)
95 | return nil
96 | }
97 | if err := json.Unmarshal(e.Payload, payload); err != nil {
98 | slog.Error("Error unmarshalling event payload", "details", err.Error())
99 | return nil
100 | }
101 | slog.Debug("Unmarshalled event payload", "payload", payload)
102 | return payload
103 | }
104 |
--------------------------------------------------------------------------------
/src/frontend/src/components/CountdownTimer.vue:
--------------------------------------------------------------------------------
1 |
77 |
78 |
79 |
80 | {{ formattedRemainingTime }}
81 |
84 |
--------------------------------------------------------------------------------
/src/eventresponses.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | type UserDetails struct {
4 | Nickname string `json:"nickname"`
5 | Xid string `json:"xid"`
6 | }
7 |
8 | type RegisterResponse struct {
9 | Type string `json:"typ"`
10 | BoardName string `json:"boardName"`
11 | BoardTeam string `json:"boardTeam"`
12 | BoardStatus string `json:"boardStatus"`
13 | BoardColumns []*BoardColumn `json:"columns"` // Using same BoardColumn struct that is used for request and redis store. Todo - refactor later.
14 | Users []UserDetails `json:"users"`
15 | Messages []MessageResponse `json:"messages"` // Todo: Change to *MessageResponse
16 | Comments []MessageResponse `json:"comments"` // Todo: Change to *MessageResponse
17 | BoardExpiryTimeUtcSeconds int64 `json:"boardExpiryUtcSeconds"` // Unix Timestamp Seconds
18 | TimerExpiresInSeconds uint16 `json:"timerExpiresInSeconds"` // uint16 since we are restricting timer to max 1 hour (3600 seconds)
19 | NotifyNewBoardExpiry bool `json:"notifyNewBoardExpiry"`
20 | BoardMasking bool `json:"boardMasking"`
21 | BoardLock bool `json:"boardLock"`
22 | IsBoardOwner bool `json:"isBoardOwner"`
23 | // Mine bool `json:"mine"`
24 | }
25 |
26 | type UserJoiningResponse struct {
27 | Type string `json:"typ"`
28 | Nickname string `json:"nickname"`
29 | Xid string `json:"xid"`
30 | }
31 |
32 | type UserClosingResponse struct {
33 | Type string `json:"typ"`
34 | Xid string `json:"xid"`
35 | }
36 |
37 | type MaskResponse struct {
38 | Type string `json:"typ"`
39 | Mask bool `json:"mask"`
40 | }
41 |
42 | type LockResponse struct {
43 | Type string `json:"typ"`
44 | Lock bool `json:"lock"`
45 | }
46 |
47 | type MessageResponse struct {
48 | Type string `json:"typ"`
49 | Id string `json:"id"`
50 | ParentId string `json:"pid"`
51 | ByXid string `json:"byxid"`
52 | ByNickname string `json:"nickname"`
53 | Content string `json:"msg"`
54 | Category string `json:"cat"`
55 | Likes int64 `json:"likes"`
56 | Liked bool `json:"liked"` // True if receiving user has liked this message.
57 | Mine bool `json:"mine"`
58 | Anonymous bool `json:"anon"`
59 | }
60 |
61 | type LikeMessageResponse struct {
62 | Type string `json:"typ"`
63 | Id string `json:"id"`
64 | Likes int64 `json:"likes"`
65 | Liked bool `json:"liked"` // True if receiving user has liked this message.
66 | }
67 |
68 | type DeleteMessageResponse struct {
69 | Type string `json:"typ"`
70 | Id string `json:"id"`
71 | }
72 |
73 | type DeleteAllResponse struct {
74 | Type string `json:"typ"`
75 | }
76 |
77 | type CategoryChangeResponse struct {
78 | Type string `json:"typ"`
79 | MessageId string `json:"id"`
80 | NewCategory string `json:"newcat"`
81 | }
82 |
83 | type TimerResponse struct {
84 | Type string `json:"typ"`
85 | ExpiresInSeconds uint16 `json:"expiresInSeconds"`
86 | }
87 |
88 | type ColumnsChangeResponse struct {
89 | Type string `json:"typ"`
90 | BoardColumns []*BoardColumn `json:"columns"` // Using same BoardColumn struct that is used for request and redis store. Todo - refactor later.
91 | }
92 |
--------------------------------------------------------------------------------
/src/frontend/src/i18n/ru.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | langName: 'Русский (ru)',
3 | common: {
4 | anonymous: 'Аноним',
5 | minutes: 'Минуты',
6 | seconds: 'Секунды',
7 | start: 'Старт',
8 | stop: 'Стоп',
9 | copy: 'Копировать',
10 | board: 'Доска',
11 | toolTips: {
12 | darkTheme: 'Тёмная тема',
13 | lightTheme: 'Светлая тема'
14 | },
15 | contentOverloadError: 'Превышен лимит содержимого',
16 | contentStrippingError: 'Лишний текст удалён',
17 | invalidColumnSelection: 'Выберите столбцы'
18 | },
19 | join: {
20 | label: 'Войти как гость',
21 | namePlaceholder: 'Введите имя здесь!',
22 | nameRequired: 'Введите имя',
23 | button: 'Присоединиться'
24 | },
25 | createBoard: {
26 | label: 'Создать доску',
27 | namePlaceholder: 'Название доски здесь!',
28 | nameRequired: 'Введите название доски',
29 | teamNamePlaceholder: 'Название команды здесь!',
30 | button: 'Создать',
31 | buttonProgress: 'Создание..',
32 | captchaInfo: 'Пройдите CAPTCHA для продолжения',
33 | boardCreationError: 'Ошибка при создании доски'
34 | },
35 | dashboard: {
36 | timer: {
37 | oneMinuteLeft: 'Осталась минута',
38 | timeCompleted: 'Время вышло!',
39 | title: 'Старт/Стоп таймер',
40 | helpTip: 'Используйте +/- или стрелки. Макс. 1 час.',
41 | invalid: 'Недопустимое время (1 сек - 60 мин)',
42 | tooltip: 'Таймер обратного отсчёта'
43 | },
44 | share: {
45 | title: 'Скопируйте и поделитесь ссылкой',
46 | linkCopied: 'Ссылка скопирована!',
47 | linkCopyError: 'Ошибка копирования',
48 | toolTip: 'Поделиться доской'
49 | },
50 | mask: {
51 | maskTooltip: 'Скрыть сообщения',
52 | unmaskTooltip: 'Показать сообщения'
53 | },
54 | lock: {
55 | lockTooltip: 'Заблокировать доску',
56 | unlockTooltip: 'Разблокировать доску',
57 | message: 'Доска заблокирована',
58 | discardChanges: 'Доска заблокирована! Несохранённые сообщения удалены'
59 | },
60 | spotlight: {
61 | noCardsToFocus: 'Нет карточек',
62 | tooltip: 'Выделить карточки'
63 | },
64 | print: {
65 | tooltip: 'Печать'
66 | },
67 | language: {
68 | tooltip : 'Сменить язык'
69 | },
70 | delete: {
71 | title: 'Подтвердите удаление',
72 | text: 'После удаления данные невозможно восстановить. Вы уверены, что хотите продолжить?',
73 | tooltip: 'Удалить эту доску',
74 | continueDelete: 'Да',
75 | cancelDelete: 'Нет'
76 | },
77 | columns: {
78 | col01: 'Что прошло хорошо',
79 | col02: 'Сложности',
80 | col03: 'Действия',
81 | col04: 'Благодарности',
82 | col05: 'Улучшения',
83 | cannotDisable: "Нельзя отключить колонку, в которой есть карточки",
84 | update: "Обновить",
85 | discardNewMessages: 'Ваш черновик был удалён, потому что колонка была отключена.'
86 | },
87 | printFooter: 'Создано с',
88 | offline: 'Офлайн',
89 | notExists: 'Доска была удалена автоматически или вручную её создателем.',
90 | autoDeleteScheduleBase: 'Эта доска будет автоматически очищена {date}',
91 | autoDeleteScheduleAddon: ', поэтому вам не нужно беспокоиться о её ручном удалении.'
92 | }
93 | }
--------------------------------------------------------------------------------
/compose.replicas.yml:
--------------------------------------------------------------------------------
1 | # Example with running multiple services of same image with replicas, load balanced by Docker internally.
2 |
3 | # Cold start
4 | # docker compose -f compose.replicas.yml up
5 |
6 | # Restart services.
7 | # Containers are NOT deleted, just STOPPED and STARTED. No data is lost.
8 | # Use case: ENV variable update.
9 | # docker compose -f compose.replicas.yml stop
10 | # docker compose -f compose.replicas.yml start
11 |
12 | # Deletes containers and networks, then create them back from already built/pulled images.
13 | # Volumes and images are retained, so data in volumes is not lost.
14 | # Use case: if the previous "Restart services" steps are inadequate.
15 | # docker compose -f compose.replicas.yml down
16 | # docker compose -f compose.replicas.yml up
17 |
18 | # Hard reset: removes containers, networks, volumes, and images.
19 | # ALL data stored in volumes will be lost (including Redis data).
20 | # Use case: code changes, new images, or if previous steps are inadequate.
21 | # docker compose -f compose.replicas.yml down --rmi "all" --volumes
22 | # docker compose -f compose.replicas.yml up
23 |
24 | services:
25 | redis:
26 | image: "redis:8.0.1-alpine"
27 | ############## Redis ACL ##############
28 | # volumes:
29 | # - ./redis/users.acl:/usr/local/etc/redis/users.acl
30 | # command: redis-server --aclfile /usr/local/etc/redis/users.acl
31 | # # command: ["redis-server", "--aclfile", "/usr/local/etc/redis/users.acl"]
32 | ############## Redis ACL ##############
33 | restart: always
34 | networks:
35 | - redisnet
36 | # ports:
37 | # - "6379:6379"
38 | expose:
39 | - 6379
40 | volumes:
41 | - redis_data:/data
42 |
43 | app:
44 | build:
45 | context: .
46 | dockerfile: build.Dockerfile
47 | restart: unless-stopped
48 | deploy:
49 | mode: replicated
50 | replicas: 2
51 | restart_policy:
52 | condition: on-failure
53 | max_attempts: 3
54 | depends_on:
55 | - redis
56 | environment:
57 | # Load from .env file in same directory as the compose file.
58 | # To create file, in CLI: echo "REDIS_CONNSTR=redis://redis:6379/0" > .env
59 | # DO NOT create file from Windows. It creates Unicode text, UTF-16, little-endian text, with CRLF line terminators.
60 | - REDIS_CONNSTR=${REDIS_CONNSTR}
61 | # Default Redis (No Auth or ACL). Outside docker - redis://localhost:6379/0
62 | # - REDIS_CONNSTR=redis://redis:6379/0
63 | # Using Redis ACL with Username & Password. Outside docker - redis://app-user:mysecretpassword@localhost:6379/0
64 | # - REDIS_CONNSTR=redis://app-user:mysecretpassword@redis:6379/0
65 | - TURNSTILE_ENABLED=${TURNSTILE_ENABLED}
66 | - TURNSTILE_SITE_KEY=${TURNSTILE_SITE_KEY}
67 | - TURNSTILE_SECRET_KEY=${TURNSTILE_SECRET_KEY}
68 | networks:
69 | - redisnet
70 | - proxynet
71 | expose:
72 | - 8080
73 |
74 | caddy:
75 | image: caddy:2.10.0-alpine
76 | restart: unless-stopped
77 | ports:
78 | - "80:80"
79 | - "443:443"
80 | - "443:443/udp"
81 | depends_on:
82 | - app
83 | networks:
84 | - proxynet
85 | volumes:
86 | - ./Caddyfile:/etc/caddy/Caddyfile
87 | - ./site:/srv
88 | - caddy_data:/data
89 | - caddy_config:/config
90 |
91 | volumes:
92 | redis_data:
93 | caddy_data:
94 | caddy_config:
95 |
96 | networks:
97 | redisnet:
98 | name: redisnet
99 | proxynet:
100 | name: proxynet
--------------------------------------------------------------------------------
/src/helpers.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | // https://www.alexedwards.net/blog/how-to-properly-parse-a-json-request-body
4 |
5 | import (
6 | "encoding/json"
7 | "errors"
8 | "fmt"
9 | "io"
10 | "net/http"
11 | "strconv"
12 | "strings"
13 | "time"
14 | )
15 |
16 | type malformedRequest struct {
17 | msg string
18 | status int
19 | }
20 |
21 | func (mr *malformedRequest) Error() string {
22 | return mr.msg
23 | }
24 |
25 | func decodeJSONBody(w http.ResponseWriter, r *http.Request, dst interface{}) error {
26 | ct := r.Header.Get("Content-Type")
27 | if ct != "" {
28 | mediaType := strings.ToLower(strings.TrimSpace(strings.Split(ct, ";")[0]))
29 | if mediaType != "application/json" {
30 | msg := "Content-Type header is not application/json"
31 | return &malformedRequest{status: http.StatusUnsupportedMediaType, msg: msg}
32 | }
33 | }
34 |
35 | r.Body = http.MaxBytesReader(w, r.Body, 1048576)
36 |
37 | dec := json.NewDecoder(r.Body)
38 | dec.DisallowUnknownFields()
39 |
40 | err := dec.Decode(&dst)
41 | if err != nil {
42 | var syntaxError *json.SyntaxError
43 | var unmarshalTypeError *json.UnmarshalTypeError
44 |
45 | switch {
46 | case errors.As(err, &syntaxError):
47 | msg := fmt.Sprintf("Request body contains badly-formed JSON (at position %d)", syntaxError.Offset)
48 | return &malformedRequest{status: http.StatusBadRequest, msg: msg}
49 |
50 | case errors.Is(err, io.ErrUnexpectedEOF):
51 | msg := "Request body contains badly-formed JSON"
52 | return &malformedRequest{status: http.StatusBadRequest, msg: msg}
53 |
54 | case errors.As(err, &unmarshalTypeError):
55 | msg := fmt.Sprintf("Request body contains an invalid value for the %q field (at position %d)", unmarshalTypeError.Field, unmarshalTypeError.Offset)
56 | return &malformedRequest{status: http.StatusBadRequest, msg: msg}
57 |
58 | case strings.HasPrefix(err.Error(), "json: unknown field "):
59 | fieldName := strings.TrimPrefix(err.Error(), "json: unknown field ")
60 | msg := fmt.Sprintf("Request body contains unknown field %s", fieldName)
61 | return &malformedRequest{status: http.StatusBadRequest, msg: msg}
62 |
63 | case errors.Is(err, io.EOF):
64 | msg := "Request body must not be empty"
65 | return &malformedRequest{status: http.StatusBadRequest, msg: msg}
66 |
67 | case err.Error() == "http: request body too large":
68 | msg := "Request body must not be larger than 1MB"
69 | return &malformedRequest{status: http.StatusRequestEntityTooLarge, msg: msg}
70 |
71 | default:
72 | return err
73 | }
74 | }
75 |
76 | err = dec.Decode(&struct{}{})
77 | if !errors.Is(err, io.EOF) {
78 | msg := "Request body must only contain a single JSON object"
79 | return &malformedRequest{status: http.StatusBadRequest, msg: msg}
80 | }
81 |
82 | return nil
83 | }
84 |
85 | func parseDuration(s string) (time.Duration, error) {
86 | var multiplier time.Duration = 1
87 | switch {
88 | case strings.HasSuffix(s, "s"):
89 | multiplier = time.Second
90 | s = strings.TrimSuffix(s, "s")
91 | case strings.HasSuffix(s, "m"):
92 | multiplier = time.Minute
93 | s = strings.TrimSuffix(s, "m")
94 | case strings.HasSuffix(s, "h"):
95 | multiplier = time.Hour
96 | s = strings.TrimSuffix(s, "h")
97 | case strings.HasSuffix(s, "d"):
98 | multiplier = 24 * time.Hour
99 | s = strings.TrimSuffix(s, "d")
100 | default:
101 | return 0, fmt.Errorf("invalid duration format: missing unit (use s/m/h/d)")
102 | }
103 |
104 | value, err := strconv.Atoi(s)
105 | if err != nil {
106 | return 0, fmt.Errorf("invalid duration value: %w", err)
107 | }
108 |
109 | return time.Duration(value) * multiplier, nil
110 | }
111 |
--------------------------------------------------------------------------------
/compose.multiservice.yml:
--------------------------------------------------------------------------------
1 | # Example with running multiple services of same image, load balanced using Caddy reverse-proxy.
2 |
3 | # Update Caddyfile with instructions given in it.
4 |
5 | # Run following command to build the image before running docker compose.
6 | # docker build -f build.Dockerfile -t quickretro-app .
7 |
8 | # Cold start
9 | # docker compose -f compose.multiservice.yml up
10 |
11 | # Restart services.
12 | # Containers are NOT deleted, just STOPPED and STARTED. No data is lost.
13 | # Use case: ENV variable update.
14 | # docker compose -f compose.multiservice.yml stop
15 | # docker compose -f compose.multiservice.yml start
16 |
17 | # Deletes containers and networks, then create them back from already built/pulled images.
18 | # Volumes and images are retained, so data in volumes is not lost.
19 | # Use case: if the previous "Restart services" steps are inadequate.
20 | # docker compose -f compose.multiservice.yml down
21 | # docker compose -f compose.multiservice.yml up
22 |
23 | # Hard reset: removes containers, networks, volumes, and images.
24 | # ALL data stored in volumes will be lost (including Redis data).
25 | # Use case: code changes, new images, or if previous steps are inadequate.
26 | # docker compose -f compose.multiservice.yml down --rmi "all" --volumes
27 | # docker compose -f compose.multiservice.yml up
28 |
29 | x-app-defaults: &app-defaults
30 | image: quickretro-app
31 | restart: unless-stopped
32 | depends_on:
33 | - redis
34 | environment:
35 | # Load from .env file in same directory as the compose file.
36 | # To create file, in CLI: echo "REDIS_CONNSTR=redis://redis:6379/0" > .env
37 | # DO NOT create file from Windows. It creates Unicode text, UTF-16, little-endian text, with CRLF line terminators.
38 | - REDIS_CONNSTR=${REDIS_CONNSTR}
39 | # Default Redis (No Auth or ACL). Outside docker - redis://localhost:6379/0
40 | # - REDIS_CONNSTR=redis://redis:6379/0
41 | # Using Redis ACL with Username & Password. Outside docker - redis://app-user:mysecretpassword@localhost:6379/0
42 | # - REDIS_CONNSTR=redis://app-user:mysecretpassword@redis:6379/0
43 | - TURNSTILE_ENABLED=${TURNSTILE_ENABLED}
44 | - TURNSTILE_SITE_KEY=${TURNSTILE_SITE_KEY}
45 | - TURNSTILE_SECRET_KEY=${TURNSTILE_SECRET_KEY}
46 | networks:
47 | - redisnet
48 | - proxynet
49 | expose:
50 | - 8080
51 |
52 | services:
53 | redis:
54 | image: "redis:8.0.1-alpine"
55 | ############## Redis ACL ##############
56 | # volumes:
57 | # - ./redis/users.acl:/usr/local/etc/redis/users.acl
58 | # command: redis-server --aclfile /usr/local/etc/redis/users.acl
59 | # # command: ["redis-server", "--aclfile", "/usr/local/etc/redis/users.acl"]
60 | ############## Redis ACL ##############
61 | restart: always
62 | networks:
63 | - redisnet
64 | expose:
65 | - 6379
66 | volumes:
67 | - redis_data:/data
68 |
69 | app:
70 | <<: *app-defaults
71 |
72 | app01:
73 | <<: *app-defaults
74 |
75 | caddy:
76 | image: caddy:2.10.0-alpine
77 | restart: unless-stopped
78 | ports:
79 | - "80:80"
80 | - "443:443"
81 | - "443:443/udp"
82 | depends_on:
83 | - app
84 | networks:
85 | - proxynet
86 | volumes:
87 | - ./Caddyfile:/etc/caddy/Caddyfile
88 | - ./site:/srv
89 | - caddy_data:/data
90 | - caddy_config:/config
91 |
92 | volumes:
93 | redis_data:
94 | caddy_data:
95 | caddy_config:
96 |
97 | networks:
98 | redisnet:
99 | name: redisnet
100 | proxynet:
101 | name: proxynet
--------------------------------------------------------------------------------
/src/frontend/src/i18n/nl.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | langName: 'Nederlands',
3 | common: {
4 | anonymous: 'Anoniem',
5 | minutes: 'Minuten',
6 | seconds: 'Seconden',
7 | start: 'Start',
8 | stop: 'Stop',
9 | copy: 'Kopiëren',
10 | board: 'Bord',
11 | toolTips: {
12 | darkTheme: 'Donker thema inschakelen',
13 | lightTheme: 'Licht thema inschakelen'
14 | },
15 | contentOverloadError: 'Inhoud overschrijdt limiet.',
16 | contentStrippingError: 'Extra tekst verwijderd.',
17 | invalidColumnSelection: 'Selecteer kolom(en)'
18 | },
19 | join: {
20 | label: 'Als gast deelnemen',
21 | namePlaceholder: 'Vul je naam hier in!',
22 | nameRequired: 'Voer je naam in',
23 | button: 'Deelnemen'
24 | },
25 | createBoard: {
26 | label: 'Bord aanmaken',
27 | namePlaceholder: 'Bordnaam hier invullen!',
28 | nameRequired: 'Voer bordnaam in',
29 | teamNamePlaceholder: 'Teamnaam hier invullen!',
30 | button: 'Aanmaken',
31 | buttonProgress: 'Aanmaken..',
32 | captchaInfo: 'Voltooi de CAPTCHA om door te gaan',
33 | boardCreationError: 'Fout bij het aanmaken van het bord'
34 | },
35 | dashboard: {
36 | timer: {
37 | oneMinuteLeft: 'Nog 1 minuut',
38 | timeCompleted: 'Tijd is om!',
39 | title: 'Timer Starten/Stoppen',
40 | helpTip: 'Pas tijd aan met +/- of pijltjes. Maximaal 1 uur.',
41 | invalid: 'Ongeldige tijd (1 seconde - 60 minuten)',
42 | tooltip: 'Countdown-timer'
43 | },
44 | share: {
45 | title: 'Deel deze link',
46 | linkCopied: 'Link gekopieerd!',
47 | linkCopyError: 'Kopieer handmatig.',
48 | toolTip: 'Bord delen'
49 | },
50 | mask: {
51 | maskTooltip: 'Berichten verbergen',
52 | unmaskTooltip: 'Berichten tonen'
53 | },
54 | lock: {
55 | lockTooltip: 'Bord vergrendelen',
56 | unlockTooltip: 'Bord ontgrendelen',
57 | message: 'Bord is vergrendeld.',
58 | discardChanges: 'Board vergrendeld! Niet-opgeslagen berichten verwijderd'
59 | },
60 | spotlight: {
61 | noCardsToFocus: 'Geen kaarten om te focussen',
62 | tooltip: 'Focus kaarten'
63 | },
64 | print: {
65 | tooltip: 'Afdrukken'
66 | },
67 | language: {
68 | tooltip : 'Taal wijzigen'
69 | },
70 | delete: {
71 | title: 'Verwijderen bevestigen',
72 | text: 'Gegevens kunnen niet worden hersteld nadat ze zijn verwijderd. Weet je zeker dat je wilt doorgaan?',
73 | tooltip: 'Dit bord verwijderen',
74 | continueDelete: 'Ja',
75 | cancelDelete: 'Nee'
76 | },
77 | columns: {
78 | col01: 'Wat ging goed',
79 | col02: 'Uitdagingen',
80 | col03: 'Actiepunten',
81 | col04: 'Waardering',
82 | col05: 'Verbeterpunten',
83 | cannotDisable: "Kolommen met kaarten kunnen niet worden uitgeschakeld",
84 | update: "Bijwerken",
85 | discardNewMessages: 'Je concept is verwijderd omdat de kolom is uitgeschakeld.'
86 | },
87 | printFooter: 'Gemaakt met',
88 | offline: 'Offline.',
89 | notExists: 'Het bord is automatisch verwijderd of handmatig door de maker verwijderd.',
90 | autoDeleteScheduleBase: 'Dit bord wordt automatisch opgeschoond op {date}',
91 | autoDeleteScheduleAddon: ', dus je hoeft je geen zorgen te maken om het handmatig te verwijderen.'
92 | }
93 | }
--------------------------------------------------------------------------------
/compose.reverseproxy.yml:
--------------------------------------------------------------------------------
1 | # With reverse-proxy. Access only with https://localhost.
2 |
3 | # Cold start
4 | # docker compose -f compose.reverseproxy.yml up
5 | # To force a rebuild without cache:
6 | # docker compose -f compose.reverseproxy.yml build --no-cache
7 | # docker compose -f compose.reverseproxy.yml up
8 |
9 | # Restart services.
10 | # Containers are NOT deleted, just STOPPED and STARTED. No data is lost.
11 | # Use case: ENV variable update.
12 | # docker compose -f compose.reverseproxy.yml stop
13 | # docker compose -f compose.reverseproxy.yml start
14 |
15 | # Deletes containers and networks, then create them back from already built/pulled images.
16 | # Volumes and images are retained, so data in volumes is not lost.
17 | # Use case: if the previous "Restart services" steps are inadequate.
18 | # docker compose -f compose.reverseproxy.yml down
19 | # docker compose -f compose.reverseproxy.yml up
20 |
21 | # Hard reset: removes containers, networks, volumes, and images.
22 | # ALL data stored in volumes will be lost (including Redis data).
23 | # Use case: code changes, new images, or if previous steps are inadequate.
24 | # docker compose -f compose.reverseproxy.yml down --rmi "all" --volumes
25 | # docker compose -f compose.reverseproxy.yml up
26 |
27 | services:
28 | redis:
29 | image: "redis:8.0.1-alpine"
30 | ############## Redis ACL ##############
31 | # volumes:
32 | # - ./redis/users.acl:/usr/local/etc/redis/users.acl
33 | # command: redis-server --aclfile /usr/local/etc/redis/users.acl
34 | # # command: ["redis-server", "--aclfile", "/usr/local/etc/redis/users.acl"]
35 | ############## Redis ACL ##############
36 | restart: always
37 | networks:
38 | - redisnet
39 | # ports:
40 | # - "6379:6379"
41 | expose:
42 | - 6379
43 | volumes:
44 | - redis_data:/data
45 |
46 | app:
47 | build:
48 | context: .
49 | dockerfile: build.Dockerfile
50 | restart: unless-stopped
51 | depends_on:
52 | - redis
53 | environment:
54 | # Load from .env file in same directory as the compose file.
55 | # To create file, in CLI: echo "REDIS_CONNSTR=redis://redis:6379/0" > .env
56 | # DO NOT create file from Windows. It creates Unicode text, UTF-16, little-endian text, with CRLF line terminators.
57 | - REDIS_CONNSTR=${REDIS_CONNSTR}
58 | # Default Redis (No Auth or ACL). Outside docker - redis://localhost:6379/0
59 | # - REDIS_CONNSTR=redis://redis:6379/0
60 | # Using Redis ACL with Username & Password. Outside docker - redis://app-user:mysecretpassword@localhost:6379/0
61 | # - REDIS_CONNSTR=redis://app-user:mysecretpassword@redis:6379/0
62 | - TURNSTILE_ENABLED=${TURNSTILE_ENABLED}
63 | - TURNSTILE_SITE_KEY=${TURNSTILE_SITE_KEY}
64 | - TURNSTILE_SECRET_KEY=${TURNSTILE_SECRET_KEY}
65 | networks:
66 | - redisnet
67 | - proxynet
68 | # ports:
69 | # - "8080:8080"
70 | expose:
71 | - 8080
72 |
73 | caddy:
74 | image: caddy:2.10.0-alpine
75 | restart: unless-stopped
76 | ports:
77 | - "80:80"
78 | - "443:443"
79 | - "443:443/udp"
80 | depends_on:
81 | - app
82 | networks:
83 | - proxynet
84 | volumes:
85 | - ./Caddyfile:/etc/caddy/Caddyfile
86 | - ./homepage:/var/www/homepage
87 | - ./site:/srv
88 | - caddy_data:/data
89 | - caddy_config:/config
90 |
91 | volumes:
92 | redis_data:
93 | caddy_data:
94 | caddy_config:
95 |
96 | networks:
97 | redisnet:
98 | name: redisnet
99 | proxynet:
100 | name: proxynet
101 | # external: true
--------------------------------------------------------------------------------
/src/frontend/src/i18n/pt.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | langName: 'Português',
3 | common: {
4 | anonymous: 'Anônimo',
5 | minutes: 'Minutos',
6 | seconds: 'Segundos',
7 | start: 'Iniciar',
8 | stop: 'Parar',
9 | copy: 'Copiar',
10 | board: 'Quadro',
11 | toolTips: {
12 | darkTheme: 'Ativar tema escuro',
13 | lightTheme: 'Ativar tema claro'
14 | },
15 | contentOverloadError: 'Conteúdo excede o limite.',
16 | contentStrippingError: 'Texto extra removido do final.',
17 | invalidColumnSelection: 'Selecione coluna(s)'
18 | },
19 | join: {
20 | label: 'Entrar como convidado',
21 | namePlaceholder: 'Digite seu nome aqui!',
22 | nameRequired: 'Digite seu nome',
23 | button: 'Entrar'
24 | },
25 | createBoard: {
26 | label: 'Criar quadro',
27 | namePlaceholder: 'Nome do quadro aqui!',
28 | nameRequired: 'Digite o nome do quadro',
29 | teamNamePlaceholder: 'Nome do time aqui!',
30 | button: 'Criar',
31 | buttonProgress: 'Criando..',
32 | captchaInfo: 'Complete o CAPTCHA para continuar',
33 | boardCreationError: 'Erro ao criar o quadro'
34 | },
35 | dashboard: {
36 | timer: {
37 | oneMinuteLeft: 'Último minuto',
38 | timeCompleted: 'Tempo esgotado!',
39 | title: 'Iniciar/Parar timer',
40 | helpTip: 'Ajuste minutos/segundos com + - ou setas. Máx 1 hora.',
41 | invalid: 'Valores inválidos. Permitido: 1 segundo a 60 minutos.',
42 | tooltip: 'Temporizador'
43 | },
44 | share: {
45 | title: 'Compartilhe esta URL',
46 | linkCopied: 'Link copiado!',
47 | linkCopyError: 'Falha ao copiar. Copie manualmente.',
48 | toolTip: 'Compartilhar quadro'
49 | },
50 | mask: {
51 | maskTooltip: 'Ocultar mensagens',
52 | unmaskTooltip: 'Mostrar mensagens'
53 | },
54 | lock: {
55 | lockTooltip: 'Bloquear quadro',
56 | unlockTooltip: 'Desbloquear quadro',
57 | message: 'Quadro bloqueado.',
58 | discardChanges: 'Quadro bloqueado! Mensagens não guardadas foram descartadas'
59 | },
60 | spotlight: {
61 | noCardsToFocus: 'Nenhum cartão para focar',
62 | tooltip: 'Focar cartões'
63 | },
64 | print: {
65 | tooltip: 'Imprimir'
66 | },
67 | language: {
68 | tooltip : 'Mudar idioma'
69 | },
70 | delete: {
71 | title: 'Confirmar eliminação',
72 | text: 'Os dados não podem ser recuperados após serem eliminados. Tem a certeza de que deseja continuar?',
73 | tooltip: 'Eliminar este quadro',
74 | continueDelete: 'Sim',
75 | cancelDelete: 'Não'
76 | },
77 | columns: {
78 | col01: 'O que deu certo',
79 | col02: 'Desafios',
80 | col03: 'Ações',
81 | col04: 'Agradecimentos',
82 | col05: 'Melhorias',
83 | cannotDisable: "Não é possível desativar coluna(s) que têm cartões",
84 | update: "Atualizar",
85 | discardNewMessages: 'O seu rascunho foi descartado porque a coluna foi desativada.'
86 | },
87 | printFooter: 'Criado com',
88 | offline: 'Offline.',
89 | notExists: 'O quadro foi eliminado automaticamente ou então manualmente pelo seu criador.',
90 | autoDeleteScheduleBase: 'Este quadro será automaticamente limpo em {date}',
91 | autoDeleteScheduleAddon: ', por isso não precisa de se preocupar em eliminá-lo manualmente.'
92 | }
93 | }
--------------------------------------------------------------------------------
/src/frontend/src/i18n/it.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | langName: 'Italiano',
3 | common: {
4 | anonymous: 'Anonimo',
5 | minutes: 'Minuti',
6 | seconds: 'Secondi',
7 | start: 'Avvia',
8 | stop: 'Ferma',
9 | copy: 'Copia',
10 | board: 'Bacheca',
11 | toolTips: {
12 | darkTheme: 'Attiva tema scuro',
13 | lightTheme: 'Attiva tema chiaro'
14 | },
15 | contentOverloadError: 'Contenuto oltre il limite consentito.',
16 | contentStrippingError: 'Testo in eccesso rimosso.',
17 | invalidColumnSelection: 'Seleziona colonna(e)'
18 | },
19 | join: {
20 | label: 'Partecipa come ospite',
21 | namePlaceholder: 'Inserisci il tuo nome qui!',
22 | nameRequired: 'Inserisci il tuo nome',
23 | button: 'Unisciti'
24 | },
25 | createBoard: {
26 | label: 'Crea bacheca',
27 | namePlaceholder: 'Nome della bacheca qui!',
28 | nameRequired: 'Inserisci il nome della bacheca',
29 | teamNamePlaceholder: 'Nome del team qui!',
30 | button: 'Crea',
31 | buttonProgress: 'Creazione..',
32 | captchaInfo: 'Completa il CAPTCHA per continuare',
33 | boardCreationError: 'Errore durante la creazione della bacheca'
34 | },
35 | dashboard: {
36 | timer: {
37 | oneMinuteLeft: 'Un minuto rimasto',
38 | timeCompleted: 'Tempo scaduto!',
39 | title: 'Avvia/Ferma timer',
40 | helpTip: 'Regola minuti/secondi con + - o frecce. Massimo 1 ora.',
41 | invalid: 'Valori non validi (1 secondo - 60 minuti)',
42 | tooltip: 'Timer conto alla rovescia'
43 | },
44 | share: {
45 | title: 'Copia e condividi il link',
46 | linkCopied: 'Link copiato!',
47 | linkCopyError: 'Copia fallita. Copia manualmente.',
48 | toolTip: 'Condividi bacheca'
49 | },
50 | mask: {
51 | maskTooltip: 'Nascondi messaggi',
52 | unmaskTooltip: 'Mostra messaggi'
53 | },
54 | lock: {
55 | lockTooltip: 'Blocca bacheca',
56 | unlockTooltip: 'Sblocca bacheca',
57 | message: 'Bacheca bloccata.',
58 | discardChanges: 'Bacheca bloccata! Messaggi non salvati eliminati'
59 | },
60 | spotlight: {
61 | noCardsToFocus: 'Nessuna carta da focalizzare',
62 | tooltip: 'Evidenzia carte'
63 | },
64 | print: {
65 | tooltip: 'Stampa'
66 | },
67 | language: {
68 | tooltip : 'Cambia lingua'
69 | },
70 | delete: {
71 | title: 'Conferma eliminazione',
72 | text: 'I dati non possono essere recuperati dopo l’eliminazione. Sei sicuro di voler procedere?',
73 | tooltip: 'Elimina questa bacheca',
74 | continueDelete: 'Sì',
75 | cancelDelete: 'No'
76 | },
77 | columns: {
78 | col01: 'Cosa ha funzionato',
79 | col02: 'Sfide',
80 | col03: 'Azioni',
81 | col04: 'Apprezzamenti',
82 | col05: 'Miglioramenti',
83 | cannotDisable: "Impossibile disattivare le colonne che contengono schede",
84 | update: "Aggiorna",
85 | discardNewMessages: 'La tua bozza è stata eliminata perché la colonna è stata disabilitata.'
86 | },
87 | printFooter: 'Creato con',
88 | offline: 'Disconnesso.',
89 | notExists: 'La bacheca è stata eliminata automaticamente o manualmente dal suo creatore.',
90 | autoDeleteScheduleBase: 'Questa board verrà pulita automaticamente il {date}',
91 | autoDeleteScheduleAddon: ', quindi non devi preoccuparti di eliminarla manualmente.'
92 | }
93 | }
--------------------------------------------------------------------------------
/src/frontend/src/i18n/uk.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | langName: 'Українська (uk)',
3 | common: {
4 | anonymous: 'Анонім',
5 | minutes: 'Хвилини',
6 | seconds: 'Секунди',
7 | start: 'Старт',
8 | stop: 'Стоп',
9 | copy: 'Копіювати',
10 | board: 'Дошка',
11 | toolTips: {
12 | darkTheme: 'Увімкнути темну тему',
13 | lightTheme: 'Увімкнути світлу тему'
14 | },
15 | contentOverloadError: 'Перевищено допустимий обсяг контенту.',
16 | contentStrippingError: 'Текст було скорочено через перевищення ліміту.',
17 | invalidColumnSelection: 'Оберіть колонку(и)'
18 | },
19 | join: {
20 | label: 'Приєднатися як гість',
21 | namePlaceholder: 'Введіть ваше імʼя тут!',
22 | nameRequired: 'Будь ласка, введіть імʼя',
23 | button: 'Приєднатися'
24 | },
25 | createBoard: {
26 | label: 'Створити дошку',
27 | namePlaceholder: 'Введіть назву дошки тут!',
28 | nameRequired: 'Будь ласка, введіть назву дошки',
29 | teamNamePlaceholder: 'Введіть назву команди тут!',
30 | button: 'Створити',
31 | buttonProgress: 'Створення..',
32 | captchaInfo: 'Будь ласка, пройдіть CAPTCHA',
33 | boardCreationError: 'Помилка при створенні дошки'
34 | },
35 | dashboard: {
36 | timer: {
37 | oneMinuteLeft: 'Залишилася 1 хвилина',
38 | timeCompleted: 'Час вийшов!',
39 | title: 'Старт/Стоп таймер',
40 | helpTip: 'Користуйтеся + - або стрілками. Максимум 1 година.',
41 | invalid: 'Невірні значення (1 секунда - 60 хвилин)',
42 | tooltip: 'Таймер зворотного відліку'
43 | },
44 | share: {
45 | title: 'Скопіюйте та поділіться посиланням',
46 | linkCopied: 'Посилання скопійовано!',
47 | linkCopyError: 'Помилка копіювання. Скопіюйте вручну.',
48 | toolTip: 'Поділитися дошкою'
49 | },
50 | mask: {
51 | maskTooltip: 'Приховати повідомлення',
52 | unmaskTooltip: 'Показати повідомлення'
53 | },
54 | lock: {
55 | lockTooltip: 'Заблокувати дошку',
56 | unlockTooltip: 'Розблокувати дошку',
57 | message: 'Дошка заблокована власником.',
58 | discardChanges: 'Дошку заблоковано! Незбережені повідомлення видалено'
59 | },
60 | spotlight: {
61 | noCardsToFocus: 'Немає карток для фокусування',
62 | tooltip: 'Фокусувати картки'
63 | },
64 | print: {
65 | tooltip: 'Друк'
66 | },
67 | language: {
68 | tooltip : 'Змінити мову'
69 | },
70 | delete: {
71 | title: 'Підтвердження видалення',
72 | text: 'Після видалення дані неможливо відновити. Ви впевнені, що хочете продовжити?',
73 | tooltip: 'Видалити цю дошку',
74 | continueDelete: 'Так',
75 | cancelDelete: 'Ні'
76 | },
77 | columns: {
78 | col01: 'Що вдалося',
79 | col02: 'Складності',
80 | col03: 'Завдання',
81 | col04: 'Подяки',
82 | col05: 'Покращення',
83 | cannotDisable: "Неможливо вимкнути стовпчик, у якому є картки",
84 | update: "Оновити",
85 | discardNewMessages: 'Ваш чернетку було видалено, оскільки стовпець було вимкнено.'
86 | },
87 | printFooter: 'Створено за допомогою',
88 | offline: 'Відсутнє інтернет-зʼєднання.',
89 | notExists: 'Дошку було видалено автоматично або вручну її творцем.',
90 | autoDeleteScheduleBase: 'Цю дошку буде автоматично очищено {date}',
91 | autoDeleteScheduleAddon: ', тож вам не потрібно турбуватися про її ручне видалення.'
92 | }
93 | }
--------------------------------------------------------------------------------
/src/frontend/src/i18n/en.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | langName: 'English',
3 | common: {
4 | anonymous: 'Anonymous',
5 | minutes: 'Minutes',
6 | seconds: 'Seconds',
7 | start: 'Start',
8 | stop: 'Stop',
9 | copy: 'Copy',
10 | board: 'Board',
11 | toolTips: {
12 | darkTheme: 'Turn on dark theme',
13 | lightTheme: 'Turn on light theme'
14 | },
15 | contentOverloadError: 'Content more than allowed limit.',
16 | contentStrippingError: 'Content more than allowed limit. Extra text is stripped from the end.',
17 | invalidColumnSelection: 'Please select column(s)'
18 | },
19 | join: {
20 | label: 'Join as guest',
21 | namePlaceholder: 'Type your name here!',
22 | nameRequired: 'Please enter your name',
23 | button: 'Join'
24 | },
25 | createBoard: {
26 | label: 'Create Board',
27 | namePlaceholder: 'Type board name here!',
28 | nameRequired: 'Please enter board name',
29 | teamNamePlaceholder: 'Type team name here!',
30 | button: 'Create',
31 | buttonProgress: 'Creating..',
32 | captchaInfo: 'Please complete the CAPTCHA to continue',
33 | boardCreationError: 'Error when creating board'
34 | },
35 | dashboard: {
36 | timer: {
37 | oneMinuteLeft: 'One minute left for countdown',
38 | timeCompleted: "Hey! You've run out of time",
39 | title: 'Start/Stop Timer',
40 | helpTip: 'Adjust minutes and seconds using the + and - controls, or the Up and Down arrows on keyboard. Max allowed is 1 hour.',
41 | invalid: 'Please enter valid minutes/seconds values. Allowed range is 1 second to 60 minutes.',
42 | tooltip: 'Countdown Timer'
43 | },
44 | share: {
45 | title: 'Copy and share below url to participants',
46 | linkCopied: 'Link copied!',
47 | linkCopyError: 'Failed to copy. Please copy directly.',
48 | toolTip: 'Share board with others'
49 | },
50 | mask: {
51 | maskTooltip: 'Mask messages',
52 | unmaskTooltip: 'Unmask messages'
53 | },
54 | lock: {
55 | lockTooltip: 'Lock board',
56 | unlockTooltip: 'Unlock board',
57 | message: 'Cannot add or update. Board is locked by owner.',
58 | discardChanges: 'Board locked! Unsaved messages discarded'
59 | },
60 | spotlight: {
61 | noCardsToFocus: 'There are no cards to focus',
62 | tooltip: 'Focus cards'
63 | },
64 | print: {
65 | tooltip: 'Print'
66 | },
67 | language: {
68 | tooltip: 'Change language'
69 | },
70 | delete: {
71 | title: 'Confirm deletion',
72 | text: 'Data cannot be recovered after its deleted. Are you sure to proceed?',
73 | tooltip: 'Delete this Board',
74 | continueDelete: 'Yes',
75 | cancelDelete: 'No'
76 | },
77 | columns: {
78 | col01: 'What went well',
79 | col02: 'Challenges',
80 | col03: 'Action Items',
81 | col04: 'Appreciations',
82 | col05: 'Improvements',
83 | cannotDisable: 'Cannot disable column(s) with cards',
84 | update: 'Update',
85 | discardNewMessages: 'Your draft was discarded because the column was disabled.'
86 | },
87 | printFooter: 'Created with',
88 | offline: 'You seem to be offline.',
89 | notExists: 'Board was either auto-deleted, or manually deleted by its creator.',
90 | autoDeleteScheduleBase: 'This board will be cleaned up automatically on {date}',
91 | autoDeleteScheduleAddon: ', so you do not need to worry about deleting it manually.'
92 | }
93 | }
--------------------------------------------------------------------------------
/src/frontend/src/i18n/pt-BR.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | langName: 'Português (Brasil)',
3 | common: {
4 | anonymous: 'Anônimo',
5 | minutes: 'Minutos',
6 | seconds: 'Segundos',
7 | start: 'Iniciar',
8 | stop: 'Parar',
9 | copy: 'Copiar',
10 | board: 'Quadro',
11 | toolTips: {
12 | darkTheme: 'Ativar tema escuro',
13 | lightTheme: 'Ativar tema claro'
14 | },
15 | contentOverloadError: 'Conteúdo excede o limite permitido.',
16 | contentStrippingError: 'Texto adicional foi removido do final.',
17 | invalidColumnSelection: 'Selecione pelo menos uma coluna'
18 | },
19 | join: {
20 | label: 'Entrar como visitante',
21 | namePlaceholder: 'Digite seu nome aqui!',
22 | nameRequired: 'Por favor, digite seu nome',
23 | button: 'Entrar'
24 | },
25 | createBoard: {
26 | label: 'Criar quadro',
27 | namePlaceholder: 'Digite o nome do quadro aqui!',
28 | nameRequired: 'Por favor, digite o nome do quadro',
29 | teamNamePlaceholder: 'Digite o nome do time aqui!',
30 | button: 'Criar',
31 | buttonProgress: 'Criando..',
32 | captchaInfo: 'Complete o CAPTCHA para prosseguir',
33 | boardCreationError: 'Erro ao criar o quadro'
34 | },
35 | dashboard: {
36 | timer: {
37 | oneMinuteLeft: 'Último minuto restante',
38 | timeCompleted: 'Tempo esgotado!',
39 | title: 'Iniciar/Parar temporizador',
40 | helpTip: 'Ajuste minutos/segundos com os botões + - ou teclas direcionais. Máximo de 1 hora.',
41 | invalid: 'Valores inválidos. Intervalo permitido: 1 segundo a 60 minutos.',
42 | tooltip: 'Temporizador regressivo'
43 | },
44 | share: {
45 | title: 'Copie e compartilhe o link abaixo',
46 | linkCopied: 'Link copiado!',
47 | linkCopyError: 'Falha ao copiar. Copie manualmente.',
48 | toolTip: 'Compartilhar quadro'
49 | },
50 | mask: {
51 | maskTooltip: 'Ocultar mensagens',
52 | unmaskTooltip: 'Exibir mensagens'
53 | },
54 | lock: {
55 | lockTooltip: 'Bloquear quadro',
56 | unlockTooltip: 'Desbloquear quadro',
57 | message: 'Quadro bloqueado pelo dono.',
58 | discardChanges: 'Quadro bloqueado! Mensagens não salvas descartadas'
59 | },
60 | spotlight: {
61 | noCardsToFocus: 'Nenhum card para destacar',
62 | tooltip: 'Destacar cards'
63 | },
64 | print: {
65 | tooltip: 'Imprimir'
66 | },
67 | language: {
68 | tooltip : 'Mudar idioma'
69 | },
70 | delete: {
71 | title: 'Confirmar exclusão',
72 | text: 'Os dados não podem ser recuperados após serem excluídos. Tem certeza de que deseja continuar?',
73 | tooltip: 'Excluir este quadro',
74 | continueDelete: 'Sim',
75 | cancelDelete: 'Não'
76 | },
77 | columns: {
78 | col01: 'O que funcionou bem',
79 | col02: 'Desafios',
80 | col03: 'Ações',
81 | col04: 'Agradecimentos',
82 | col05: 'Melhorias',
83 | cannotDisable: "Não é possível desativar coluna(s) que possuem cartões",
84 | update: "Atualizar",
85 | discardNewMessages: 'Seu rascunho foi descartado porque a coluna foi desativada.'
86 | },
87 | printFooter: 'Criado com',
88 | offline: 'Você parece estar offline.',
89 | notExists: 'O quadro foi excluído automaticamente ou manualmente por seu criador.',
90 | autoDeleteScheduleBase: 'Este quadro será automaticamente limpo em {date}',
91 | autoDeleteScheduleAddon: ', então você não precisa se preocupar em excluí-lo manualmente.'
92 | }
93 | }
--------------------------------------------------------------------------------
/src/frontend/src/i18n/de.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | langName: 'Deutsch',
3 | common: {
4 | anonymous: 'Anonym',
5 | minutes: 'Minuten',
6 | seconds: 'Sekunden',
7 | start: 'Starten',
8 | stop: 'Stoppen',
9 | copy: 'Kopieren',
10 | board: 'Board',
11 | toolTips: {
12 | darkTheme: 'Dunkles Design aktivieren',
13 | lightTheme: 'Helles Design aktivieren'
14 | },
15 | contentOverloadError: 'Inhalt überschreitet Limit.',
16 | contentStrippingError: 'Inhalt zu lang. Überschüssiger Text wurde entfernt.',
17 | invalidColumnSelection: 'Bitte Spalte(n) auswählen'
18 | },
19 | join: {
20 | label: 'Als Gast beitreten',
21 | namePlaceholder: 'Namen hier eingeben!',
22 | nameRequired: 'Bitte Namen eingeben',
23 | button: 'Beitreten'
24 | },
25 | createBoard: {
26 | label: 'Board erstellen',
27 | namePlaceholder: 'Boardnamen hier eingeben!',
28 | nameRequired: 'Bitte Boardnamen eingeben',
29 | teamNamePlaceholder: 'Teamnamen hier eingeben!',
30 | button: 'Erstellen',
31 | buttonProgress: 'Wird erstellt..',
32 | captchaInfo: 'Bitte lösen Sie das CAPTCHA, um fortzufahren',
33 | boardCreationError: 'Fehler beim Erstellen des Boards'
34 | },
35 | dashboard: {
36 | timer: {
37 | oneMinuteLeft: 'Noch eine Minute',
38 | timeCompleted: 'Zeit abgelaufen!',
39 | title: 'Timer Starten/Stoppen',
40 | helpTip: 'Minuten/Sekunden mit +/- oder Pfeiltasten einstellen. Maximal 1 Stunde.',
41 | invalid: 'Ungültige Werte. Erlaubt: 1 Sekunde bis 60 Minuten.',
42 | tooltip: 'Countdown-Timer'
43 | },
44 | share: {
45 | title: 'URL an Teilnehmer kopieren',
46 | linkCopied: 'Link kopiert!',
47 | linkCopyError: 'Kopieren fehlgeschlagen. Bitte manuell kopieren.',
48 | toolTip: 'Board teilen'
49 | },
50 | mask: {
51 | maskTooltip: 'Nachrichten verdecken',
52 | unmaskTooltip: 'Nachrichten zeigen'
53 | },
54 | lock: {
55 | lockTooltip: 'Board sperren',
56 | unlockTooltip: 'Board entsperren',
57 | message: 'Board ist gesperrt.',
58 | discardChanges: 'Board gesperrt! Ungespeicherte Nachrichten verworfen'
59 | },
60 | spotlight: {
61 | noCardsToFocus: 'Keine Karten vorhanden',
62 | tooltip: 'Karten fokussieren'
63 | },
64 | print: {
65 | tooltip: 'Drucken'
66 | },
67 | language: {
68 | tooltip : 'Sprache ändern'
69 | },
70 | delete: {
71 | title: 'Löschen bestätigen',
72 | text: 'Daten können nach dem Löschen nicht wiederhergestellt werden. Sind Sie sicher, dass Sie fortfahren möchten?',
73 | tooltip: 'Dieses Board löschen',
74 | continueDelete: 'Ja',
75 | cancelDelete: 'Nein'
76 | },
77 | columns: {
78 | col01: 'Was gut lief',
79 | col02: 'Herausforderungen',
80 | col03: 'Aktionspunkte',
81 | col04: 'Dankbarkeiten',
82 | col05: 'Verbesserungen',
83 | cannotDisable: "Spalte(n) mit Karten können nicht deaktiviert werden",
84 | update: "Aktualisieren",
85 | discardNewMessages: 'Dein Entwurf wurde verworfen, weil die Spalte deaktiviert wurde.'
86 | },
87 | printFooter: 'Erstellt mit',
88 | offline: 'Offline.',
89 | notExists: 'Das Board wurde entweder automatisch gelöscht oder manuell von seinem Ersteller entfernt.',
90 | autoDeleteScheduleBase: 'Dieses Board wird am {date} automatisch bereinigt',
91 | autoDeleteScheduleAddon: ', sodass du dir keine Sorgen machen musst, es manuell zu löschen.'
92 | }
93 | }
--------------------------------------------------------------------------------
/src/frontend/src/i18n/fr.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | langName: 'Français',
3 | common: {
4 | anonymous: 'Anonyme',
5 | minutes: 'Minutes',
6 | seconds: 'Secondes',
7 | start: 'Démarrer',
8 | stop: 'Arrêter',
9 | copy: 'Copier',
10 | board: 'Tableau',
11 | toolTips: {
12 | darkTheme: 'Activer le mode sombre',
13 | lightTheme: 'Activer le mode clair'
14 | },
15 | contentOverloadError: 'Contenu dépasse la limite.',
16 | contentStrippingError: 'Contenu trop long. Texte excédentaire supprimé.',
17 | invalidColumnSelection: 'Veuillez sélectionner des colonnes'
18 | },
19 | join: {
20 | label: 'Rejoindre en invité',
21 | namePlaceholder: 'Saisissez votre nom ici !',
22 | nameRequired: 'Veuillez saisir votre nom',
23 | button: 'Rejoindre'
24 | },
25 | createBoard: {
26 | label: 'Créer un tableau',
27 | namePlaceholder: 'Nom du tableau ici !',
28 | nameRequired: 'Veuillez saisir le nom du tableau',
29 | teamNamePlaceholder: 'Nom de l\'équipe ici !',
30 | button: 'Créer',
31 | buttonProgress: 'Création en cours..',
32 | captchaInfo: 'Veuillez compléter le CAPTCHA pour continuer',
33 | boardCreationError: 'Erreur lors de la création du tableau'
34 | },
35 | dashboard: {
36 | timer: {
37 | oneMinuteLeft: 'Une minute restante',
38 | timeCompleted: 'Temps écoulé !',
39 | title: 'Démarrer/Arrêter le chrono',
40 | helpTip: 'Ajustez les minutes/secondes avec +/- ou flèches. Maximum 1 heure.',
41 | invalid: 'Valeurs invalides. Plage autorisée : 1 seconde à 60 minutes.',
42 | tooltip: 'Minuteur'
43 | },
44 | share: {
45 | title: 'Copier et partager l\'URL',
46 | linkCopied: 'Lien copié !',
47 | linkCopyError: 'Échec de copie. Copiez manuellement.',
48 | toolTip: 'Partager le tableau'
49 | },
50 | mask: {
51 | maskTooltip: 'Masquer messages',
52 | unmaskTooltip: 'Afficher messages'
53 | },
54 | lock: {
55 | lockTooltip: 'Verrouiller tableau',
56 | unlockTooltip: 'Déverrouiller tableau',
57 | message: 'Tableau verrouillé.',
58 | discardChanges: 'Tableau verrouillé ! Messages non enregistrés supprimés'
59 | },
60 | spotlight: {
61 | noCardsToFocus: 'Aucune carte à focaliser',
62 | tooltip: 'Mettre en avant'
63 | },
64 | print: {
65 | tooltip: 'Imprimer'
66 | },
67 | language: {
68 | tooltip : 'Changer de langue'
69 | },
70 | delete: {
71 | title: 'Confirmer la suppression',
72 | text: 'Les données ne peuvent pas être récupérées après leur suppression. Êtes-vous sûr de vouloir continuer?',
73 | tooltip: 'Supprimer ce tableau',
74 | continueDelete: 'Oui',
75 | cancelDelete: 'Non'
76 | },
77 | columns: {
78 | col01: 'Ce qui a bien fonctionné',
79 | col02: 'Défis',
80 | col03: 'Actions',
81 | col04: 'Reconnaissance',
82 | col05: 'Améliorations',
83 | cannotDisable: "Impossible de désactiver la colonne car elle contient des cartes",
84 | update: "Mettre à jour",
85 | discardNewMessages: 'Votre brouillon a été supprimé car la colonne a été désactivée.'
86 | },
87 | printFooter: 'Créé avec',
88 | offline: 'Hors ligne.',
89 | notExists: 'Le tableau a été soit supprimé automatiquement, soit manuellement par son créateur.',
90 | autoDeleteScheduleBase: 'Ce tableau sera automatiquement nettoyé le {date}',
91 | autoDeleteScheduleAddon: ', vous n’avez donc pas à vous soucier de le supprimer manuellement.'
92 | }
93 | }
--------------------------------------------------------------------------------
/src/frontend/src/i18n/fr-CA.ts:
--------------------------------------------------------------------------------
1 | export default {
2 | langName: 'Français (Canada)',
3 | common: {
4 | anonymous: 'Anonyme',
5 | minutes: 'Minutes',
6 | seconds: 'Secondes',
7 | start: 'Démarrer',
8 | stop: 'Arrêter',
9 | copy: 'Copier',
10 | board: 'Tableau',
11 | toolTips: {
12 | darkTheme: 'Activer le mode sombre',
13 | lightTheme: 'Activer le mode clair'
14 | },
15 | contentOverloadError: 'Contenu dépasse la limite permise.',
16 | contentStrippingError: 'Texte excédentaire supprimé.',
17 | invalidColumnSelection: 'Veuillez sélectionner des colonnes'
18 | },
19 | join: {
20 | label: 'Joindre comme invité',
21 | namePlaceholder: 'Entrez votre nom ici !',
22 | nameRequired: 'Veuillez entrer votre nom',
23 | button: 'Joindre'
24 | },
25 | createBoard: {
26 | label: 'Créer un tableau',
27 | namePlaceholder: 'Nom du tableau ici !',
28 | nameRequired: 'Veuillez entrer le nom du tableau',
29 | teamNamePlaceholder: 'Nom de l\'équipe ici !',
30 | button: 'Créer',
31 | buttonProgress: 'Création en cours..',
32 | captchaInfo: 'Veuillez compléter le CAPTCHA',
33 | boardCreationError: 'Erreur lors de la création du tableau'
34 | },
35 | dashboard: {
36 | timer: {
37 | oneMinuteLeft: 'Une minute restante',
38 | timeCompleted: 'Le temps est écoulé !',
39 | title: 'Démarrer/Arrêter la minuterie',
40 | helpTip: 'Ajustez avec les boutons + - ou flèches. Maximum 1 heure.',
41 | invalid: 'Valeurs invalides (1 seconde à 60 minutes)',
42 | tooltip: 'Minuterie décompte'
43 | },
44 | share: {
45 | title: 'Copier et partager le lien',
46 | linkCopied: 'Lien copié !',
47 | linkCopyError: 'Échec de copie. Copiez manuellement.',
48 | toolTip: 'Partager le tableau'
49 | },
50 | mask: {
51 | maskTooltip: 'Masquer les messages',
52 | unmaskTooltip: 'Afficher les messages'
53 | },
54 | lock: {
55 | lockTooltip: 'Verrouiller le tableau',
56 | unlockTooltip: 'Déverrouiller le tableau',
57 | message: 'Tableau verrouillé par le propriétaire.',
58 | discardChanges: 'Tableau verrouillé ! Messages non sauvegardés supprimés'
59 | },
60 | spotlight: {
61 | noCardsToFocus: 'Aucune carte à mettre en évidence',
62 | tooltip: 'Mettre en évidence'
63 | },
64 | print: {
65 | tooltip: 'Imprimer'
66 | },
67 | language: {
68 | tooltip : 'Changer de langue'
69 | },
70 | delete: {
71 | title: 'Confirmer la suppression',
72 | text: 'Les données ne peuvent pas être récupérées après la suppression. Voulez-vous vraiment continuer?',
73 | tooltip: 'Supprimer ce tableau',
74 | continueDelete: 'Oui',
75 | cancelDelete: 'Non'
76 | },
77 | columns: {
78 | col01: 'Ce qui a bien fonctionné',
79 | col02: 'Défis',
80 | col03: 'Actions',
81 | col04: 'Reconnaissance',
82 | col05: 'Améliorations',
83 | cannotDisable: "Impossible de désactiver la colonne puisqu’elle contient des cartes",
84 | update: "Mettre à jour",
85 | discardNewMessages: 'Votre brouillon a été supprimé parce que la colonne a été désactivée.'
86 | },
87 | printFooter: 'Créé avec',
88 | offline: 'Hors ligne.',
89 | notExists: 'Le tableau a été supprimé soit automatiquement, soit manuellement par son créateur.',
90 | autoDeleteScheduleBase: 'Ce tableau sera automatiquement nettoyé le {date}',
91 | autoDeleteScheduleAddon: ', donc vous n’avez pas à vous en faire pour le supprimer manuellement.'
92 | }
93 | }
--------------------------------------------------------------------------------
/src/frontend/src/components/Category.vue:
--------------------------------------------------------------------------------
1 |
46 |
47 |
48 |
49 |
50 |
51 |
{{ displayText }}
66 |
72 |
86 |
87 |
88 |
89 |
90 |
--------------------------------------------------------------------------------
/homepage/assets/guide_self-hosting.md.BoyRbhMl.js:
--------------------------------------------------------------------------------
1 | import{_ as s,c as a,o as i,ae as t}from"./chunks/framework.CTVYQtO4.js";const u=JSON.parse('{"title":"Self-Hosting","description":"","frontmatter":{},"headers":[],"relativePath":"guide/self-hosting.md","filePath":"guide/self-hosting.md","lastUpdated":1757066112000}'),o={name:"guide/self-hosting.md"};function r(n,e,l,c,d,p){return i(),a("div",null,e[0]||(e[0]=[t(`
Although the demo app has all the features and can be used as-is, it runs on low resources. The data is auto-deleted within 2 days. It is recommended to self-host the app for better flexibility.
It is recommended to secure your Redis instance, preferably with ACL enabled. Check out the redis directory, and sample docker compose files compose.yml, compose.reverseproxy.yml, compose.demohosting.yml etc in github repository for more details.
Environment variables are passed using .env file which is present in the same directory as compose*.yml files. Example: Create an env file with your values -
To securely pass ENV vars, feel free to use an approach which suits you best.
NOTE
DO NOT create the file directly from Windows CMD if you intend to run the app in Linux. It creates Unicode text, UTF-16, little-endian text, with CRLF line terminators. This causes problems for Docker Compose to read the env file.
On Windows, you can create the file in UTF-8 using Git Terminal.
',4)),a("div",g,[e[6]||(e[6]=a("p",{class:"custom-block-title"},"TIP",-1)),a("p",null,[e[0]||(e[0]=o("Since the introduction of multi-language support with ")),l(r,{type:"tip",text:"v1.3.0"}),e[1]||(e[1]=o(", default column names can be automatically translated to other languages.")),e[2]||(e[2]=a("br",null,null,-1)),e[3]||(e[3]=a("em",null,[a("strong",null,"Custom column names are not automatically translated.")],-1)),e[4]||(e[4]=a("br",null,null,-1)),e[5]||(e[5]=o(" It is recommended to use the defaults, if any of your team members use the app in a different language."))])]),e[12]||(e[12]=a("p",null,[o("A max of 5 columns are allowed. The first 3 columns are always enabled by default."),a("br"),o(" You can choose which columns you want and name them accordingly.")],-1)),e[13]||(e[13]=a("img",{src:m,class:"shadow-img",alt:"Create Board",width:"360",loading:"lazy"},null,-1)),e[14]||(e[14]=a("p",null,[o("Click the coloured dot ("),a("em",null,[a("strong",null,"present towards left of each column name")]),o(") to enable/disable a column."),a("br"),o(" Click the column name text to type any custom name.")],-1)),e[15]||(e[15]=a("h3",{id:"changing-column-order",tabindex:"-1"},[o("Changing column order "),a("a",{class:"header-anchor",href:"#changing-column-order","aria-label":'Permalink to "Changing column order"'},"")],-1)),a("p",null,[e[7]||(e[7]=o("Available from ")),l(r,{type:"tip",text:"v1.5.4"}),e[8]||(e[8]=a("br",null,null,-1)),e[9]||(e[9]=o(" Drag-and-Drop columns vertically to change the column order."))]),e[16]||(e[16]=n('
When a Board is created, the user is taken to the Dashboard.
115 |
--------------------------------------------------------------------------------
/docs/docs/guide/configurations.md:
--------------------------------------------------------------------------------
1 | # Configurations
2 | The application's default behaviour can be altered with configuration settings. This document provides a quick overview about it.
3 |
4 | ## Auto-Delete Duration
5 | By default, data is deleted within 2 days in Redis. This can be updated by making the below changes.\
6 | In the src/config.toml file, update the value for auto_delete_duration
7 |
8 | ```toml{5}
9 | [data]
10 | # Format:
11 | # Units: s=seconds, m=minutes, h=hours, d=days
12 | # Examples: "50s" for 50 seconds, "5m" for 5 minutes, "2h" for 2 hours, "7d" for 7 days
13 | auto_delete_duration = "2d"
14 | ```
15 |
16 | ## Websocket Max Message Size
17 | QuickRetro uses Websockets for communication. This configuration setting controls the max allowed size in bytes for all data sent through the websocket.
18 |
19 | In the src/config.toml file, update the value for max_message_size_bytes
20 | ```toml{4}
21 | [websocket]
22 | # Maximum message size (in bytes) allowed from peer for the websocket connection
23 | # For the front-end validation, keep the same value in (src/frontend/.env [VITE_MAX_WEBSOCKET_MESSAGE_SIZE_BYTES])
24 | max_message_size_bytes = 1024
25 | ```
26 |
27 | This setting is defined separately for the backend and frontend. For the frontend, this is defined in src/frontend/.env.\
28 | Update the value for VITE_MAX_WEBSOCKET_MESSAGE_SIZE_BYTES
29 | ```ini{6}
30 | VITE_WS_PROTOCOL=wss
31 | VITE_SHOW_CONSOLE_LOGS=false
32 | # Triggers message size validation.
33 | # It is recommended to keep the same value as what's allowed in backend server (defined in src/config.toml [websocket].max_message_size_bytes).
34 | # To avoid message size validation, comment out below line. However, this will break the server websocket connection when the limit is breached.
35 | VITE_MAX_WEBSOCKET_MESSAGE_SIZE_BYTES=1024
36 | ```
37 | ::: danger IMPORTANT
38 | Ensure the config values are same for both frontend and backend
39 | :::
40 |
41 | ::: tip
42 | VITE_MAX_WEBSOCKET_MESSAGE_SIZE_BYTES also causes UI validation to run everytime a User type's or paste's text.\
43 | Commenting it out will stop the validation from being run everytime.\
44 | It is not recommended to comment out this config, unless its causing issues for users.
45 | :::
46 |
47 | ## Max Category Text Length
48 | Available from
49 |
50 | You can change the max number of characters allowed for each column name. Default is 80.
51 |
52 | In the src/config.toml file, update the value for max_category_text_length
53 | ```toml{4}
54 | [server]
55 | # Maximum number of characters allowed for each category name
56 | # For the front-end validation, keep the same value in (src/frontend/.env [VITE_MAX_CATEGORY_TEXT_LENGTH])
57 | max_category_text_length = 80
58 | ```
59 |
60 | This setting is defined separately for the backend and frontend. For the frontend, this is defined in src/frontend/.env.\
61 | Update the value for VITE_MAX_CATEGORY_TEXT_LENGTH
62 | ```ini{3}
63 | # Maximum number of characters allowed for each category name
64 | # It is recommended to keep the same value as what's allowed in backend server (defined in src/config.toml [server].max_category_text_length).
65 | VITE_MAX_CATEGORY_TEXT_LENGTH=80
66 | ```
67 | ::: danger IMPORTANT
68 | Ensure the config values are same for both frontend and backend.
69 |
70 | Changing this also impacts the value defined in previous [Websocket Max Message Size](configurations#websocket-max-message-size) section.
71 | Ensure that whatever value is set, **the websocket message/payload size doesn't exceed from what has been defined in previous section.**
72 | :::
73 |
74 | ## Allowed Origins
75 | Update the allowed_origins config setting in src/config.toml to add some degree of protection to the websocket connection.\
76 | You will typically update this setting when [self-hosting](self-hosting).
77 | ```toml{7-14}
78 | [server]
79 | # When self-hosting, add your domain to allowed_origins list.
80 | # For e.g. if you are hosting your site at https://example.com, allowed_origins will look like -
81 | # allowed_origins = [
82 | # "https://example.com"
83 | # ]
84 | allowed_origins = [
85 | "http://localhost:8080",
86 | "https://localhost:8080",
87 | "http://localhost:5173",
88 | "https://localhost",
89 | "https://quickretro.app",
90 | "https://demo.quickretro.app"
91 | ]
92 | ```
93 |
94 | ## Connecting to Redis
95 | The Go app always attempts to connect to Redis when its starts. It errors out if connecting to Redis fails.
96 | The app looks for an ENV variable named REDIS_CONNSTR for the connection details.
97 |
98 | The Redis ACL username and password can be passed as part of the url to REDIS_CONNSTR.
99 |
100 | ## Enable Cloudflare Turnstile
101 | Turnstile is a smart CAPTCHA alternative from Cloudflare used to prevent bots. It is disabled by default for the Create board page.
102 |
103 | To enable it, set the TURNSTILE_ENABLED, TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY environment variables.
104 |
105 | ```ini{2-4}
106 | REDIS_CONNSTR=
107 | TURNSTILE_ENABLED=true
108 | TURNSTILE_SITE_KEY=
109 | TURNSTILE_SECRET_KEY=
110 | ```
111 |
112 | ::: tip
113 | You need to register with Cloudflare to get TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY. Visit [Cloudflare](https://www.cloudflare.com/en-in/application-services/products/turnstile/) for more details.
114 | :::
115 |
--------------------------------------------------------------------------------