initial commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
.idea
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Meeting Roulette</title>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
||||
<script type="text/javascript" src="main.js"></script>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<!-- card related -->
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<meta property="og:title" content="Meeting Roulette" />
|
||||
<meta property="og:description" content="🎡 Spin your meetings" />
|
||||
<!-- <meta property="og:image" content="https://.../preview_640x320.jpg" /> -->
|
||||
<!-- <meta property="org:url" content="https://..." /> -->
|
||||
</head>
|
||||
<body>
|
||||
<main id="app" style="display: none">
|
||||
<div class="left panel">
|
||||
<h2>Meeting Roulette</h2>
|
||||
<br />
|
||||
<ul>
|
||||
<li>
|
||||
Remaining meeting time: <b>{{ timeText(totalRemainingTime) }}</b>
|
||||
</li>
|
||||
<li>Total meeting time: <b>{{ timeText(totalTime) }}</b></li>
|
||||
<li>
|
||||
<label for="wheighted">Wheighted topics</label> <input
|
||||
id="wheighted"
|
||||
v-model="wheighted"
|
||||
type="checkbox"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
<br />
|
||||
<div class="buttons">
|
||||
<button :disabled="!showSelected" @click="timerFunction">
|
||||
<span v-if="!timerStarted">Start timer</span>
|
||||
<span v-else>Stop timer</span></button
|
||||
>
|
||||
<button
|
||||
:disabled="!showSelected || timerStarted"
|
||||
@click="removeTopic"
|
||||
>
|
||||
Remove topic
|
||||
</button>
|
||||
</div>
|
||||
<h1 :id="rid">
|
||||
<span :style="overtime() ? 'color: #B71C1C' : ''">
|
||||
{{ 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
|
||||
viewBox="-1.05 -1.05 2.1 2.1"
|
||||
:style="`transform: rotate(${wheelPosition}deg)`"
|
||||
@click="spin"
|
||||
>
|
||||
<circle
|
||||
r="1"
|
||||
cx="0"
|
||||
cy="0"
|
||||
fill="#263238"
|
||||
stroke="#263238"
|
||||
stroke-width="0.04"
|
||||
/>
|
||||
<g v-for="d in svgData" :transform="d.transform">
|
||||
<path :d="d.path" :fill="d.color" />
|
||||
<text
|
||||
:x="d.textPosition"
|
||||
y="0"
|
||||
style="font: bold 1px sans-serif; text-align: center"
|
||||
dominant-baseline="middle"
|
||||
text-anchor="end"
|
||||
fill="white"
|
||||
:transform="d.textTransform"
|
||||
>
|
||||
{{ d.text }}
|
||||
</text>
|
||||
</g>
|
||||
<circle
|
||||
r="10%"
|
||||
cx="0"
|
||||
cy="0"
|
||||
fill="#cfd8dc"
|
||||
stroke="#263238"
|
||||
stroke-width="0.02"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="right panel">
|
||||
<textarea v-model="rawData"></textarea>
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,192 @@
|
||||
/* exported utils */
|
||||
|
||||
let app = {
|
||||
data() {
|
||||
return {
|
||||
rawData:
|
||||
"Topic 1: 60min\nTopic 2: 20min\nTopic 3: 50min\nTopic 4: 20min\nTopic 5:10min\nTopic 6: 10min\nTopic 7: 10min",
|
||||
data: [],
|
||||
wheelPosition: 0,
|
||||
wheighted: false,
|
||||
timeoutId: 0,
|
||||
showSelected: false,
|
||||
selected: 0,
|
||||
timerEnd: new Date(),
|
||||
timerStarted: false,
|
||||
initialSpin: true,
|
||||
initialColor: Math.random() * 360,
|
||||
rid: 0,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
rawData() {
|
||||
this.data = this.getData();
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
selectedData() {
|
||||
return (
|
||||
this.data[this.selected] ?? {
|
||||
text: "???",
|
||||
time: 1,
|
||||
disabled: false,
|
||||
}
|
||||
);
|
||||
},
|
||||
filteredData() {
|
||||
return this.data.filter((item) => !item.disabled);
|
||||
},
|
||||
totalTime() {
|
||||
return this.data.map((item) => item.time).reduce((a, b) => a + b, 0);
|
||||
},
|
||||
totalRemainingTime() {
|
||||
return this.filteredData
|
||||
.map((item) => item.time)
|
||||
.reduce((a, b) => a + b, 0);
|
||||
},
|
||||
svgData() {
|
||||
let totalAngle = 0;
|
||||
return this.filteredData.map((item, index) => {
|
||||
const ratio = this.wheighted
|
||||
? item.time / this.totalRemainingTime
|
||||
: 1 / this.filteredData.length;
|
||||
const textScale = item.text.length * 1.1;
|
||||
const angleRad = 2 * Math.PI * ratio;
|
||||
const angleDeg = 360 * ratio;
|
||||
totalAngle += angleDeg;
|
||||
return {
|
||||
id: item.id,
|
||||
text: item.text,
|
||||
textPosition: textScale * 0.95,
|
||||
textTransform: `scale(${1 / textScale})`,
|
||||
path: `M 0 0 L ${Math.cos(-angleRad / 2)} ${Math.sin(
|
||||
-angleRad / 2
|
||||
)} A 1 1 0 ${ratio > 0.5 ? 1 : 0} 1 ${Math.cos(
|
||||
angleRad / 2
|
||||
)} ${Math.sin(angleRad / 2)} z`,
|
||||
transform: `rotate(${totalAngle - angleDeg / 2}, 0, 0)`,
|
||||
color: `hsl(${this.initialColor + 100 * index} 80% 50%)`,
|
||||
from: totalAngle - angleDeg,
|
||||
to: totalAngle,
|
||||
};
|
||||
});
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
overtime() {
|
||||
return this.timerStarted && this.timerEnd - new Date() <= 0;
|
||||
},
|
||||
timerText() {
|
||||
const delta = this.timerStarted
|
||||
? Math.floor(Math.abs(this.timerEnd - new Date()) / 1000)
|
||||
: this.showSelected
|
||||
? this.selectedData.time * 60
|
||||
: 0;
|
||||
return `${String(Math.floor(delta / 60)).padStart(2, "0")}:${String(
|
||||
delta % 60
|
||||
).padStart(2, "0")}`;
|
||||
},
|
||||
timeText(minutes) {
|
||||
if (minutes > 60) {
|
||||
return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(
|
||||
2,
|
||||
"0"
|
||||
)}`;
|
||||
} else {
|
||||
return `${String(minutes % 60).padStart(2, "0")}min`;
|
||||
}
|
||||
},
|
||||
spin() {
|
||||
if (this.timerStarted) return;
|
||||
this.showSelected = false;
|
||||
this.initialSpin = false;
|
||||
this.wheelPosition += 360 * 10 + Math.random() * 360;
|
||||
clearTimeout(this.timeoutId);
|
||||
this.selected = this.getSelected();
|
||||
this.timeoutId = setTimeout(() => {
|
||||
this.showSelected = true;
|
||||
}, 5000);
|
||||
},
|
||||
getSelected() {
|
||||
const angle = 360 - (this.wheelPosition % 360);
|
||||
for (let index = 0; index < this.data.length; index++) {
|
||||
const element = this.svgData[index];
|
||||
if (angle >= element.from && angle < element.to) {
|
||||
return element.id;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
getData() {
|
||||
const re = /:\s?(?:(?:(\d+)\s?h)?(\d+)?(?:m(?:in)?)?)\s?$/i;
|
||||
return this.rawData
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length)
|
||||
.map((line, index) => {
|
||||
const result = re.exec(line);
|
||||
if (result === null) {
|
||||
return {
|
||||
id: index,
|
||||
text: line,
|
||||
time: 1,
|
||||
disabled: line.substring(0, 1) === "-",
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
id: index,
|
||||
text: line.substring(0, line.indexOf(result[0])),
|
||||
time: parseInt(result[1] ?? 0) * 60 + parseInt(result[2] ?? 0),
|
||||
disabled: line.substring(0, 1) === "-",
|
||||
};
|
||||
}
|
||||
});
|
||||
},
|
||||
showApp() {
|
||||
document.getElementById("app").setAttribute("style", "");
|
||||
},
|
||||
removeTopic() {
|
||||
let i = 0;
|
||||
this.rawData = this.rawData
|
||||
.split("\n")
|
||||
.map((line) => {
|
||||
if (line.trim().length) {
|
||||
if (i === this.selected) {
|
||||
line = "-" + line;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
return line;
|
||||
})
|
||||
.join("\n");
|
||||
this.showSelected = false;
|
||||
this.initialSpin = true;
|
||||
},
|
||||
timerFunction() {
|
||||
this.timerStarted = !this.timerStarted;
|
||||
if (this.timerStarted) {
|
||||
this.timerEnd = new Date(
|
||||
new Date().getTime() + this.selectedData.time * 60 * 1000
|
||||
);
|
||||
} else {
|
||||
document.title = "Meeting Roulette";
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted: function () {
|
||||
console.log("app mounted");
|
||||
this.data = this.getData();
|
||||
setTimeout(this.showApp);
|
||||
setInterval(() => {
|
||||
this.rid = Math.random();
|
||||
if (this.timerStarted) {
|
||||
document.title = this.timerText();
|
||||
}
|
||||
}, 200);
|
||||
},
|
||||
};
|
||||
|
||||
window.onload = () => {
|
||||
app = Vue.createApp(app);
|
||||
app.mount("#app");
|
||||
};
|
||||
@@ -0,0 +1,172 @@
|
||||
/*
|
||||
=================================================
|
||||
https://www.joshwcomeau.com/css/custom-css-reset/
|
||||
=================================================
|
||||
*/
|
||||
|
||||
/*
|
||||
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: 1em;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.panel.middle h2 {
|
||||
position: absolute;
|
||||
bottom: 1em;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.wheel {
|
||||
position: relative;
|
||||
height: 80%;
|
||||
width: fit-content;
|
||||
margin: 10% auto;
|
||||
}
|
||||
|
||||
.wheel svg {
|
||||
transition: transform 5s ease-out;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
svg text {
|
||||
pointer-events: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.wheel::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 94%;
|
||||
top: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-right: 6vh solid #263238;
|
||||
border-bottom: 2vh solid transparent;
|
||||
border-top: 2vh solid transparent;
|
||||
transform: translateY(-50%);
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user