This commit is contained in:
Klemek
2025-02-21 19:38:58 +01:00
parent 5ffdf5675a
commit 8248cbb56f
3 changed files with 354 additions and 258 deletions
+227 -59
View File
@@ -7,6 +7,17 @@
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script> <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
<script type="text/javascript" src="main.js"></script> <script type="text/javascript" src="main.js"></script>
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link
href="https://cdn.jsdelivr.net/npm/daisyui@5.0.0-beta.6/daisyui.css"
rel="stylesheet"
type="text/css"
/>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
<link
href="https://cdn.jsdelivr.net/npm/daisyui@5.0.0-beta.6/themes.css"
rel="stylesheet"
type="text/css"
/>
<!-- card related --> <!-- card related -->
<meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:card" content="summary_large_image" />
<meta property="og:title" content="Meeting Roulette" /> <meta property="og:title" content="Meeting Roulette" />
@@ -14,62 +25,180 @@
<!-- <meta property="og:image" content="https://.../preview_640x320.jpg" /> --> <!-- <meta property="og:image" content="https://.../preview_640x320.jpg" /> -->
<!-- <meta property="org:url" content="https://..." /> --> <!-- <meta property="org:url" content="https://..." /> -->
</head> </head>
<body> <body class="w-full h-screen max-h-screen">
<main id="app" style="display: none"> <main
<div class="left panel"> id="app"
<h2>Meeting Roulette</h2> style="display: none"
<br /> class="flex flex-row h-screen w-full gap-6 p-6 max-h-screen"
<ul> >
<li> <div class="flex flex-col h-full gap-6 min-w-64">
Meeting started at:&nbsp;<b :id="rid + 1">{{ timeText(startedAt, 2) }}</b> <div class="p-3 bg-base-200 rounded-box">
</li> <div class="font-title font-black leading-none text-2xl">
<li> Meeting Roulette
Meeting duration so far:&nbsp;<b :id="rid + 2">{{ timeText(elapsedTime) }}</b> </div>
</li> <p class="font-title font-light text-2xl">🎡 Spin your meetings</p>
<li> </div>
Remaining meeting time:&nbsp;<b :id="rid + 3">{{ timeText(totalRemainingTime) }}</b> <div
</li> class="p-3 rounded-box text-center"
<li> :class="overtime() ? 'text-error bg-error-content' : 'bg-base-200'"
End estimated at:&nbsp;<b :id="rid + 4">{{ timeText(estimatedEnd, 2) }}</b> >
</li> <span class="countdown font-mono text-5xl">
<li> {{ timerParts(0) }}
Total meeting time:&nbsp;<b :id="rid + 5">{{ timeText(totalTime) }}</b> <span :style="`--value:${timerParts(1)};`"
</li> >{{ timerParts(1) }}</span
<li> >
<label for="weighted">Weighted topics:</label>&nbsp;<input :
id="weighted" <span :style="`--value:${timerParts(2)};`"
v-model="weighted" >{{ timerParts(2) }}</span
type="checkbox" >
:
<span :style="`--value: ${timerParts(3)};`"
>{{ timerParts(3) }}</span
>
</span>
</div>
<div class="flex flex-row gap-6">
<button
class="btn btn-xl btn-success flex-1"
:disabled="!showSelected || timerStarted"
title="Start timer"
@click="timerFunction"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z"
/>
</svg>
</button>
<button
class="btn btn-xl btn-warning flex-1"
:disabled="!showSelected || !timerStarted"
title="Stop timer"
@click="timerFunction"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M5.25 7.5A2.25 2.25 0 0 1 7.5 5.25h9a2.25 2.25 0 0 1 2.25 2.25v9a2.25 2.25 0 0 1-2.25 2.25h-9a2.25 2.25 0 0 1-2.25-2.25v-9Z"
/>
</svg>
</button>
<button
class="btn btn-xl btn-error flex-1"
:disabled="!showSelected || timerStarted"
title="Remove topic"
@click="removeTopic"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
/>
</svg>
</button>
</div>
<div class="p-3 bg-base-200 rounded-box stats stats-vertical">
<div class="stat">
<div class="stat-title">Meeting duration so far</div>
<div class="stat-value" :id="rid + 1">
{{ timeText(elapsedTime) }}
</div>
<div class="stat-desc" :id="rid + 2">
Started at: <b>{{ timeText(startedAt, 2) }}</b>
</div>
</div>
<div class="stat">
<div class="stat-title">Remaining meeting time</div>
<div class="stat-value" :id="rid + 3">
{{ timeText(totalRemainingTime) }}
</div>
<div class="stat-desc" :id="rid + 4">
End estimated at: <b>{{ timeText(estimatedEnd, 2) }}</b>
</div>
</div>
<div class="stat">
<div class="stat-title">Total meeting time</div>
<div class="stat-value" :id="rid + 4">
{{ timeText(totalTime) }}
</div>
<div class="stat-desc">
Overtime: <b>{{ timeText(overtimeTime) }}</b>
</div>
</div>
</div>
<div class="grow"></div>
<div class="p-3 bg-base-200 rounded-box">
<div class="dropdown dropdown-top w-full">
<div tabindex="0" role="button" class="btn m-1 w-full flex">
<div class="grow text-left">Theme</div>
<svg
width="12px"
height="12px"
class="inline-block h-2 w-2 fill-current opacity-60"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 2048 2048"
>
<path
d="M1799 349l242 241-1017 1017L7 590l242-241 775 775 775-775z"
></path>
</svg>
</div>
<ul
tabindex="0"
class="dropdown-content max-h-100 overflow-scroll bg-base-300 rounded-box z-1 w-52 p-2 shadow-2xl"
>
<li v-for="theme in themes">
<input
type="radio"
name="theme-dropdown"
class="theme-controller btn btn-sm btn-block btn-ghost justify-start"
:aria-label="theme"
:value="theme"
:checked="theme === currentTheme"
@click="setTheme(theme)"
/> />
</li> </li>
</ul> </ul>
<br /> </div>
<div class="buttons"> </div>
<button :disabled="!showSelected" @click="timerFunction"> </div>
<span v-if="!timerStarted">Start timer</span> <div class="flex flex-col grow h-full gap-6">
<span v-else>Stop timer</span></button <div
>&nbsp; class="bg-base-200 rounded-box p-3 text-center font-black text-3xl"
<button
:disabled="!showSelected || timerStarted"
@click="removeTopic"
> >
Remove topic Current topic: {{ showSelected ? selectedData.text : '???' }}
</button>
</div> </div>
<h1 :id="rid"> <div class="grow rounded-box p-3 overflow-hidden justify-center">
<span :style="overtime() ? 'color: #B71C1C' : ''"> <div class="wheel relative h-full aspect-square m-auto">
{{ timerText() }}
</span>
</h1>
</div>
<div class="middle panel">
<h1>Current topic: {{ showSelected ? selectedData.text : '???' }}</h1>
<h2 v-if="(showSelected || initialSpin) && !timerStarted">
click to spin
</h2>
<div class="wheel">
<svg <svg
viewBox="-1.05 -1.05 2.1 2.1" viewBox="-1.05 -1.05 2.1 2.1"
class="h-full"
:class="timerStarted ? 'cursor-not-allowed' : 'cursor-pointer'"
:style="`transform: rotate(${wheelPosition}deg)`" :style="`transform: rotate(${wheelPosition}deg)`"
@click="spin" @click="spin"
> >
@@ -77,12 +206,11 @@
r="1" r="1"
cx="0" cx="0"
cy="0" cy="0"
fill="#263238" stroke-width="0.02"
stroke="#263238" style="stroke: var(--wheel); fill: var(--wheel)"
stroke-width="0.04"
/> />
<g v-for="d in svgData" :transform="d.transform"> <g v-for="d in svgData" :transform="d.transform">
<path :d="d.path" :fill="d.color" /> <path :d="d.path" :style="d.backgroundStyle" />
<text <text
:x="d.textPosition" :x="d.textPosition"
y="0" y="0"
@@ -93,7 +221,7 @@
" "
dominant-baseline="middle" dominant-baseline="middle"
text-anchor="end" text-anchor="end"
fill="white" :style="d.textStyle"
:transform="d.textTransform" :transform="d.textTransform"
> >
{{ d.text }} {{ d.text }}
@@ -103,15 +231,55 @@
r="10%" r="10%"
cx="0" cx="0"
cy="0" cy="0"
fill="#cfd8dc" style="stroke: var(--wheel); fill: var(--color-base-100)"
stroke="#263238" stroke-width="0.01"
stroke-width="0.02"
/> />
</svg> </svg>
</div> </div>
</div> </div>
<div class="right panel"> <div class="bg-base-200 rounded-box p-3 text-center">
<textarea v-model="rawData"></textarea> <div v-if="timerStarted">Discuss the current topic</div>
<div v-else-if="initialSpin" class="animate-pulse">
Enter your <b>topics</b> then click the wheel to <b>spin</b> !
</div>
<div v-else-if="showSelected" class="animate-pulse">
Start <b>timer</b> or click the wheel to <b>spin</b>
</div>
<div v-else>&nbsp;</div>
</div>
</div>
<div class="flex flex-col min-w-64 h-full gap-6">
<textarea
v-model="rawData"
class="grow bg-base-200 textarea rounded-box resize-none"
placeholder="Meeting point 1: 5min"
></textarea>
<div class="p-3 bg-base-200 rounded-box">
<fieldset class="fieldset">
<label class="fieldset-label">
<input
type="checkbox"
id="weighted"
v-model="weighted"
class="toggle"
/>
Weighted topics
</label>
</fieldset>
</div>
<div class="bg-base-200 rounded-box p-3">
<a href="https://github.com/Klemek" target="_blank" class="link"
>@klemek</a
>
-
<a
href="https://github.com/Klemek/meeting-roulette"
target="_blank"
class="link"
>Github</a
>
- 2025
</div>
</div> </div>
</main> </main>
</body> </body>
+85 -19
View File
@@ -1,3 +1,37 @@
const DAISYUI_THEMES = [
"light",
"dark",
"cupcake",
"bumblebee",
"emerald",
"corporate",
"synthwave",
"retro",
"cyberpunk",
"valentine",
"halloween",
"garden",
"forest",
"aqua",
"pastel",
"fantasy",
"luxury",
"dracula",
"cmyk",
"autumn",
"business",
"acid",
"lemonade",
"night",
"coffee",
"winter",
"dim",
"nord",
"sunset",
"caramellatte",
"abyss",
];
let app = { let app = {
data() { data() {
return { return {
@@ -15,10 +49,12 @@ let app = {
meetingStart: new Date(), meetingStart: new Date(),
elapsedTime: 0, elapsedTime: 0,
initialSpin: true, initialSpin: true,
initialColor: Math.random() * 360, initialColor: Math.floor(Math.random() * 4),
rid: 0, rid: 0,
beepTimer: undefined, beepTimer: undefined,
sound: undefined, sound: undefined,
themes: DAISYUI_THEMES,
currentTheme: "light",
}; };
}, },
watch: { watch: {
@@ -43,9 +79,7 @@ let app = {
return this.elapsedTime + this.totalRemainingTime; return this.elapsedTime + this.totalRemainingTime;
}, },
totalExpectedTime() { totalExpectedTime() {
return this.data return this.data.map((item) => item.time).reduce((a, b) => a + b, 0);
.map((item) => item.time)
.reduce((a, b) => a + b, 0);
}, },
totalRemainingTime() { totalRemainingTime() {
return this.filteredData return this.filteredData
@@ -62,7 +96,9 @@ let app = {
estimatedEnd() { estimatedEnd() {
const end = new Date(this.meetingStart.getTime()); const end = new Date(this.meetingStart.getTime());
const timerDelta = (this.timerEnd - this.date) / (1000 * 60); const timerDelta = (this.timerEnd - this.date) / (1000 * 60);
end.setMinutes(end.getMinutes() + this.totalTime - (this.timerStarted ? timerDelta : 0)); end.setMinutes(
end.getMinutes() + this.totalTime - (this.timerStarted ? timerDelta : 0)
);
return end.getHours() * 60 + end.getMinutes(); return end.getHours() * 60 + end.getMinutes();
}, },
svgData() { svgData() {
@@ -75,6 +111,12 @@ let app = {
const angleDeg = (360 * ratio) % 360; const angleDeg = (360 * ratio) % 360;
const textScale = this.textScale(item.text, angleRad); const textScale = this.textScale(item.text, angleRad);
totalAngle += angleDeg; totalAngle += angleDeg;
const colorIndex =
((index == this.filteredData.length - 1 && index % 4 == 0 ? 1 : 0) +
index +
this.initialColor) %
4;
const color = ["primary", "secondary", "accent", "neutral"][colorIndex];
return { return {
id: item.id, id: item.id,
text: item.text, text: item.text,
@@ -86,7 +128,8 @@ let app = {
angleRad / 2 angleRad / 2
)} ${Math.sin(angleRad / 2)} z`, )} ${Math.sin(angleRad / 2)} z`,
transform: `rotate(${totalAngle - angleDeg / 2}, 0, 0)`, transform: `rotate(${totalAngle - angleDeg / 2}, 0, 0)`,
color: `hsl(${this.initialColor + 100 * index} 80% 50%)`, textStyle: `fill: var(--color-${color}-content)`,
backgroundStyle: `fill: var(--color-${color})`,
from: totalAngle - angleDeg, from: totalAngle - angleDeg,
to: totalAngle, to: totalAngle,
}; };
@@ -103,22 +146,36 @@ let app = {
overtime() { overtime() {
return this.timerStarted && this.timerEnd - new Date() <= 0; return this.timerStarted && this.timerEnd - new Date() <= 0;
}, },
timerText() { timerParts(i) {
const delta = this.timerStarted const delta = this.timerStarted
? Math.floor((this.timerEnd - new Date()) / 1000) ? Math.floor((this.timerEnd - new Date()) / 1000)
: (this.showSelected : this.showSelected
? this.selectedData.time * 60 ? this.selectedData.time * 60
: 0); : 0;
return `${delta < 0 ? "-" : ""}${String( if (i == 0) {
Math.floor(Math.abs(delta) / 60) return delta < 0 ? "-" : "";
).padStart(2, "0")}:${String(Math.abs(delta) % 60).padStart(2, "0")}`; }
const hours = Math.floor(Math.abs(delta) / 3600);
if (i == 1) {
return String(hours).padStart(2, "0");
}
const minutes = Math.floor(Math.abs(delta) / 60 - hours * 60);
if (i == 2) {
return String(minutes).padStart(2, "0");
}
const seconds = Math.abs(delta) % 60;
return String(seconds).padStart(2, "0");
}, },
beep() { beep() {
this.sound.play(); this.sound.play();
}, },
timeText(minutes, padHours = 0) { timeText(minutes, padHours = 0) {
if (minutes >= 60 || padHours > 0) { if (minutes >= 60 || padHours > 0) {
return `${Math.floor(minutes / 60).toFixed(0).padStart(padHours, "0")}h${(minutes % 60).toFixed(0).padStart(2, "0")}`; return `${Math.floor(minutes / 60)
.toFixed(0)
.padStart(padHours, "0")}h${(minutes % 60)
.toFixed(0)
.padStart(2, "0")}`;
} else { } else {
return `${(minutes % 60).toFixed(0).padStart(2, "0")}min`; return `${(minutes % 60).toFixed(0).padStart(2, "0")}min`;
} }
@@ -173,12 +230,14 @@ let app = {
} }
}); });
if (data.length === 0) { if (data.length === 0) {
return [{ return [
{
id: 0, id: 0,
text: '?', text: "?",
time: 1, time: 1,
disabled: false, disabled: false,
}]; },
];
} }
return data; return data;
}, },
@@ -217,7 +276,7 @@ let app = {
}, },
setCookie(cname, cvalue, exdays) { setCookie(cname, cvalue, exdays) {
const d = new Date(); const d = new Date();
d.setTime(d.getTime() + (exdays * 24 * 60 * 60 * 1000)); d.setTime(d.getTime() + exdays * 24 * 60 * 60 * 1000);
let expires = "expires=" + d.toUTCString(); let expires = "expires=" + d.toUTCString();
console.log(cname + "=" + cvalue + "; path=/; " + expires); console.log(cname + "=" + cvalue + "; path=/; " + expires);
document.cookie = cname + "=" + cvalue + "; path=/; " + expires; document.cookie = cname + "=" + cvalue + "; path=/; " + expires;
@@ -225,7 +284,7 @@ let app = {
getCookie(cname, defaultValue) { getCookie(cname, defaultValue) {
let name = cname + "="; let name = cname + "=";
let decodedCookie = decodeURIComponent(document.cookie); let decodedCookie = decodeURIComponent(document.cookie);
let ca = decodedCookie.split(';'); let ca = decodedCookie.split(";");
for (let i = 0; i < ca.length; i++) { for (let i = 0; i < ca.length; i++) {
let c = ca[i]; let c = ca[i];
while (c.charAt(0) == " ") { while (c.charAt(0) == " ") {
@@ -237,17 +296,24 @@ let app = {
} }
return defaultValue; return defaultValue;
}, },
setTheme(value) {
this.currentTheme = value;
this.setCookie("theme", this.currentTheme);
},
}, },
mounted: function () { mounted: function () {
console.log("app mounted"); console.log("app mounted");
this.sound = new Audio("./sound.wav"); this.sound = new Audio("./sound.wav");
this.rawData = atob(this.getCookie("rawData", btoa(this.rawData))); this.rawData = atob(this.getCookie("rawData", btoa(this.rawData)));
this.currentTheme = this.getCookie("theme", "light");
this.data = this.getData(); this.data = this.getData();
setTimeout(this.showApp); setTimeout(this.showApp);
setInterval(() => { setInterval(() => {
this.rid = Math.random(); this.rid = Math.random();
if (this.timerStarted) { if (this.timerStarted) {
document.title = this.timerText(); document.title = `${this.timerParts(0)}${this.timerParts(
1
)}:${this.timerParts(2)}:${this.timerParts(3)}`;
} }
this.elapsedTime = (new Date() - this.meetingStart) / (1000 * 60); this.elapsedTime = (new Date() - this.meetingStart) / (1000 * 60);
this.date = new Date(); this.date = new Date();
+8 -146
View File
@@ -1,141 +1,13 @@
/* :root {
================================================= --wheel: color-mix(
https://www.joshwcomeau.com/css/custom-css-reset/ in oklch,
================================================= var(--color-base-content) 30%,
*/ var(--color-base-100)
);
/*
1. Use a more-intuitive box-sizing model.
*/
*,
*::before,
*::after {
box-sizing: border-box;
}
/*
2. Remove default margin
*/
* {
margin: 0;
}
/*
3. Allow percentage-based heights in the application
*/
html,
body {
height: 100%;
}
/*
Typographic tweaks!
4. Add accessible line-height
5. Improve text rendering
*/
body {
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
/*
6. Improve media defaults
*/
img,
picture,
video,
canvas,
svg {
display: block;
max-width: 100%;
}
/*
7. Remove built-in form typography styles
*/
input,
button,
textarea,
select {
font: inherit;
}
/*
8. Avoid text overflows
*/
p,
h1,
h2,
h3,
h4,
h5,
h6 {
overflow-wrap: break-word;
}
/*
9. Create a root stacking context
*/
#root,
#__next {
isolation: isolate;
}
/*
=================================================
CUSTOM STYLE
=================================================
*/
#app {
width: 100vw;
height: 100vh;
overflow: hidden;
position: relative;
display: flex;
flex-direction: row;
justify-content: center;
align-items: stretch;
background-color: #eceff1;
color: #263238;
font-family: sans-serif;
}
.panel {
flex-grow: 2;
position: relative;
}
.panel.right,
.panel.left {
background-color: #cfd8dc;
margin: 1em;
border-radius: 2em;
padding: 1em;
}
.panel.middle {
height: 100%;
flex-grow: 1;
}
.panel.middle h1 {
position: absolute;
top: 0;
width: 100%;
text-align: center;
}
.panel.middle h2 {
position: absolute;
bottom: 0;
width: 100%;
text-align: center;
}
.wheel {
position: relative;
height: 80%;
width: fit-content;
margin: 10% auto;
} }
.wheel svg { .wheel svg {
transition: transform 5s ease-out; transition: transform 5s ease-out;
height: 100%;
} }
svg text { svg text {
@@ -153,20 +25,10 @@ svg text {
top: 50%; top: 50%;
width: 0; width: 0;
height: 0; height: 0;
border-right: 6vh solid #263238; border-right: 6vh solid
color-mix(in oklch, var(--color-base-content) 40%, var(--color-base-100));
border-bottom: 2vh solid transparent; border-bottom: 2vh solid transparent;
border-top: 2vh solid transparent; border-top: 2vh solid transparent;
transform: translateY(-50%); transform: translateY(-50%);
clear: both; clear: both;
} }
.panel.right textarea {
background-color: #cfd8dc;
color: #263238;
font-family: sans-serif;
padding: 0.5em;
border: 1px solid #263238;
height: 100%;
width: 100%;
border-radius: 1em;
}