dylanebert HF staff commited on
Commit
c99cc8d
1 Parent(s): 99c8110

vote layout

Browse files

dual viewers

loading bars

loading bar max width

voting title

fetch pairs

record votes (fixed username)

more robust error handling

display score, votes

add oauth

README.md CHANGED
@@ -7,6 +7,8 @@ sdk: docker
7
  pinned: false
8
  license: mit
9
  app_port: 3000
 
 
10
  ---
11
 
12
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
7
  pinned: false
8
  license: mit
9
  app_port: 3000
10
+ hf_oauth: true
11
+ hf_oauth_expiration_minutes: 480
12
  ---
13
 
14
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
src/routes/+page.svelte CHANGED
@@ -2,6 +2,7 @@
2
  import Leaderboard from "./Leaderboard.svelte";
3
  import ModelDetails from "./ModelDetails.svelte";
4
  import Viewer from "./Viewer.svelte";
 
5
 
6
  interface Scene {
7
  name: string;
@@ -9,7 +10,7 @@
9
  thumbnail: string;
10
  }
11
 
12
- let currentView: "Leaderboard" | "Vote" | "ModelDetails" | "Viewer" = "Leaderboard";
13
  let selectedEntry: { name: string; path: string } | null = null;
14
  let selectedScene: Scene | null = null;
15
 
@@ -46,9 +47,7 @@
46
  {#if currentView === "Leaderboard"}
47
  <Leaderboard onEntryClick={showModelDetails} />
48
  {:else if currentView === "Vote"}
49
- <div class="loading-container">
50
- <div class="loading-text">Coming Soon</div>
51
- </div>
52
  {:else if currentView === "ModelDetails" && selectedEntry}
53
  <ModelDetails
54
  modelName={selectedEntry.name}
 
2
  import Leaderboard from "./Leaderboard.svelte";
3
  import ModelDetails from "./ModelDetails.svelte";
4
  import Viewer from "./Viewer.svelte";
5
+ import Vote from "./Vote.svelte";
6
 
7
  interface Scene {
8
  name: string;
 
10
  thumbnail: string;
11
  }
12
 
13
+ let currentView: "Leaderboard" | "Vote" | "ModelDetails" | "Viewer" = "Vote";
14
  let selectedEntry: { name: string; path: string } | null = null;
15
  let selectedScene: Scene | null = null;
16
 
 
47
  {#if currentView === "Leaderboard"}
48
  <Leaderboard onEntryClick={showModelDetails} />
49
  {:else if currentView === "Vote"}
50
+ <Vote />
 
 
51
  {:else if currentView === "ModelDetails" && selectedEntry}
52
  <ModelDetails
53
  modelName={selectedEntry.name}
src/routes/Leaderboard.svelte CHANGED
@@ -4,35 +4,29 @@
4
 
5
  interface Entry {
6
  name: string;
7
- path: string;
8
- thumbnail: string;
 
9
  }
10
 
11
  export let onEntryClick: (entry: Entry) => void;
12
 
 
13
  let leaderboard: Entry[] = [];
14
 
15
  const fetchLeaderboardData = async () => {
16
- const baseUrl = "https://huggingface.co";
17
- const repoId = "dylanebert/3d-arena";
18
- const url = `${baseUrl}/api/datasets/${repoId}`;
19
- const response = await fetch(url);
20
- const data = await response.json();
21
-
22
- const entries: Entry[] = [];
23
-
24
- for (const item of data.siblings) {
25
- const filename = item.rfilename;
26
- if (!filename.endsWith(".json")) continue;
27
-
28
- const directory = filename.split("/").slice(0, -1).join("/");
29
- const name = directory.split("/").pop();
30
- const thumbnail = `${baseUrl}/datasets/${repoId}/resolve/main/${directory}/thumbnail.png`;
31
-
32
- entries.push({ name, path: filename, thumbnail });
33
- }
34
-
35
- leaderboard = entries;
36
  };
37
 
38
  onMount(async () => {
@@ -42,11 +36,21 @@
42
 
43
  {#if leaderboard.length > 0}
44
  <div class="grid">
45
- {#each leaderboard as entry, index}
46
  <button class="grid-item" on:click={() => onEntryClick(entry)}>
47
- <img src={entry.thumbnail} alt={entry.name} class="thumbnail" />
48
- <div class="ranking">{index + 1}</div>
49
  <div class="title">{entry.name}</div>
 
 
 
 
 
 
 
 
 
 
50
  </button>
51
  {/each}
52
  </div>
 
4
 
5
  interface Entry {
6
  name: string;
7
+ rank: number;
8
+ score: number;
9
+ votes: number;
10
  }
11
 
12
  export let onEntryClick: (entry: Entry) => void;
13
 
14
+ const baseUrl = "https://huggingface.co/datasets/dylanebert/3d-arena/resolve/main/outputs";
15
  let leaderboard: Entry[] = [];
16
 
17
  const fetchLeaderboardData = async () => {
18
+ const url = "https://dylanebert-3d-arena-backend.hf.space/leaderboard";
19
+ const response = await fetch(url, {
20
+ method: "GET",
21
+ headers: {
22
+ Authorization: "Bearer " + import.meta.env.VITE_HF_TOKEN,
23
+ "Cache-Control": "no-cache",
24
+ },
25
+ });
26
+ const data = (await response.json()) as Entry[];
27
+ data.sort((a, b) => a.rank - b.rank);
28
+
29
+ leaderboard = data;
 
 
 
 
 
 
 
 
30
  };
31
 
32
  onMount(async () => {
 
36
 
37
  {#if leaderboard.length > 0}
38
  <div class="grid">
39
+ {#each leaderboard as entry}
40
  <button class="grid-item" on:click={() => onEntryClick(entry)}>
41
+ <img src={`${baseUrl}/${entry.name}/thumbnail.png`} alt={entry.name} class="thumbnail" />
42
+ <div class="ranking">{entry.rank}</div>
43
  <div class="title">{entry.name}</div>
44
+ <div class="score-container">
45
+ <div class="score">
46
+ <span class="label">Score:</span>
47
+ {entry.score}
48
+ </div>
49
+ <div class="votes">
50
+ <span class="label">Votes:</span>
51
+ {entry.votes}
52
+ </div>
53
+ </div>
54
  </button>
55
  {/each}
56
  </div>
src/routes/ModelDetails.svelte CHANGED
@@ -9,7 +9,6 @@
9
  }
10
 
11
  export let modelName: string;
12
- export let modelPath: string;
13
  export let onBack: () => void;
14
  export let onSceneClick: (scene: Scene) => void;
15
 
@@ -18,14 +17,12 @@
18
  async function fetchScenes() {
19
  scenes = [];
20
 
21
- const baseUrl = "https://huggingface.co";
22
- const repoId = "dylanebert/3d-arena";
23
- const url = `${baseUrl}/api/datasets/${repoId}`;
24
  const response = await fetch(url);
25
  const responseData = await response.json();
26
 
 
27
  const extensions = ["obj", "glb", "ply", "splat"];
28
- const directory = modelPath.split("/").slice(0, -1).join("/");
29
 
30
  scenes = responseData.siblings
31
  .filter((scene: any) => {
@@ -34,7 +31,7 @@
34
  })
35
  .reduce((acc: Scene[], scene: any) => {
36
  const name = scene.rfilename.split("/").pop().split(".").slice(0, -1).join(".");
37
- const url = `${baseUrl}/datasets/${repoId}/resolve/main/${scene.rfilename}`;
38
  const thumbnail = url.replace(/\.[^.]+$/, ".png");
39
  acc.push({ name, url, thumbnail });
40
  return acc;
 
9
  }
10
 
11
  export let modelName: string;
 
12
  export let onBack: () => void;
13
  export let onSceneClick: (scene: Scene) => void;
14
 
 
17
  async function fetchScenes() {
18
  scenes = [];
19
 
20
+ const url = `https://huggingface.co/api/datasets/dylanebert/3d-arena`;
 
 
21
  const response = await fetch(url);
22
  const responseData = await response.json();
23
 
24
+ const directory = `outputs/${modelName}`;
25
  const extensions = ["obj", "glb", "ply", "splat"];
 
26
 
27
  scenes = responseData.siblings
28
  .filter((scene: any) => {
 
31
  })
32
  .reduce((acc: Scene[], scene: any) => {
33
  const name = scene.rfilename.split("/").pop().split(".").slice(0, -1).join(".");
34
+ const url = `https://huggingface.co/datasets/dylanebert/3d-arena/resolve/main/${scene.rfilename}`;
35
  const thumbnail = url.replace(/\.[^.]+$/, ".png");
36
  acc.push({ name, url, thumbnail });
37
  return acc;
src/routes/Viewer.svelte CHANGED
@@ -16,22 +16,30 @@
16
 
17
  let container: HTMLDivElement;
18
  let canvas: HTMLCanvasElement;
 
 
19
 
20
  let viewer: IViewer;
21
 
22
  async function loadScene() {
23
- viewer = await createViewer(scene.url, canvas);
 
 
 
24
  window.addEventListener("resize", handleResize);
25
  window.addEventListener("keydown", handleKeyDown);
26
  handleResize();
 
27
  }
28
 
29
  function handleResize() {
30
  if (!canvas || !container) return;
31
- const maxWidth = container.clientHeight * (16 / 9);
32
- const maxHeight = container.clientWidth * (9 / 16);
33
- canvas.width = Math.min(container.clientWidth, maxWidth);
34
- canvas.height = Math.min(container.clientHeight, maxHeight);
 
 
35
  }
36
 
37
  function handleKeyDown(e: KeyboardEvent) {
@@ -56,8 +64,10 @@
56
 
57
  onDestroy(() => {
58
  viewer?.dispose();
59
- window.removeEventListener("resize", handleResize);
60
- window.removeEventListener("keydown", handleKeyDown);
 
 
61
  });
62
  </script>
63
 
@@ -74,5 +84,10 @@
74
  <div class="desktop-spacer" />
75
  </div>
76
  <div class="canvas-container" bind:this={container}>
 
 
 
 
 
77
  <canvas bind:this={canvas} width={800} height={600} />
78
  </div>
 
16
 
17
  let container: HTMLDivElement;
18
  let canvas: HTMLCanvasElement;
19
+ let overlay: HTMLDivElement;
20
+ let loadingBarFill: HTMLDivElement;
21
 
22
  let viewer: IViewer;
23
 
24
  async function loadScene() {
25
+ overlay.style.display = "flex";
26
+ viewer = await createViewer(scene.url, canvas, (progress) => {
27
+ loadingBarFill.style.width = `${progress * 100}%`;
28
+ });
29
  window.addEventListener("resize", handleResize);
30
  window.addEventListener("keydown", handleKeyDown);
31
  handleResize();
32
+ overlay.style.display = "none";
33
  }
34
 
35
  function handleResize() {
36
  if (!canvas || !container) return;
37
+ requestAnimationFrame(() => {
38
+ const maxWidth = container.clientHeight * (16 / 9);
39
+ const maxHeight = container.clientWidth * (9 / 16);
40
+ canvas.width = Math.min(container.clientWidth, maxWidth);
41
+ canvas.height = Math.min(container.clientHeight, maxHeight);
42
+ });
43
  }
44
 
45
  function handleKeyDown(e: KeyboardEvent) {
 
64
 
65
  onDestroy(() => {
66
  viewer?.dispose();
67
+ if (typeof window !== "undefined") {
68
+ window.removeEventListener("resize", handleResize);
69
+ window.removeEventListener("keydown", handleKeyDown);
70
+ }
71
  });
72
  </script>
73
 
 
84
  <div class="desktop-spacer" />
85
  </div>
86
  <div class="canvas-container" bind:this={container}>
87
+ <div bind:this={overlay} class="loading-overlay">
88
+ <div class="loading-bar">
89
+ <div bind:this={loadingBarFill} class="loading-bar-fill" />
90
+ </div>
91
+ </div>
92
  <canvas bind:this={canvas} width={800} height={600} />
93
  </div>
src/routes/Vote.svelte ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script lang="ts">
2
+ import { onMount, onDestroy } from "svelte";
3
+ import type { IViewer } from "./viewers/IViewer";
4
+ import { createViewer } from "./viewers/ViewerFactory";
5
+
6
+ interface Data {
7
+ input: string;
8
+ input_path: string;
9
+ model1: string;
10
+ model1_path: string;
11
+ model2: string;
12
+ model2_path: string;
13
+ }
14
+
15
+ let viewerA: IViewer;
16
+ let viewerB: IViewer;
17
+ let canvasA: HTMLCanvasElement;
18
+ let canvasB: HTMLCanvasElement;
19
+ let containerA: HTMLDivElement;
20
+ let containerB: HTMLDivElement;
21
+ let overlayA: HTMLDivElement;
22
+ let overlayB: HTMLDivElement;
23
+ let loadingBarFillA: HTMLDivElement;
24
+ let loadingBarFillB: HTMLDivElement;
25
+ let statusMessage: string = "Loading...";
26
+ let errorMessage: string = "";
27
+ let data: Data;
28
+
29
+ async function fetchScenes() {
30
+ statusMessage = "Loading...";
31
+ errorMessage = "";
32
+
33
+ try {
34
+ const username = "dylanebert";
35
+ const url = `https://dylanebert-3d-arena-backend.hf.space/pair?username=${username}`;
36
+ const response = await fetch(url, {
37
+ method: "GET",
38
+ headers: {
39
+ Authorization: "Bearer " + import.meta.env.VITE_HF_TOKEN,
40
+ "Cache-Control": "no-cache",
41
+ },
42
+ });
43
+ const result = await response.json();
44
+ if (result.input) {
45
+ data = result;
46
+ statusMessage = "";
47
+ return true;
48
+ } else {
49
+ statusMessage = "Voting complete.";
50
+ return false;
51
+ }
52
+ } catch (error) {
53
+ errorMessage = "Failed to fetch pair.";
54
+ statusMessage = "";
55
+ return false;
56
+ }
57
+ }
58
+
59
+ async function loadScenes() {
60
+ const success = await fetchScenes();
61
+ if (!success) return;
62
+
63
+ overlayA.style.display = "flex";
64
+ overlayB.style.display = "flex";
65
+
66
+ const baseUrl = "https://huggingface.co/datasets/dylanebert/3d-arena/resolve/main/";
67
+ const model1_path = `${baseUrl}${data.model1_path}`;
68
+ const model2_path = `${baseUrl}${data.model2_path}`;
69
+
70
+ try {
71
+ const promises = [
72
+ createViewer(model1_path, canvasA, (progress) => {
73
+ loadingBarFillA.style.width = `${progress * 100}%`;
74
+ }),
75
+ createViewer(model2_path, canvasB, (progress) => {
76
+ loadingBarFillB.style.width = `${progress * 100}%`;
77
+ }),
78
+ ];
79
+ await Promise.all(promises);
80
+
81
+ window.addEventListener("resize", handleResize);
82
+ handleResize();
83
+ } catch (error) {
84
+ errorMessage = "Failed to load scenes.";
85
+ }
86
+
87
+ overlayA.style.display = "none";
88
+ overlayB.style.display = "none";
89
+ }
90
+
91
+ async function vote(option: "A" | "B") {
92
+ statusMessage = "Processing vote...";
93
+ errorMessage = "";
94
+
95
+ const payload = {
96
+ username: "dylanebert",
97
+ input: data.input,
98
+ better: option == "A" ? data.model1 : data.model2,
99
+ worse: option == "A" ? data.model2 : data.model1,
100
+ };
101
+ const url = `https://dylanebert-3d-arena-backend.hf.space/vote`;
102
+
103
+ try {
104
+ const response = await fetch(url, {
105
+ method: "POST",
106
+ headers: {
107
+ Authorization: "Bearer " + import.meta.env.VITE_HF_TOKEN,
108
+ "Cache-Control": "no-cache",
109
+ "Content-Type": "application/json",
110
+ },
111
+ body: JSON.stringify(payload),
112
+ });
113
+
114
+ if (response.ok) {
115
+ const result = await response.json();
116
+ console.log(result);
117
+ loadScenes();
118
+ } else {
119
+ errorMessage = "Failed to process vote.";
120
+ }
121
+ } catch (error) {
122
+ errorMessage = "Failed to process vote.";
123
+ statusMessage = "";
124
+ }
125
+ }
126
+
127
+ function handleResize() {
128
+ requestAnimationFrame(() => {
129
+ if (canvasA && containerA) {
130
+ const maxWidth = containerA.clientHeight;
131
+ const maxHeight = containerA.clientWidth;
132
+ canvasA.width = Math.min(containerA.clientWidth, maxWidth);
133
+ canvasA.height = Math.min(containerA.clientHeight, maxHeight);
134
+ }
135
+ if (canvasB && containerB) {
136
+ const maxWidth = containerB.clientHeight;
137
+ const maxHeight = containerB.clientWidth;
138
+ canvasB.width = Math.min(containerB.clientWidth, maxWidth);
139
+ canvasB.height = Math.min(containerB.clientHeight, maxHeight);
140
+ }
141
+ });
142
+ }
143
+
144
+ onMount(loadScenes);
145
+
146
+ onDestroy(() => {
147
+ viewerA?.dispose();
148
+ viewerB?.dispose();
149
+ if (typeof window !== "undefined") {
150
+ window.removeEventListener("resize", handleResize);
151
+ }
152
+ });
153
+ </script>
154
+
155
+ {#if errorMessage}
156
+ <p class="center-title muted" style="color: red;">{errorMessage}</p>
157
+ {:else if statusMessage}
158
+ <p class="center-title muted">{statusMessage}</p>
159
+ {:else}
160
+ <h2 class="center-title">Which is better?</h2>
161
+ <div class="voting-container">
162
+ <div bind:this={containerA} class="voting-canvas-wrapper">
163
+ <div bind:this={overlayA} class="loading-overlay">
164
+ <div class="loading-bar">
165
+ <div bind:this={loadingBarFillA} class="loading-bar-fill" />
166
+ </div>
167
+ </div>
168
+ <canvas bind:this={canvasA} class="voting-canvas" id="canvas1"></canvas>
169
+ <button class="vote-button" on:click={() => vote("A")}>A is Better</button>
170
+ </div>
171
+ <div bind:this={containerB} class="voting-canvas-wrapper">
172
+ <div bind:this={overlayB} class="loading-overlay">
173
+ <div class="loading-bar">
174
+ <div bind:this={loadingBarFillB} class="loading-bar-fill" />
175
+ </div>
176
+ </div>
177
+ <canvas bind:this={canvasB} class="voting-canvas" id="canvas2"></canvas>
178
+ <button class="vote-button" on:click={() => vote("B")}>B is Better</button>
179
+ </div>
180
+ </div>
181
+ {/if}
src/routes/viewers/ViewerFactory.ts CHANGED
@@ -5,7 +5,11 @@ import { SplatViewer } from "./SplatViewer";
5
  const meshFormats = ["obj", "stl", "gltf", "glb"];
6
  const splatFormats = ["splat", "ply"];
7
 
8
- export async function createViewer(url: string, canvas: HTMLCanvasElement): Promise<IViewer> {
 
 
 
 
9
  let viewer: IViewer;
10
  if (meshFormats.some((format) => url.endsWith(format))) {
11
  viewer = new BabylonViewer(canvas);
@@ -14,6 +18,6 @@ export async function createViewer(url: string, canvas: HTMLCanvasElement): Prom
14
  } else {
15
  throw new Error("Unsupported file format");
16
  }
17
- await viewer.loadScene(url);
18
  return viewer;
19
  }
 
5
  const meshFormats = ["obj", "stl", "gltf", "glb"];
6
  const splatFormats = ["splat", "ply"];
7
 
8
+ export async function createViewer(
9
+ url: string,
10
+ canvas: HTMLCanvasElement,
11
+ onProgress: (progress: number) => void
12
+ ): Promise<IViewer> {
13
  let viewer: IViewer;
14
  if (meshFormats.some((format) => url.endsWith(format))) {
15
  viewer = new BabylonViewer(canvas);
 
18
  } else {
19
  throw new Error("Unsupported file format");
20
  }
21
+ await viewer.loadScene(url, onProgress);
22
  return viewer;
23
  }
static/global.css CHANGED
@@ -104,6 +104,46 @@ body {
104
  overflow: hidden;
105
  }
106
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  .header {
108
  display: flex;
109
  align-items: center;
@@ -232,6 +272,32 @@ body {
232
  background-color: #26272c;
233
  }
234
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
235
  .thumbnail {
236
  position: absolute;
237
  top: 50%;
@@ -248,6 +314,54 @@ body {
248
  transform: translate(-50%, -50%) scale(1.1);
249
  }
250
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
  .tabs {
252
  display: flex;
253
  justify-content: left;
 
104
  overflow: hidden;
105
  }
106
 
107
+ .loading-overlay {
108
+ position: absolute;
109
+ top: 0;
110
+ left: 0;
111
+ width: 100%;
112
+ height: 100%;
113
+ background-color: #1a1b1e;
114
+ display: flex;
115
+ flex-direction: column;
116
+ justify-content: center;
117
+ align-items: center;
118
+ z-index: 100;
119
+ gap: 10px;
120
+ }
121
+
122
+ .loading-overlay::before {
123
+ content: "Loading...";
124
+ color: white;
125
+ font-size: 16px;
126
+ }
127
+
128
+ .loading-bar {
129
+ position: relative;
130
+ width: 256px;
131
+ max-width: 62%;
132
+ height: 20px;
133
+ border: 2px solid #aaa;
134
+ background-color: #1a1b1e;
135
+ }
136
+
137
+ .loading-bar-fill {
138
+ position: absolute;
139
+ top: 0;
140
+ left: 0;
141
+ width: 0%;
142
+ height: 100%;
143
+ background-color: #555;
144
+ transition: width 0.2s ease;
145
+ }
146
+
147
  .header {
148
  display: flex;
149
  align-items: center;
 
272
  background-color: #26272c;
273
  }
274
 
275
+ .score-container {
276
+ position: absolute;
277
+ top: 0;
278
+ right: 0;
279
+ display: flex;
280
+ flex-direction: column;
281
+ align-items: flex-start;
282
+ padding: 10px;
283
+ box-sizing: border-box;
284
+ color: #aaa;
285
+ font-size: 12px;
286
+ }
287
+
288
+ .score,
289
+ .votes {
290
+ display: flex;
291
+ gap: 10px;
292
+ width: 100%;
293
+ }
294
+
295
+ .label {
296
+ font-weight: bold;
297
+ justify-content: flex-end;
298
+ width: 50px;
299
+ }
300
+
301
  .thumbnail {
302
  position: absolute;
303
  top: 50%;
 
314
  transform: translate(-50%, -50%) scale(1.1);
315
  }
316
 
317
+ .center-title {
318
+ text-align: center;
319
+ display: block;
320
+ margin: 0 auto;
321
+ padding: 10px;
322
+ }
323
+
324
+ .voting-container {
325
+ display: flex;
326
+ justify-content: space-around;
327
+ }
328
+
329
+ .voting-canvas-wrapper {
330
+ position: relative;
331
+ display: flex;
332
+ flex-direction: column;
333
+ align-items: center;
334
+ width: 100%;
335
+ max-width: 50%;
336
+ box-sizing: border-box;
337
+ padding: 10px;
338
+ }
339
+
340
+ .voting-canvas {
341
+ width: 100%;
342
+ height: auto;
343
+ aspect-ratio: 1 / 1;
344
+ }
345
+
346
+ .vote-button {
347
+ background-color: #333;
348
+ color: #fff;
349
+ border: 1px solid #444;
350
+ outline: none;
351
+ padding: 10px 10px 10px 10px;
352
+ font-family: "Roboto", sans-serif;
353
+ font-size: 14px;
354
+ font-weight: 600;
355
+ width: 100%;
356
+ height: 56px;
357
+ transition: background-color 0.2s ease;
358
+ }
359
+
360
+ .vote-button:hover {
361
+ background-color: #444;
362
+ cursor: pointer;
363
+ }
364
+
365
  .tabs {
366
  display: flex;
367
  justify-content: left;