├── .dockerignore ├── .env ├── onlybackend.dockerignore ├── src ├── frontend │ ├── src │ │ ├── vite-env.d.ts │ │ ├── index.css │ │ ├── models │ │ │ ├── LikeMessage.ts │ │ │ ├── OnlineUser.ts │ │ │ ├── CategoryChangeMessage.ts │ │ │ ├── DraftMessage.ts │ │ │ ├── CategoryDefinition.ts │ │ │ └── BoardColumn.ts │ │ ├── App.vue │ │ ├── composables │ │ │ ├── useSanitize.ts │ │ │ └── useLanguage.ts │ │ ├── main.ts │ │ ├── assets │ │ │ └── vue.svg │ │ ├── constants │ │ │ └── defaultCategories.ts │ │ ├── components │ │ │ ├── NewAnonymousCard.vue │ │ │ ├── HelloWorld.vue │ │ │ ├── Avatar.vue │ │ │ ├── DarkModeToggle.vue │ │ │ ├── LanguageSelector.vue │ │ │ ├── NewCard.vue │ │ │ ├── NewComment.vue │ │ │ ├── CountdownTimer.vue │ │ │ ├── Category.vue │ │ │ ├── TurnstileWidget.vue │ │ │ ├── Join.vue │ │ │ └── CategoryEditor.vue │ │ ├── types │ │ │ └── turnstile.d.ts │ │ ├── i18n │ │ │ ├── index.ts │ │ │ ├── zh-CN.ts │ │ │ ├── ja.ts │ │ │ ├── ko.ts │ │ │ ├── ru.ts │ │ │ ├── nl.ts │ │ │ ├── pt.ts │ │ │ ├── it.ts │ │ │ ├── uk.ts │ │ │ ├── en.ts │ │ │ ├── pt-BR.ts │ │ │ ├── de.ts │ │ │ ├── fr.ts │ │ │ ├── fr-CA.ts │ │ │ ├── es.ts │ │ │ └── pl.ts │ │ ├── router.ts │ │ ├── api │ │ │ └── index.ts │ │ └── style.css │ ├── .env.development │ ├── .vscode │ │ └── extensions.json │ ├── postcss.config.js │ ├── tsconfig.node.json │ ├── .gitignore │ ├── .eslintrc.js │ ├── vite.config.ts │ ├── tsconfig.json │ ├── package.json │ ├── .env │ ├── tailwind.config.js │ ├── README.md │ └── index.html ├── input.css ├── user.go ├── go.mod ├── helpers_test.go ├── config.toml ├── message.go ├── hub.go ├── go.sum ├── event.go ├── eventresponses.go └── helpers.go ├── homepage ├── logo.png ├── comments.png ├── favicon.ico ├── robots.txt ├── createboard.png ├── apple-touch-icon.png ├── dashboard_guest.png ├── dashboard_move.png ├── dashboard_owner.png ├── logo_large_dark.png ├── logo_large_light.png ├── createboard_turnstile.png ├── dashboard_add_cards.png ├── dashboard_focus_panel.png ├── videos │ ├── create-board.mp4 │ ├── start-stop-timer.mp4 │ └── add-update-message.mp4 ├── dashboard_edit_columns.png ├── assets │ ├── inter-italic-greek.DJ8dCoTZ.woff2 │ ├── inter-italic-latin.C2AdPX0b.woff2 │ ├── inter-roman-greek.BBVDIX6e.woff2 │ ├── inter-roman-latin.Di8DUHzh.woff2 │ ├── inter-roman-cyrillic.C5lxZ8CY.woff2 │ ├── inter-italic-cyrillic.By2_1cv3.woff2 │ ├── inter-italic-greek-ext.1u6EdAuj.woff2 │ ├── inter-italic-latin-ext.CN1xVJS-.woff2 │ ├── inter-italic-vietnamese.BSbpV94h.woff2 │ ├── inter-roman-greek-ext.CqjqNYQ-.woff2 │ ├── inter-roman-latin-ext.4ZJIpNVo.woff2 │ ├── inter-roman-vietnamese.BjW4sHH5.woff2 │ ├── inter-italic-cyrillic-ext.r48I6akx.woff2 │ ├── inter-roman-cyrillic-ext.BBPuwvHQ.woff2 │ ├── guide_self-hosting.md.BoyRbhMl.lean.js │ ├── guide_configurations.md.BLKCYmEX.lean.js │ ├── app.rh1oyAvj.js │ ├── guide_development.md.BCIGqezz.lean.js │ ├── index.md.DUOif5wB.js │ ├── index.md.DUOif5wB.lean.js │ ├── guide_getting-started.md.C9__6HHp.lean.js │ ├── guide_getting-started.md.C9__6HHp.js │ ├── guide_create-board.md.CidzqPom.lean.js │ ├── guide_self-hosting.md.BoyRbhMl.js │ └── guide_create-board.md.CidzqPom.js ├── hashmap.json ├── vp-icons.css ├── sitemap.xml └── 404.html ├── docs ├── docs │ ├── public │ │ ├── logo.png │ │ ├── robots.txt │ │ ├── comments.png │ │ ├── favicon.ico │ │ ├── createboard.png │ │ ├── dashboard_guest.png │ │ ├── dashboard_move.png │ │ ├── dashboard_owner.png │ │ ├── logo_large_dark.png │ │ ├── apple-touch-icon.png │ │ ├── logo_large_light.png │ │ ├── dashboard_add_cards.png │ │ ├── videos │ │ │ ├── create-board.mp4 │ │ │ ├── start-stop-timer.mp4 │ │ │ └── add-update-message.mp4 │ │ ├── createboard_turnstile.png │ │ ├── dashboard_edit_columns.png │ │ └── dashboard_focus_panel.png │ ├── .vitepress │ │ └── theme │ │ │ ├── index.ts │ │ │ └── custom.css │ ├── guide │ │ ├── getting-started.md │ │ ├── self-hosting.md │ │ ├── development.md │ │ ├── create-board.md │ │ └── configurations.md │ └── index.md └── package.json ├── redis └── users.acl ├── .gitignore ├── onlybackend.Dockerfile ├── Caddyfile ├── Dockerfile ├── Caddyfile.demohosting ├── compose.yml ├── README.md ├── compose.replicas.yml ├── compose.multiservice.yml ├── compose.reverseproxy.yml └── compose.demohosting.yml /.dockerignore: -------------------------------------------------------------------------------- 1 | **/node_modules -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | REDIS_CONNSTR=redis://redis:6379/0 2 | -------------------------------------------------------------------------------- /onlybackend.dockerignore: -------------------------------------------------------------------------------- 1 | **/node_modules -------------------------------------------------------------------------------- /src/frontend/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/frontend/.env.development: -------------------------------------------------------------------------------- 1 | VITE_WS_PROTOCOL=ws 2 | VITE_SHOW_CONSOLE_LOGS=true -------------------------------------------------------------------------------- /src/input.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; -------------------------------------------------------------------------------- /homepage/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vijeeshr/quickretro/HEAD/homepage/logo.png -------------------------------------------------------------------------------- /homepage/comments.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vijeeshr/quickretro/HEAD/homepage/comments.png -------------------------------------------------------------------------------- /homepage/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vijeeshr/quickretro/HEAD/homepage/favicon.ico -------------------------------------------------------------------------------- /homepage/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / 3 | 4 | Sitemap: https://quickretro.app/sitemap.xml -------------------------------------------------------------------------------- /homepage/createboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vijeeshr/quickretro/HEAD/homepage/createboard.png -------------------------------------------------------------------------------- /docs/docs/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vijeeshr/quickretro/HEAD/docs/docs/public/logo.png -------------------------------------------------------------------------------- /docs/docs/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / 3 | 4 | Sitemap: https://quickretro.app/sitemap.xml -------------------------------------------------------------------------------- /docs/docs/public/comments.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vijeeshr/quickretro/HEAD/docs/docs/public/comments.png -------------------------------------------------------------------------------- /docs/docs/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vijeeshr/quickretro/HEAD/docs/docs/public/favicon.ico -------------------------------------------------------------------------------- /homepage/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vijeeshr/quickretro/HEAD/homepage/apple-touch-icon.png -------------------------------------------------------------------------------- /homepage/dashboard_guest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vijeeshr/quickretro/HEAD/homepage/dashboard_guest.png -------------------------------------------------------------------------------- /homepage/dashboard_move.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vijeeshr/quickretro/HEAD/homepage/dashboard_move.png -------------------------------------------------------------------------------- /homepage/dashboard_owner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vijeeshr/quickretro/HEAD/homepage/dashboard_owner.png -------------------------------------------------------------------------------- /homepage/logo_large_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vijeeshr/quickretro/HEAD/homepage/logo_large_dark.png -------------------------------------------------------------------------------- /homepage/logo_large_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vijeeshr/quickretro/HEAD/homepage/logo_large_light.png -------------------------------------------------------------------------------- /src/frontend/src/index.css: -------------------------------------------------------------------------------- 1 | /* ./src/index.css */ 2 | @tailwind base; 3 | @tailwind components; 4 | @tailwind utilities; -------------------------------------------------------------------------------- /src/frontend/src/models/LikeMessage.ts: -------------------------------------------------------------------------------- 1 | export interface LikeMessage { 2 | msgId: string 3 | like: boolean 4 | } -------------------------------------------------------------------------------- /src/frontend/src/models/OnlineUser.ts: -------------------------------------------------------------------------------- 1 | export interface OnlineUser { 2 | nickname: string 3 | xid: string 4 | } -------------------------------------------------------------------------------- /docs/docs/public/createboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vijeeshr/quickretro/HEAD/docs/docs/public/createboard.png -------------------------------------------------------------------------------- /homepage/createboard_turnstile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vijeeshr/quickretro/HEAD/homepage/createboard_turnstile.png -------------------------------------------------------------------------------- /homepage/dashboard_add_cards.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vijeeshr/quickretro/HEAD/homepage/dashboard_add_cards.png -------------------------------------------------------------------------------- /homepage/dashboard_focus_panel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vijeeshr/quickretro/HEAD/homepage/dashboard_focus_panel.png -------------------------------------------------------------------------------- /homepage/videos/create-board.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vijeeshr/quickretro/HEAD/homepage/videos/create-board.mp4 -------------------------------------------------------------------------------- /src/frontend/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["Vue.volar", "Vue.vscode-typescript-vue-plugin"] 3 | } 4 | -------------------------------------------------------------------------------- /docs/docs/public/dashboard_guest.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vijeeshr/quickretro/HEAD/docs/docs/public/dashboard_guest.png -------------------------------------------------------------------------------- /docs/docs/public/dashboard_move.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vijeeshr/quickretro/HEAD/docs/docs/public/dashboard_move.png -------------------------------------------------------------------------------- /docs/docs/public/dashboard_owner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vijeeshr/quickretro/HEAD/docs/docs/public/dashboard_owner.png -------------------------------------------------------------------------------- /docs/docs/public/logo_large_dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vijeeshr/quickretro/HEAD/docs/docs/public/logo_large_dark.png -------------------------------------------------------------------------------- /homepage/dashboard_edit_columns.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vijeeshr/quickretro/HEAD/homepage/dashboard_edit_columns.png -------------------------------------------------------------------------------- /homepage/videos/start-stop-timer.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vijeeshr/quickretro/HEAD/homepage/videos/start-stop-timer.mp4 -------------------------------------------------------------------------------- /docs/docs/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vijeeshr/quickretro/HEAD/docs/docs/public/apple-touch-icon.png -------------------------------------------------------------------------------- /docs/docs/public/logo_large_light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vijeeshr/quickretro/HEAD/docs/docs/public/logo_large_light.png -------------------------------------------------------------------------------- /homepage/videos/add-update-message.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vijeeshr/quickretro/HEAD/homepage/videos/add-update-message.mp4 -------------------------------------------------------------------------------- /docs/docs/public/dashboard_add_cards.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vijeeshr/quickretro/HEAD/docs/docs/public/dashboard_add_cards.png -------------------------------------------------------------------------------- /docs/docs/public/videos/create-board.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vijeeshr/quickretro/HEAD/docs/docs/public/videos/create-board.mp4 -------------------------------------------------------------------------------- /src/frontend/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /docs/docs/.vitepress/theme/index.ts: -------------------------------------------------------------------------------- 1 | import DefaultTheme from 'vitepress/theme' 2 | import './custom.css' 3 | 4 | export default DefaultTheme -------------------------------------------------------------------------------- /docs/docs/public/createboard_turnstile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vijeeshr/quickretro/HEAD/docs/docs/public/createboard_turnstile.png -------------------------------------------------------------------------------- /docs/docs/public/dashboard_edit_columns.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vijeeshr/quickretro/HEAD/docs/docs/public/dashboard_edit_columns.png -------------------------------------------------------------------------------- /docs/docs/public/dashboard_focus_panel.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vijeeshr/quickretro/HEAD/docs/docs/public/dashboard_focus_panel.png -------------------------------------------------------------------------------- /docs/docs/public/videos/start-stop-timer.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vijeeshr/quickretro/HEAD/docs/docs/public/videos/start-stop-timer.mp4 -------------------------------------------------------------------------------- /docs/docs/public/videos/add-update-message.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vijeeshr/quickretro/HEAD/docs/docs/public/videos/add-update-message.mp4 -------------------------------------------------------------------------------- /homepage/assets/inter-italic-greek.DJ8dCoTZ.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vijeeshr/quickretro/HEAD/homepage/assets/inter-italic-greek.DJ8dCoTZ.woff2 -------------------------------------------------------------------------------- /homepage/assets/inter-italic-latin.C2AdPX0b.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vijeeshr/quickretro/HEAD/homepage/assets/inter-italic-latin.C2AdPX0b.woff2 -------------------------------------------------------------------------------- /homepage/assets/inter-roman-greek.BBVDIX6e.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vijeeshr/quickretro/HEAD/homepage/assets/inter-roman-greek.BBVDIX6e.woff2 -------------------------------------------------------------------------------- /homepage/assets/inter-roman-latin.Di8DUHzh.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vijeeshr/quickretro/HEAD/homepage/assets/inter-roman-latin.Di8DUHzh.woff2 -------------------------------------------------------------------------------- /homepage/assets/inter-roman-cyrillic.C5lxZ8CY.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vijeeshr/quickretro/HEAD/homepage/assets/inter-roman-cyrillic.C5lxZ8CY.woff2 -------------------------------------------------------------------------------- /homepage/assets/inter-italic-cyrillic.By2_1cv3.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vijeeshr/quickretro/HEAD/homepage/assets/inter-italic-cyrillic.By2_1cv3.woff2 -------------------------------------------------------------------------------- /homepage/assets/inter-italic-greek-ext.1u6EdAuj.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vijeeshr/quickretro/HEAD/homepage/assets/inter-italic-greek-ext.1u6EdAuj.woff2 -------------------------------------------------------------------------------- /homepage/assets/inter-italic-latin-ext.CN1xVJS-.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vijeeshr/quickretro/HEAD/homepage/assets/inter-italic-latin-ext.CN1xVJS-.woff2 -------------------------------------------------------------------------------- /homepage/assets/inter-italic-vietnamese.BSbpV94h.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vijeeshr/quickretro/HEAD/homepage/assets/inter-italic-vietnamese.BSbpV94h.woff2 -------------------------------------------------------------------------------- /homepage/assets/inter-roman-greek-ext.CqjqNYQ-.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vijeeshr/quickretro/HEAD/homepage/assets/inter-roman-greek-ext.CqjqNYQ-.woff2 -------------------------------------------------------------------------------- /homepage/assets/inter-roman-latin-ext.4ZJIpNVo.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vijeeshr/quickretro/HEAD/homepage/assets/inter-roman-latin-ext.4ZJIpNVo.woff2 -------------------------------------------------------------------------------- /homepage/assets/inter-roman-vietnamese.BjW4sHH5.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vijeeshr/quickretro/HEAD/homepage/assets/inter-roman-vietnamese.BjW4sHH5.woff2 -------------------------------------------------------------------------------- /homepage/assets/inter-italic-cyrillic-ext.r48I6akx.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vijeeshr/quickretro/HEAD/homepage/assets/inter-italic-cyrillic-ext.r48I6akx.woff2 -------------------------------------------------------------------------------- /homepage/assets/inter-roman-cyrillic-ext.BBPuwvHQ.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vijeeshr/quickretro/HEAD/homepage/assets/inter-roman-cyrillic-ext.BBPuwvHQ.woff2 -------------------------------------------------------------------------------- /src/frontend/src/models/CategoryChangeMessage.ts: -------------------------------------------------------------------------------- 1 | export interface CategoryChangeMessage { 2 | msgId: string 3 | newCategoryId: string 4 | oldCategoryId: string 5 | } -------------------------------------------------------------------------------- /src/frontend/src/models/DraftMessage.ts: -------------------------------------------------------------------------------- 1 | export interface DraftMessage { 2 | id: string 3 | msg: string 4 | cat: string 5 | anon: boolean 6 | pid: string 7 | } -------------------------------------------------------------------------------- /src/frontend/src/App.vue: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /redis/users.acl: -------------------------------------------------------------------------------- 1 | user default off 2 | user admin on >mysecretadminpassword ~* &* +@all 3 | user app-user on >mysecretpassword ~* &* +@read +@write +@pubsub -@dangerous -FLUSHDB -CONFIG +PING -------------------------------------------------------------------------------- /src/user.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // Store 4 | type User struct { 5 | Id string `redis:"id"` 6 | Xid string `redis:"xid"` 7 | Nickname string `redis:"nickname"` 8 | } 9 | -------------------------------------------------------------------------------- /src/frontend/src/models/CategoryDefinition.ts: -------------------------------------------------------------------------------- 1 | export interface CategoryDefinition { 2 | id: string; 3 | text: string; 4 | enabled: boolean; 5 | pos: number; 6 | color: string; 7 | colorClass: string; 8 | } -------------------------------------------------------------------------------- /homepage/hashmap.json: -------------------------------------------------------------------------------- 1 | {"guide_configurations.md":"BLKCYmEX","guide_create-board.md":"CidzqPom","guide_dashboard.md":"C4l-NjdP","guide_development.md":"BCIGqezz","guide_getting-started.md":"C9__6HHp","guide_self-hosting.md":"BoyRbhMl","index.md":"DUOif5wB"} 2 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "vitepress": "^1.6.3" 4 | }, 5 | "scripts": { 6 | "docs:dev": "vitepress dev docs", 7 | "docs:build": "vitepress build docs", 8 | "docs:preview": "vitepress preview docs" 9 | } 10 | } -------------------------------------------------------------------------------- /src/frontend/src/composables/useSanitize.ts: -------------------------------------------------------------------------------- 1 | import DOMPurify from 'dompurify'; 2 | 3 | export default () => ({ 4 | sanitize: (dirty: string) => DOMPurify.sanitize(dirty, { 5 | ALLOWED_TAGS: ['b', 'i', 'u', 'em', 'strong', 'br'], 6 | ALLOWED_ATTR: [] 7 | }) 8 | }) -------------------------------------------------------------------------------- /src/frontend/src/models/BoardColumn.ts: -------------------------------------------------------------------------------- 1 | export interface BoardColumn { 2 | id: string 3 | text: string 4 | isDefault: boolean // Used to identify if Board creator entered custom value for "text". Useful during multi-lang translation. 5 | color: string 6 | pos: number 7 | } -------------------------------------------------------------------------------- /src/frontend/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true 8 | }, 9 | "include": ["vite.config.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /src/frontend/src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | // import './style.css' 3 | import './index.css' 4 | import App from './App.vue' 5 | import router from './router' 6 | import ToastPlugin from 'vue-toast-notification' 7 | import i18n from './i18n' 8 | 9 | createApp(App).use(i18n).use(router).use(ToastPlugin).mount('#app') 10 | -------------------------------------------------------------------------------- /src/frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /src/frontend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | // add more generic rulesets here, such as: 4 | // 'eslint:recommended', 5 | 'plugin:vue/vue3-recommended', 6 | // 'plugin:vue/recommended' // Use this if you are using Vue.js 2.x. 7 | ], 8 | rules: { 9 | // override/add rules settings here, such as: 10 | // 'vue/no-unused-vars': 'error' 11 | } 12 | } -------------------------------------------------------------------------------- /homepage/assets/guide_self-hosting.md.BoyRbhMl.lean.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("",13)]))}const m=s(o,[["render",r]]);export{u as __pageData,m as default}; 2 | -------------------------------------------------------------------------------- /src/frontend/src/assets/vue.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/vijeeshr/quickretro 2 | 3 | go 1.25.4 4 | 5 | require ( 6 | github.com/BurntSushi/toml v1.5.0 7 | github.com/gorilla/mux v1.8.1 8 | github.com/gorilla/websocket v1.5.3 9 | github.com/lithammer/shortuuid/v4 v4.2.0 10 | github.com/redis/go-redis/v9 v9.17.1 11 | ) 12 | 13 | require ( 14 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 15 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 16 | github.com/google/uuid v1.6.0 // indirect 17 | ) 18 | -------------------------------------------------------------------------------- /homepage/assets/guide_configurations.md.BLKCYmEX.lean.js: -------------------------------------------------------------------------------- 1 | import{_ as a,C as t,c as n,o as l,ae as e,j as o,a as h,G as p}from"./chunks/framework.CTVYQtO4.js";const _=JSON.parse('{"title":"Configurations","description":"","frontmatter":{},"headers":[],"relativePath":"guide/configurations.md","filePath":"guide/configurations.md","lastUpdated":1765120473000}'),d={name:"guide/configurations.md"};function r(c,s,k,g,E,u){const i=t("Badge");return l(),n("div",null,[s[1]||(s[1]=e("",14)),o("p",null,[s[0]||(s[0]=h("Available from ")),p(i,{type:"tip",text:"v1.6.0"})]),s[2]||(s[2]=e("",17))])}const b=a(d,[["render",r]]);export{_ as __pageData,b as default}; 2 | -------------------------------------------------------------------------------- /src/frontend/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import vue from '@vitejs/plugin-vue' 3 | 4 | // https://vitejs.dev/config/ 5 | export default defineConfig({ 6 | plugins: [vue()], 7 | server: { 8 | proxy: { 9 | '^/(ws)': { 10 | target: 'http://localhost:8080/', 11 | changeOrigin: true, 12 | ws: true, 13 | }, 14 | '^/(api)': { 15 | target: 'http://localhost:8080/', 16 | changeOrigin: true, 17 | }, 18 | '/config.js': { 19 | target: 'http://localhost:8080', 20 | changeOrigin: true, 21 | secure: false, 22 | } 23 | } 24 | } 25 | }) 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | # vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | 23 | node_modules 24 | 25 | # VitePress output directory 26 | docs/docs/.vitepress/cache/ 27 | docs/docs/.vitepress/dist/ -------------------------------------------------------------------------------- /src/frontend/src/constants/defaultCategories.ts: -------------------------------------------------------------------------------- 1 | import { CategoryDefinition } from "../models/CategoryDefinition"; 2 | 3 | export const defaultCategories: CategoryDefinition[] = [ 4 | { id: "col01", text: "", color: "green", colorClass: "text-green-500", enabled: true, pos: 1 }, 5 | { id: "col02", text: "", color: "red", colorClass: "text-red-500", enabled: true, pos: 2 }, 6 | { id: "col03", text: "", color: "yellow", colorClass: "text-yellow-500", enabled: true, pos: 3 }, 7 | { id: "col04", text: "", color: "fuchsia", colorClass: "text-fuchsia-500", enabled: false, pos: 4 }, 8 | { id: "col05", text: "", color: "orange", colorClass: "text-orange-500", enabled: false, pos: 5 } 9 | ] -------------------------------------------------------------------------------- /src/frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "module": "ESNext", 6 | "lib": ["ES2020", "DOM", "DOM.Iterable", "ES2021.String"], 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "preserve", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true 22 | }, 23 | "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"], 24 | "references": [{ "path": "./tsconfig.node.json" }] 25 | } 26 | -------------------------------------------------------------------------------- /src/frontend/src/components/NewAnonymousCard.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | -------------------------------------------------------------------------------- /src/frontend/src/types/turnstile.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace Turnstile { 2 | interface RenderParameters { 3 | sitekey: string 4 | callback?: (token: string) => void 5 | 'error-callback'?: () => void 6 | 'expired-callback'?: () => void 7 | theme?: 'auto' | 'light' | 'dark', 8 | size?: 'normal' | 'flexible' | 'compact' 9 | language?: string 10 | } 11 | 12 | function remove(widgetId: string): void 13 | 14 | function reset(widgetId: string): void 15 | 16 | function render( 17 | container: string | HTMLElement, 18 | params: RenderParameters 19 | ): string 20 | } 21 | 22 | declare interface App_Config { 23 | turnstileEnabled: boolean 24 | turnstileSiteKey: string 25 | } 26 | 27 | declare interface Window { 28 | APP_CONFIG?: typeof App_Config 29 | turnstile: typeof Turnstile 30 | } -------------------------------------------------------------------------------- /homepage/vp-icons.css: -------------------------------------------------------------------------------- 1 | .vpi-social-github{--icon:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='24' height='24'%3E%3Cpath fill='black' d='M12 .297c-6.63 0-12 5.373-12 12c0 5.303 3.438 9.8 8.205 11.385c.6.113.82-.258.82-.577c0-.285-.01-1.04-.015-2.04c-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729c1.205.084 1.838 1.236 1.838 1.236c1.07 1.835 2.809 1.305 3.495.998c.108-.776.417-1.305.76-1.605c-2.665-.3-5.466-1.332-5.466-5.93c0-1.31.465-2.38 1.235-3.22c-.135-.303-.54-1.523.105-3.176c0 0 1.005-.322 3.3 1.23c.96-.267 1.98-.399 3-.405c1.02.006 2.04.138 3 .405c2.28-1.552 3.285-1.23 3.285-1.23c.645 1.653.24 2.873.12 3.176c.765.84 1.23 1.91 1.23 3.22c0 4.61-2.805 5.625-5.475 5.92c.42.36.81 1.096.81 2.22c0 1.606-.015 2.896-.015 3.286c0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12'/%3E%3C/svg%3E")} -------------------------------------------------------------------------------- /src/frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "quickretroapp", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vue-tsc && vite build", 9 | "build-dev": "vue-tsc && vite build --mode development", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@headlessui/vue": "^1.7.23", 14 | "dompurify": "^3.2.6", 15 | "vue": "^3.5.21", 16 | "vue-i18n": "^11.1.11", 17 | "vue-router": "^4.5.1", 18 | "vue-toast-notification": "^3.1.3" 19 | }, 20 | "devDependencies": { 21 | "@vitejs/plugin-vue": "^6.0.1", 22 | "autoprefixer": "^10.4.17", 23 | "eslint": "^9.34.0", 24 | "eslint-plugin-vue": "^9.20.1", 25 | "postcss": "^8.5.6", 26 | "tailwindcss": "^3.4.1", 27 | "typescript": "^5.9.2", 28 | "vite": "^7.1.4", 29 | "vue-tsc": "^3.0.6" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /onlybackend.Dockerfile: -------------------------------------------------------------------------------- 1 | # docker build -f onlybackend.Dockerfile -t quickretro-app . 2 | 3 | FROM golang:1.25.4-alpine AS backend-builder 4 | WORKDIR /app 5 | # Copy Go module files and download dependencies 6 | COPY src/go.mod src/go.sum ./ 7 | RUN go mod download 8 | # Copy application source code and config 9 | COPY src/config.toml . 10 | COPY src/*.go ./ 11 | # Frontend must be already built by doing "npm run build" or "npm run build-dev" before this step 12 | COPY src/frontend/dist frontend/dist 13 | # CGO_ENABLED=0 ensures a static binary 14 | # -ldflags "-s -w" removes debugging symbols, reducing binary size 15 | RUN CGO_ENABLED=0 GOOS=linux go build -ldflags "-s -w" -o retroapp . 16 | 17 | FROM scratch AS final 18 | COPY --from=alpine:latest /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 19 | WORKDIR /app 20 | COPY --from=backend-builder /app/retroapp . 21 | COPY --from=backend-builder /app/config.toml . 22 | 23 | EXPOSE 8080 24 | CMD ["./retroapp"] -------------------------------------------------------------------------------- /src/frontend/src/components/HelloWorld.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 32 | 33 | 38 | -------------------------------------------------------------------------------- /docs/docs/.vitepress/theme/custom.css: -------------------------------------------------------------------------------- 1 | :root { 2 | /* --vp-home-hero-name-color: blue; */ 3 | --vp-c-brand-1: #0EA5E9; 4 | --vp-c-brand-2: #0369A1; 5 | --vp-c-brand-3: #0EA5E9; 6 | } 7 | 8 | .shadow-img { 9 | box-shadow: 0px 4px 10px rgba(0, 0, 0, 0.2); 10 | border-radius: 8px; 11 | /* Optional for rounded corners */ 12 | } 13 | 14 | .display-icon { 15 | width: 1.5rem; 16 | height: 1.5rem; 17 | display: inline-block; 18 | vertical-align: middle; 19 | } 20 | 21 | .video-play { 22 | border-radius: 8px; 23 | box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); 24 | } 25 | 26 | /* :root { 27 | --vp-home-hero-name-color: transparent; 28 | --vp-home-hero-name-background: -webkit-linear-gradient(120deg, 29 | #bd34fe 30%, 30 | #41d1ff); 31 | --vp-home-hero-image-background-image: linear-gradient(-45deg, 32 | #bd34fe 50%, 33 | #47caff 50%); 34 | --vp-home-hero-image-filter: blur(44px); 35 | } */ -------------------------------------------------------------------------------- /src/helpers_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | ) 7 | 8 | func Test_parseDuration(t *testing.T) { 9 | tests := []struct { 10 | input string 11 | expected time.Duration 12 | hasError bool 13 | }{ 14 | {"10s", 10 * time.Second, false}, 15 | {"5m", 5 * time.Minute, false}, 16 | {"2h", 2 * time.Hour, false}, 17 | {"3d", 3 * 24 * time.Hour, false}, 18 | {"0s", 0, false}, 19 | {"100m", 100 * time.Minute, false}, 20 | {"-5m", -5 * time.Minute, false}, 21 | {"abc", 0, true}, 22 | {"10x", 0, true}, 23 | {"", 0, true}, 24 | } 25 | 26 | for _, tt := range tests { 27 | t.Run(tt.input, func(t *testing.T) { 28 | result, err := parseDuration(tt.input) 29 | if (err != nil) != tt.hasError { 30 | t.Errorf("parseDuration(%q) error = %v, wantErr %v", tt.input, err, tt.hasError) 31 | } 32 | if result != tt.expected { 33 | t.Errorf("parseDuration(%q) = %v, want %v", tt.input, result, tt.expected) 34 | } 35 | }) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/frontend/.env: -------------------------------------------------------------------------------- 1 | VITE_WS_PROTOCOL=wss 2 | VITE_SHOW_CONSOLE_LOGS=false 3 | # Triggers message size validation. 4 | # 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). 5 | # To avoid message size validation, comment out below line. However, this will break the server websocket connection when the limit is breached. 6 | VITE_MAX_WEBSOCKET_MESSAGE_SIZE_BYTES=1024 7 | VITE_TURNSTILE_SCRIPT_URL=https://challenges.cloudflare.com/turnstile/v0/api.js 8 | # Maximum number of characters allowed for each category name 9 | # 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). 10 | VITE_MAX_CATEGORY_TEXT_LENGTH=80 11 | # Maximum number of characters allowed for board name, team name, nickname. 12 | # It is recommended to keep the same value as what's allowed in backend server (defined in src/config.toml [server].max_text_length). 13 | VITE_MAX_TEXT_LENGTH=80 -------------------------------------------------------------------------------- /Caddyfile: -------------------------------------------------------------------------------- 1 | docs.localhost { 2 | # Permanent redirects for changed URLs 3 | redir /configurations /guide/configurations 301 4 | redir /create-board /guide/create-board 301 5 | redir /dashboard /guide/dashboard 301 6 | redir /development /guide/development 301 7 | redir /getting-started /guide/getting-started 301 8 | redir /self-hosting /guide/self-hosting 301 9 | # Handle trailing slash for guide 10 | redir /guide/ /guide/getting-started 301 11 | redir /guide /guide/getting-started 301 12 | 13 | encode gzip 14 | root * /var/www/homepage 15 | try_files {path}.html {path}/ {path} /404.html 16 | file_server 17 | } 18 | 19 | localhost { 20 | encode gzip 21 | reverse_proxy app:8080 22 | } 23 | 24 | # Uncomment below block and comment above block when used with comnpose.multiservice.yml 25 | # localhost { 26 | # encode gzip 27 | # reverse_proxy { 28 | # to app:8080 app01:8080 29 | 30 | # lb_policy round_robin 31 | # lb_retries 2 32 | # } 33 | # } -------------------------------------------------------------------------------- /src/frontend/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'], 4 | theme: { 5 | extend: { 6 | minHeight: (theme) => ({ 7 | ...theme('spacing'), 8 | }), 9 | } 10 | }, 11 | darkMode: 'class', 12 | plugins: [], 13 | safelist: [ 14 | // Backgrounds 15 | { 16 | pattern: /^(bg|hover:bg|dark:bg|dark:hover:bg)-(red|green|yellow|fuchsia|orange)-(100|400|500|600|800)/, 17 | variants: ['hover', 'dark', 'dark:hover'], 18 | }, 19 | // Borders 20 | { 21 | pattern: /^(border|dark:border)-(red|green|yellow|fuchsia|orange)-(300|700)/, 22 | variants: ['dark'], 23 | }, 24 | // Text 25 | { 26 | pattern: /^(text|dark:text)-(red|green|yellow|fuchsia|orange)-(100|600)/, 27 | variants: ['dark'], 28 | } 29 | // Sizes 30 | // { 31 | // pattern: /w-(6|8)/, 32 | // }, 33 | // { 34 | // pattern: /h-(6|8)/, 35 | // } 36 | ], 37 | } -------------------------------------------------------------------------------- /src/frontend/src/i18n/index.ts: -------------------------------------------------------------------------------- 1 | import { createI18n } from 'vue-i18n' 2 | import en from './en' 3 | import es from './es' 4 | import de from './de' 5 | import fr from './fr' 6 | import frCA from './fr-CA' 7 | import ptBR from './pt-BR' 8 | import pt from './pt' 9 | import nl from './nl' 10 | import it from './it' 11 | import zhCN from './zh-CN' 12 | import ja from './ja' 13 | import ko from './ko' 14 | import ru from './ru' 15 | import uk from './uk' 16 | import pl from './pl' 17 | 18 | type MessageSchema = typeof en 19 | 20 | const savedLanguage = localStorage.getItem('lang') 21 | 22 | export default createI18n<[MessageSchema], 'en' | 'zhCN' | 'es' | 'de' | 'fr' | 'ptBR' | 'ru' | 'ja' | 'nl' | 'ko' | 'it' | 'pt' | 'uk' | 'frCA' | 'pl'>({ 23 | legacy: false, 24 | locale: savedLanguage || 'en', 25 | fallbackLocale: 'en', 26 | messages : { 27 | en, zhCN, es, de, fr, ptBR, ru, ja, nl, ko, it, pt, uk, frCA, pl 28 | } 29 | }) 30 | 31 | declare module 'vue-i18n' { 32 | // Define the vue-i18n type schema 33 | export interface DefineLocaleMessage extends MessageSchema {} 34 | } -------------------------------------------------------------------------------- /docs/docs/guide/getting-started.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | This guide gives a quick and easy functional walkthrough of QuickRetro app.\ 4 | To start, visit the site and type in a name to join as guest. There is no signup/login process. 5 | 6 | ### Latest version 7 | 8 | ## Try the Demo 9 | Try out the [live demo](https://demo.quickretro.app). It is recommended to self-host. 10 | 11 | ::: danger DATA CLEANUP 12 | All data in [demo](https://demo.quickretro.app) site is auto-deleted in **2 days**. 13 | 14 | In versions prior to , data is auto-deleted in 2 hours. 15 | ::: 16 | ::: info NOTE 17 | 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. 18 | ::: 19 | 20 | ## Supported Languages 21 | English\ 22 | 简体中文 (zh-CN)\ 23 | Español\ 24 | Deutsch\ 25 | Français\ 26 | Português (Brasil)\ 27 | Русский (ru)\ 28 | 日本語 (ja)\ 29 | Português\ 30 | Nederlands\ 31 | 한국어 (ko)\ 32 | Українська (uk)\ 33 | Italiano\ 34 | Français (Canada)\ 35 | Polski -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:24.11.1-alpine AS frontend-builder 2 | WORKDIR /app 3 | # node_modules directory is excluded with .dockerignore 4 | # Copy package files first for efficient caching 5 | COPY src/frontend/package*.json ./ 6 | RUN npm install 7 | # Copy source code and run the development build 8 | COPY src/frontend/ . 9 | RUN npm run build-dev 10 | 11 | FROM golang:1.25.4-alpine AS backend-builder 12 | WORKDIR /app 13 | # Copy Go module files and download dependencies 14 | COPY src/go.mod src/go.sum ./ 15 | RUN go mod download 16 | # Copy application source code and config 17 | COPY src/config.toml . 18 | COPY src/*.go ./ 19 | # Copy compiled frontend assets from the previous stage 20 | COPY --from=frontend-builder /app/dist frontend/dist 21 | # CGO_ENABLED=0 ensures a static binary 22 | # -ldflags "-s -w" removes debugging symbols, reducing binary size 23 | RUN CGO_ENABLED=0 GOOS=linux go build -ldflags "-s -w" -o retroapp . 24 | 25 | FROM scratch AS final 26 | COPY --from=alpine:latest /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ 27 | WORKDIR /app 28 | COPY --from=backend-builder /app/retroapp . 29 | COPY --from=backend-builder /app/config.toml . 30 | 31 | EXPOSE 8080 32 | CMD ["./retroapp"] -------------------------------------------------------------------------------- /src/frontend/src/router.ts: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHistory } from 'vue-router' 2 | import Dashboard from './components/Dashboard.vue' 3 | import Join from './components/Join.vue' 4 | import CreateBoard from './components/CreateBoard.vue' 5 | 6 | export default createRouter({ 7 | history: createWebHistory(), 8 | routes: [ 9 | { 10 | path: '/', 11 | name: "start", 12 | component: Join, 13 | }, 14 | { 15 | path: '/create', 16 | name: 'create', 17 | component: CreateBoard, 18 | beforeEnter: () => { 19 | if (!localStorage.getItem("user") || !localStorage.getItem("xid") || !localStorage.getItem("nickname")) { 20 | return `/` 21 | } 22 | }, 23 | }, 24 | { 25 | path: '/board/:board', 26 | name: 'dashboard', 27 | component: Dashboard, 28 | beforeEnter: (to) => { 29 | if (!localStorage.getItem("user") || !localStorage.getItem("xid") || !localStorage.getItem("nickname")) { 30 | return `/board/${to.params.board}/join` 31 | } 32 | }, 33 | }, 34 | { 35 | path: '/board/:board/join', 36 | name: 'join', 37 | component: Join, 38 | }, 39 | ], 40 | }) 41 | -------------------------------------------------------------------------------- /src/frontend/src/api/index.ts: -------------------------------------------------------------------------------- 1 | import { BoardColumn } from "../models/BoardColumn" 2 | 3 | const createBoardUrl = `/api/board/create` 4 | 5 | export interface CreateBoardRequest { 6 | name: string 7 | team: string 8 | owner: string 9 | columns: BoardColumn[] 10 | cfTurnstileResponse: string 11 | } 12 | 13 | export interface CreateBoardResponse { 14 | id: string 15 | } 16 | 17 | export const createBoard = async (payload: CreateBoardRequest): Promise => { 18 | try { 19 | const response = await fetch(createBoardUrl, { 20 | method: "POST", 21 | headers: { 22 | "Content-Type": "application/json", 23 | }, 24 | body: JSON.stringify(payload), 25 | }) 26 | 27 | if (!response.ok) { 28 | throw new Error('Network response was not ok') 29 | } 30 | const data: CreateBoardResponse = await response.json() 31 | if (!data.id) { 32 | throw new Error('Error getting board id from response') 33 | } 34 | 35 | return data 36 | 37 | } catch (error) { 38 | console.error('Error:', error) 39 | throw error // Re-throw the error to maintain the Promise rejection 40 | } 41 | } -------------------------------------------------------------------------------- /src/frontend/src/composables/useLanguage.ts: -------------------------------------------------------------------------------- 1 | import { useI18n } from 'vue-i18n' 2 | import { computed } from 'vue' 3 | 4 | export const availableLocales = ['en', 'zhCN', 'es', 'de', 'fr', 'ptBR', 'ru', 'ja', 'nl', 'ko', 'it', 'pt', 'uk', 'frCA', 'pl'] as const 5 | 6 | export type AvailableLocales = typeof availableLocales[number] 7 | 8 | export function useLanguage() { 9 | const { locale: i18nLocale, getLocaleMessage } = useI18n() 10 | 11 | const locale = computed({ 12 | get: () => i18nLocale.value as AvailableLocales, 13 | set: (value) => { 14 | setLocale(value) 15 | } 16 | }) 17 | 18 | const setLocale = (newLocale: AvailableLocales) => { 19 | i18nLocale.value = newLocale 20 | try { 21 | localStorage.setItem('lang', newLocale) 22 | } catch (error) { 23 | console.error('Failed to save locale:', error) 24 | } 25 | } 26 | 27 | const languageOptions = computed(() => 28 | availableLocales.map(code => ({ 29 | code, 30 | name: getLocaleMessage(code).langName 31 | })) 32 | ) 33 | 34 | return { 35 | locale, 36 | setLocale, 37 | languageOptions, 38 | getLocaleMessage 39 | } 40 | } -------------------------------------------------------------------------------- /homepage/assets/app.rh1oyAvj.js: -------------------------------------------------------------------------------- 1 | import{t as p}from"./chunks/theme.Z7SuLSAi.js";import{R as s,a0 as i,a1 as u,a2 as c,a3 as l,a4 as f,a5 as d,a6 as m,a7 as h,a8 as g,a9 as A,d as v,u as y,v as C,s as P,aa as b,ab as w,ac as R,ad as E}from"./chunks/framework.CTVYQtO4.js";function r(e){if(e.extends){const a=r(e.extends);return{...a,...e,async enhanceApp(t){a.enhanceApp&&await a.enhanceApp(t),e.enhanceApp&&await e.enhanceApp(t)}}}return e}const n=r(p),S=v({name:"VitePressApp",setup(){const{site:e,lang:a,dir:t}=y();return C(()=>{P(()=>{document.documentElement.lang=a.value,document.documentElement.dir=t.value})}),e.value.router.prefetchLinks&&b(),w(),R(),n.setup&&n.setup(),()=>E(n.Layout)}});async function T(){globalThis.__VITEPRESS__=!0;const e=_(),a=D();a.provide(u,e);const t=c(e.route);return a.provide(l,t),a.component("Content",f),a.component("ClientOnly",d),Object.defineProperties(a.config.globalProperties,{$frontmatter:{get(){return t.frontmatter.value}},$params:{get(){return t.page.value.params}}}),n.enhanceApp&&await n.enhanceApp({app:a,router:e,siteData:m}),{app:a,router:e,data:t}}function D(){return A(S)}function _(){let e=s;return h(a=>{let t=g(a),o=null;return t&&(e&&(t=t.replace(/\.js$/,".lean.js")),o=import(t)),s&&(e=!1),o},n.NotFound)}s&&T().then(({app:e,router:a,data:t})=>{a.go().then(()=>{i(a.route,t.site),e.mount("#app")})});export{T as createApp}; 2 | -------------------------------------------------------------------------------- /src/config.toml: -------------------------------------------------------------------------------- 1 | [server] 2 | # When self-hosting, add your domain to allowed_origins list. 3 | # For e.g. if you are hosting your site at https://example.com, allowed_origins will look like - 4 | # allowed_origins = [ 5 | # "https://example.com" 6 | # ] 7 | allowed_origins = [ 8 | "http://localhost:8080", 9 | "https://localhost:8080", 10 | "http://localhost:5173", 11 | "https://localhost", 12 | "https://quickretro.app", 13 | "https://demo.quickretro.app" 14 | ] 15 | turnstile_site_verify_url = "https://challenges.cloudflare.com/turnstile/v0/siteverify" 16 | # Maximum number of characters allowed for each category name 17 | # For the front-end validation, keep the same value in (src/frontend/.env [VITE_MAX_CATEGORY_TEXT_LENGTH]) 18 | max_category_text_length = 80 19 | # Maximum number of characters allowed for board name, team name 20 | # For the front-end validation, keep the same value in (src/frontend/.env [VITE_MAX_TEXT_LENGTH]) 21 | max_text_length = 80 22 | 23 | [websocket] 24 | # Maximum message size (in bytes) allowed from peer for the websocket connection 25 | # For the front-end validation, keep the same value in (src/frontend/.env [VITE_MAX_WEBSOCKET_MESSAGE_SIZE_BYTES]) 26 | max_message_size_bytes = 1024 27 | 28 | [data] 29 | # Format: 30 | # Units: s=seconds, m=minutes, h=hours, d=days 31 | # Examples: "50s" for 50 seconds, "5m" for 5 minutes, "2h" for 2 hours, "7d" for 7 days 32 | auto_delete_duration = "2d" -------------------------------------------------------------------------------- /homepage/assets/guide_development.md.BCIGqezz.lean.js: -------------------------------------------------------------------------------- 1 | import{_ as n,C as l,c as o,o as p,j as a,ae as r,a as s,G as i}from"./chunks/framework.CTVYQtO4.js";const v=JSON.parse('{"title":"Development Guide","description":"","frontmatter":{"outline":"deep"},"headers":[],"relativePath":"guide/development.md","filePath":"guide/development.md","lastUpdated":1765120473000}'),d={name:"guide/development.md"};function h(u,e,k,c,g,b){const t=l("Badge");return p(),o("div",null,[e[6]||(e[6]=a("h1",{id:"development-guide",tabindex:"-1"},[s("Development Guide "),a("a",{class:"header-anchor",href:"#development-guide","aria-label":'Permalink to "Development Guide"'},"​")],-1)),e[7]||(e[7]=a("p",null,"This guide is intended to help you get started with running the application locally, and making changes to it.",-1)),e[8]||(e[8]=a("h2",{id:"prerequisites",tabindex:"-1"},[s("Prerequisites "),a("a",{class:"header-anchor",href:"#prerequisites","aria-label":'Permalink to "Prerequisites"'},"​")],-1)),a("ul",null,[a("li",null,[e[0]||(e[0]=s("Go (project targets) ")),i(t,{type:"tip",text:"v1.25.4"}),e[1]||(e[1]=s(". Can also work on versions above ")),i(t,{type:"tip",text:"v1.21.6"})]),a("li",null,[e[2]||(e[2]=s("Node.js version ")),i(t,{type:"tip",text:"24.11.1"})]),e[3]||(e[3]=a("li",null,"Docker",-1)),e[4]||(e[4]=a("li",null,"Redis is used as the datastore and for pubsub",-1)),e[5]||(e[5]=a("li",null,"A text editor, preferably VS Code, and a CLI",-1))]),e[9]||(e[9]=r("",23))])}const F=n(d,[["render",h]]);export{v as __pageData,F as default}; 2 | -------------------------------------------------------------------------------- /src/frontend/src/components/Avatar.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | -------------------------------------------------------------------------------- /src/message.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // Store 4 | type Message struct { 5 | Id string `redis:"id"` 6 | By string `redis:"by"` 7 | ByXid string `redis:"byxid"` 8 | ByNickname string `redis:"nickname"` 9 | Group string `redis:"group"` 10 | Content string `redis:"content"` 11 | Category string `redis:"category"` 12 | ParentId string `redis:"pid"` // For top-level "Message" this will be empty. For a message treated as "Comment", it will be the parent MessageId. 13 | Anonymous bool `redis:"anon"` 14 | } 15 | 16 | func (m *MessageEvent) ToMessage() *Message { 17 | return &Message{ 18 | Id: m.Id, By: m.By, ByXid: m.ByXid, ByNickname: m.ByNickname, Group: m.Group, Content: m.Content, Category: m.Category, Anonymous: m.Anonymous, ParentId: m.ParentId} 19 | } 20 | 21 | func (m *Message) NewMessageResponse() MessageResponse { 22 | return MessageResponse{ 23 | Type: "msg", 24 | Id: m.Id, 25 | ByXid: m.ByXid, 26 | ByNickname: m.ByNickname, 27 | Content: m.Content, 28 | Category: m.Category, 29 | Anonymous: m.Anonymous, 30 | ParentId: m.ParentId, 31 | } 32 | } 33 | func (m *Message) NewDeleteResponse() DeleteMessageResponse { 34 | return DeleteMessageResponse{ 35 | Type: "del", 36 | Id: m.Id, 37 | } 38 | } 39 | func (m *Message) NewLikeResponse() LikeMessageResponse { 40 | return LikeMessageResponse{ 41 | Type: "like", 42 | Id: m.Id, 43 | } 44 | } 45 | 46 | // Enum SaveMode 47 | type SaveMode int 48 | 49 | const ( 50 | AsNewMessage SaveMode = iota 51 | AsNewComment 52 | ) 53 | -------------------------------------------------------------------------------- /src/frontend/README.md: -------------------------------------------------------------------------------- 1 | # Vue 3 + TypeScript + Vite 2 | 3 | This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 ` 21 | 22 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | Create Board 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 | Cloudflare Turnstile 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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(`

Self-Hosting

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.

Update Allowed-Origins

As defined in Configurations, update the config setting with your site origin.

Secure Redis Instance

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.

Passing ENV variables with Compose

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 -

sh
echo "REDIS_CONNSTR=redis://redis:6379/0" > .env
2 | # echo "MY_VAR1=false" >> .env
3 | # echo "MY_VAR2=true" >> .env

INFO

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.

Sample Compose files

Check out the sample docker compose files compose.yml, compose.reverseproxy.yml, compose.demohosting.yml etc in github repository for more details.

`,13)]))}const m=s(o,[["render",r]]);export{u as __pageData,m as default}; 4 | -------------------------------------------------------------------------------- /src/frontend/src/i18n/es.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | langName: 'Español', 3 | common: { 4 | anonymous: 'Anónimo', 5 | minutes: 'Minutos', 6 | seconds: 'Segundos', 7 | start: 'Iniciar', 8 | stop: 'Detener', 9 | copy: 'Copiar', 10 | board: 'Tablero', 11 | toolTips: { 12 | darkTheme: 'Activar tema oscuro', 13 | lightTheme: 'Activar tema claro' 14 | }, 15 | contentOverloadError: 'Contenido excede el límite permitido.', 16 | contentStrippingError: 'Contenido excede el límite permitido. El texto adicional ha sido eliminado.', 17 | invalidColumnSelection: 'Por favor selecciona columna(s)' 18 | }, 19 | join: { 20 | label: 'Unirse como invitado', 21 | namePlaceholder: '¡Escribe tu nombre aquí!', 22 | nameRequired: 'Por favor ingresa tu nombre', 23 | button: 'Unirse' 24 | }, 25 | createBoard: { 26 | label: 'Crear tablero', 27 | namePlaceholder: '¡Escribe el nombre del tablero aquí!', 28 | nameRequired: 'Por favor ingresa el nombre del tablero', 29 | teamNamePlaceholder: '¡Escribe el nombre del equipo aquí!', 30 | button: 'Crear', 31 | buttonProgress: 'Creando..', 32 | captchaInfo: 'Por favor, complete el CAPTCHA para continuar', 33 | boardCreationError: 'Error al crear el tablero' 34 | }, 35 | dashboard: { 36 | timer: { 37 | oneMinuteLeft: 'Queda un minuto', 38 | timeCompleted: '¡Se ha acabado el tiempo!', 39 | title: 'Iniciar/Detener temporizador', 40 | helpTip: 'Ajusta minutos y segundos con los controles + - o flechas del teclado. Máximo: 1 hora.', 41 | invalid: 'Valores inválidos. Rango permitido: 1 segundo a 60 minutos.', 42 | tooltip: 'Temporizador regresivo' 43 | }, 44 | share: { 45 | title: 'Copia y comparte esta URL con participantes', 46 | linkCopied: '¡Enlace copiado!', 47 | linkCopyError: 'Error al copiar. Copia manualmente.', 48 | toolTip: 'Compartir tablero' 49 | }, 50 | mask: { 51 | maskTooltip: 'Ocultar mensajes', 52 | unmaskTooltip: 'Mostrar mensajes' 53 | }, 54 | lock: { 55 | lockTooltip: 'Bloquear tablero', 56 | unlockTooltip: 'Desbloquear tablero', 57 | message: 'Tablero bloqueado por el propietario.', 58 | discardChanges: '¡Tablero bloqueado! Los mensajes no guardados se han descartado' 59 | }, 60 | spotlight: { 61 | noCardsToFocus: 'No hay tarjetas para enfocar', 62 | tooltip: 'Enfocar tarjetas' 63 | }, 64 | print: { 65 | tooltip: 'Imprimir' 66 | }, 67 | language: { 68 | tooltip : 'Cambiar idioma' 69 | }, 70 | delete: { 71 | title: 'Confirmar eliminación', 72 | text: 'Los datos no se pueden recuperar después de ser eliminados. ¿Seguro que desea continuar?', 73 | tooltip: 'Eliminar este tablero', 74 | continueDelete: 'Sí', 75 | cancelDelete: 'No' 76 | }, 77 | columns: { 78 | col01: 'Lo que salió bien', 79 | col02: 'Desafíos', 80 | col03: 'Acciones', 81 | col04: 'Agradecimientos', 82 | col05: 'Mejoras', 83 | cannotDisable: "No se puede desactivar la(s) columna(s) que tienen tarjetas", 84 | update: "Actualizar", 85 | discardNewMessages: 'Tu borrador se ha descartado porque la columna fue deshabilitada.' 86 | }, 87 | printFooter: 'Creado con', 88 | offline: 'Sin conexión.', 89 | notExists: 'El tablero fue eliminado automáticamente o lo eliminó manualmente su creador.', 90 | autoDeleteScheduleBase: 'Este tablero se limpiará automáticamente el {date}', 91 | autoDeleteScheduleAddon: ', así que no necesitas preocuparte por eliminarlo manualmente.' 92 | } 93 | } -------------------------------------------------------------------------------- /homepage/assets/guide_create-board.md.CidzqPom.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('

Create Board

The first thing you do is create/setup a board.
Enter a name for the Board and an optional Team name.

NOTE

The board creator is also the board owner and can perform multiple actions not available to others.
We'll soon see it in Dashboard section.

Configuring Board Columns

',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.

Quick video

Cloudflare Turnstile Integration

',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/pl.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | langName: 'Polski', 3 | common: { 4 | anonymous: 'Anonimowy', 5 | minutes: 'Minuty', 6 | seconds: 'Sekundy', 7 | start: 'Start', 8 | stop: 'Stop', 9 | copy: 'Kopiuj', 10 | board: 'Tablica', 11 | toolTips: { 12 | darkTheme: 'Włącz tryb ciemny', 13 | lightTheme: 'Włącz tryb jasny' 14 | }, 15 | contentOverloadError: 'Treść przekracza dozwolony limit.', 16 | contentStrippingError: 'Treść przekracza dozwolony limit. Nadmiarowy tekst został obcięty z końca.', 17 | invalidColumnSelection: 'Proszę wybrać kolumnę/kolumny' 18 | }, 19 | join: { 20 | label: 'Dołącz jako gość', 21 | namePlaceholder: 'Wpisz tutaj swoje imię!', 22 | nameRequired: 'Proszę podać swoje imię', 23 | button: 'Dołącz' 24 | }, 25 | createBoard: { 26 | label: 'Utwórz tablicę', 27 | namePlaceholder: 'Wpisz nazwę tablicy!', 28 | nameRequired: 'Proszę podać nazwę tablicy', 29 | teamNamePlaceholder: 'Wpisz nazwę zespołu!', 30 | button: 'Utwórz', 31 | buttonProgress: 'Tworzenie...', 32 | captchaInfo: 'Proszę ukończyć CAPTCHA, aby kontynuować', 33 | boardCreationError: 'Błąd podczas tworzenia tablicy' 34 | }, 35 | dashboard: { 36 | timer: { 37 | oneMinuteLeft: 'Pozostała jedna minuta do końca odliczania', 38 | timeCompleted: 'Hej! Czas się skończył', 39 | title: 'Start/Stop timera', 40 | helpTip: 'Dostosuj minuty i sekundy używając przycisków + i -, lub strzałek w górę i w dół na klawiaturze. Maksymalnie 1 godzina.', 41 | invalid: 'Proszę wprowadzić prawidłowe wartości minut/sekund. Dozwolony zakres to od 1 sekundy do 60 minut.', 42 | tooltip: 'Timer odliczania' 43 | }, 44 | share: { 45 | title: 'Skopiuj i udostępnij poniższy link uczestnikom', 46 | linkCopied: 'Link skopiowany!', 47 | linkCopyError: 'Nie udało się skopiować. Proszę skopiować ręcznie.', 48 | toolTip: 'Udostępnij tablicę innym' 49 | }, 50 | mask: { 51 | maskTooltip: 'Zamaskuj wiadomości', 52 | unmaskTooltip: 'Odkryj wiadomości' 53 | }, 54 | lock: { 55 | lockTooltip: 'Zablokuj tablicę', 56 | unlockTooltip: 'Odblokuj tablicę', 57 | message: 'Nie można dodać ani zaktualizować. Tablica jest zablokowana przez właściciela.', 58 | discardChanges: 'Tablica zablokowana! Niezapisane wiadomości zostały odrzucone' 59 | }, 60 | spotlight: { 61 | noCardsToFocus: 'Brak kart do wyświetlenia', 62 | tooltip: 'Skup się na kartach' 63 | }, 64 | print: { 65 | tooltip: 'Drukuj' 66 | }, 67 | language: { 68 | tooltip: 'Zmień język' 69 | }, 70 | delete: { 71 | title: 'Potwierdź usunięcie', 72 | text: 'Po usunięciu danych nie można ich odzyskać. Czy na pewno chcesz kontynuować?', 73 | tooltip: 'Usuń tę tablicę', 74 | continueDelete: 'Tak', 75 | cancelDelete: 'Nie' 76 | }, 77 | columns: { 78 | col01: 'Co poszło dobrze', 79 | col02: 'Wyzwania', 80 | col03: 'Działania do podjęcia', 81 | col04: 'Docenienia', 82 | col05: 'Ulepszenia', 83 | cannotDisable: "Nie można wyłączyć kolumny z przypisanymi kartami", 84 | update: "Aktualizuj", 85 | discardNewMessages: 'Twój szkic został odrzucony, ponieważ kolumna została wyłączona.' 86 | }, 87 | printFooter: 'Stworzone za pomocą', 88 | offline: 'Wygląda na to, że jesteś offline.', 89 | notExists: 'Tablica została automatycznie usunięta lub ręcznie usunięta przez jej twórcę.', 90 | autoDeleteScheduleBase: 'Ta tablica zostanie automatycznie usunięta dnia {date}', 91 | autoDeleteScheduleAddon: ', więc nie musisz martwić się o jej ręczne usunięcie.' 92 | } 93 | } -------------------------------------------------------------------------------- /src/frontend/src/components/TurnstileWidget.vue: -------------------------------------------------------------------------------- 1 | 123 | 124 | -------------------------------------------------------------------------------- /compose.demohosting.yml: -------------------------------------------------------------------------------- 1 | # With reverse-proxy. Access only with https://localhost. 2 | 3 | # Cold start 4 | # docker compose -f compose.demohosting.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.demohosting.yml stop 10 | # docker compose -f compose.demohosting.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.demohosting.yml down 16 | # docker compose -f compose.demohosting.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.demohosting.yml down --rmi "all" --volumes 22 | # docker compose -f compose.demohosting.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 | image: "vijeesh82/quickretro-app" 45 | restart: unless-stopped 46 | depends_on: 47 | - redis 48 | environment: 49 | # Load from .env file in same directory as the compose file. 50 | # To create file, in CLI: echo "REDIS_CONNSTR=redis://redis:6379/0" > .env 51 | # DO NOT create file from Windows. It creates Unicode text, UTF-16, little-endian text, with CRLF line terminators. 52 | - REDIS_CONNSTR=${REDIS_CONNSTR} 53 | # Default Redis (No Auth or ACL). Outside docker - redis://localhost:6379/0 54 | # - REDIS_CONNSTR=redis://redis:6379/0 55 | # Using Redis ACL with Username & Password. Outside docker - redis://app-user:mysecretpassword@localhost:6379/0 56 | # - REDIS_CONNSTR=redis://app-user:mysecretpassword@redis:6379/0 57 | - TURNSTILE_ENABLED=${TURNSTILE_ENABLED} 58 | - TURNSTILE_SITE_KEY=${TURNSTILE_SITE_KEY} 59 | - TURNSTILE_SECRET_KEY=${TURNSTILE_SECRET_KEY} 60 | networks: 61 | - redisnet 62 | - proxynet 63 | # ports: 64 | # - "8080:8080" 65 | expose: 66 | - 8080 67 | 68 | # secretnoteapp: 69 | # image: "vijeesh82/secretnote-app" 70 | # restart: unless-stopped 71 | # depends_on: 72 | # - redis 73 | # environment: 74 | # - REDIS_CONNSTR=redis://redis:6379/0 # Default Redis (No Auth or ACL). Outside docker - redis://localhost:6379/0 75 | # ############## Redis ACL ############## 76 | # # - REDIS_CONNSTR=redis://app-user:mysecretpassword@redis:6379/0 # Using ACL with Username & Password. Outside docker - redis://app-user:mysecretpassword@localhost:6379/0 77 | # ############## Redis ACL ############## 78 | # networks: 79 | # - redisnet 80 | # - proxynet 81 | # # ports: 82 | # # - "8085:8085" 83 | # expose: 84 | # - 8085 85 | 86 | caddy: 87 | image: caddy:2.10.0-alpine 88 | restart: unless-stopped 89 | ports: 90 | - "80:80" 91 | - "443:443" 92 | - "443:443/udp" 93 | depends_on: 94 | - app 95 | networks: 96 | - proxynet 97 | volumes: 98 | - ./Caddyfile.demohosting:/etc/caddy/Caddyfile 99 | - ./homepage:/var/www/homepage 100 | - ./site:/srv 101 | - caddy_data:/data 102 | - caddy_config:/config 103 | 104 | volumes: 105 | redis_data: 106 | caddy_data: 107 | caddy_config: 108 | 109 | networks: 110 | redisnet: 111 | name: redisnet 112 | proxynet: 113 | name: proxynet 114 | # external: true -------------------------------------------------------------------------------- /homepage/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 404 | QuickRetro 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 42 | 43 | 44 | 45 | 46 | 47 |
48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /src/frontend/src/components/Join.vue: -------------------------------------------------------------------------------- 1 | 47 | 48 | -------------------------------------------------------------------------------- /src/frontend/src/components/CategoryEditor.vue: -------------------------------------------------------------------------------- 1 | 74 | 75 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------