feat(opus): add coef subcards

This commit is contained in:
Loïc Gremaud 2026-05-15 03:24:07 +02:00
parent 779393106d
commit f774ff3340
Signed by: Legrems
GPG Key ID: D4620E6DF3E0121D
4 changed files with 123 additions and 74 deletions

View File

@ -1,12 +1,12 @@
<template> <template>
<div <div class="card bg-base-100 shadow-lg hover:shadow-2xl transition-shadow duration-300"
class="card bg-base-100 shadow-lg hover:shadow-2xl transition-shadow duration-300" :class="responses?.length == 0 ? 'shadow-red-900' : 'shadow-primary-300'">
:class="responses?.length == 0 ? 'shadow-red-900' : 'shadow-primary-300'"
>
<div class="card-body"> <div class="card-body">
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div class="flex-1"> <div class="flex-1">
<h3 class="card-title text-lg font-bold">{{ puzzle.title }}</h3> <h3 class="card-title text-lg font-bold" :class="responses?.length == 0 ? 'text-error' : 'text-primary'">
{{ puzzle.title }}
</h3>
<p class="text-sm text-base-content/70 mb-2"> <p class="text-sm text-base-content/70 mb-2">
by {{ puzzle.author_name }} by {{ puzzle.author_name }}
</p> </p>
@ -18,28 +18,34 @@
<div class="badge badge-ghost badge-sm">ID: {{ puzzle.id }}</div> <div class="badge badge-ghost badge-sm">ID: {{ puzzle.id }}</div>
</div> </div>
<p <p v-if="puzzle.description" class="text-sm text-base-content/80 mb-4">
v-if="puzzle.description"
class="text-sm text-base-content/80 mb-4"
>
{{ puzzle.description }} {{ puzzle.description }}
</p> </p>
<div <!-- Points Factor Coefficients -->
v-if="puzzle.tags && puzzle.tags.length > 0" <div v-if="puzzle.points_factor" class="bg-base-200 p-3 rounded-lg mb-4">
class="flex flex-wrap gap-1 mb-4" <p class="text-xs text-base-content/70 font-semibold mb-2">Points Coefficients</p>
> <div class="grid grid-cols-3 gap-2">
<span <div class="text-center">
v-for="tag in puzzle.tags.slice(0, 3)" <span class="font-bold text-primary"><small>x</small>{{ puzzle.points_factor.cost }}</span>
:key="tag" <p class="text-xs text-base-content/70">Cost</p>
class="badge badge-outline badge-xs" </div>
> <div class="text-center">
<span class="font-bold text-primary"><small>x</small>{{ puzzle.points_factor.cycles }}</span>
<p class="text-xs text-base-content/70">Cycles</p>
</div>
<div class="text-center">
<span class="font-bold text-primary"><small>x</small>{{ puzzle.points_factor.area }}</span>
<p class="text-xs text-base-content/70">Area</p>
</div>
</div>
</div>
<div v-if="puzzle.tags && puzzle.tags.length > 0" class="flex flex-wrap gap-1 mb-4">
<span v-for="tag in puzzle.tags.slice(0, 3)" :key="tag" class="badge badge-outline badge-xs">
{{ tag }} {{ tag }}
</span> </span>
<span <span v-if="puzzle.tags.length > 3" class="badge badge-outline badge-xs">
v-if="puzzle.tags.length > 3"
class="badge badge-outline badge-xs"
>
+{{ puzzle.tags.length - 3 }} more +{{ puzzle.tags.length - 3 }} more
</span> </span>
</div> </div>
@ -47,11 +53,8 @@
<div class="flex flex-col items-end gap-2"> <div class="flex flex-col items-end gap-2">
<div class="tooltip" data-tip="View on Steam Workshop"> <div class="tooltip" data-tip="View on Steam Workshop">
<a <a :href="`https://steamcommunity.com/workshop/filedetails/?id=${puzzle.steam_item_id}`" target="_blank"
:href="`https://steamcommunity.com/workshop/filedetails/?id=${puzzle.steam_item_id}`" class="btn btn-ghost btn-sm btn-square">
target="_blank"
class="btn btn-ghost btn-sm btn-square"
>
<i class="mdi mdi-steam text-lg"></i> <i class="mdi mdi-steam text-lg"></i>
</a> </a>
</div> </div>
@ -59,11 +62,9 @@
</div> </div>
<!-- Responses Table --> <!-- Responses Table -->
<div v-if="responses && responses.length > 0" class="mt-6"> <div v-if="responses && responses.length > 0" class="mt-1">
<div class="divider"> <div class="divider">
<span class="text-sm font-medium" <span class="text-sm font-medium">Solutions ({{ responses.length }})</span>
>Solutions ({{ responses.length }})</span
>
</div> </div>
<div> <div>
@ -77,34 +78,21 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr <tr v-for="response in responses" :key="response.id" class="hover">
v-for="response in responses"
:key="response.id"
class="hover"
>
<td> <td>
<span <span v-if="response.final_cost || response.cost" class="badge badge-success badge-xs">
v-if="response.final_cost || response.cost"
class="badge badge-success badge-xs"
>
{{ response.final_cost || response.cost }} {{ response.final_cost || response.cost }}
</span> </span>
<span v-else class="text-base-content/50">-</span> <span v-else class="text-base-content/50">-</span>
</td> </td>
<td> <td>
<span <span v-if="response.final_cycles || response.cycles" class="badge badge-info badge-xs">
v-if="response.final_cycles || response.cycles"
class="badge badge-info badge-xs"
>
{{ response.final_cycles || response.cycles }} {{ response.final_cycles || response.cycles }}
</span> </span>
<span v-else class="text-base-content/50">-</span> <span v-else class="text-base-content/50">-</span>
</td> </td>
<td> <td>
<span <span v-if="response.final_area || response.area" class="badge badge-warning badge-xs">
v-if="response.final_area || response.area"
class="badge badge-warning badge-xs"
>
{{ response.final_area || response.area }} {{ response.final_area || response.area }}
</span> </span>
<span v-else class="text-base-content/50">-</span> <span v-else class="text-base-content/50">-</span>
@ -113,23 +101,14 @@
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<span class="badge badge-ghost badge-xs">{{ <span class="badge badge-ghost badge-xs">{{
response.files?.length || 0 response.files?.length || 0
}}</span> }}</span>
<div <div v-if="response.files?.length" class="tooltip" :data-tip="response.files
v-if="response.files?.length" .map((f) => f.original_filename || f.file?.name)
class="tooltip" .join(', ')
:data-tip=" ">
response.files
.map((f) => f.original_filename || f.file?.name)
.join(', ')
"
>
<i class="mdi mdi-information-outline text-xs"></i> <i class="mdi mdi-information-outline text-xs"></i>
</div> </div>
<div <div v-if="response.needs_manual_validation" class="tooltip" data-tip="Needs manual validation">
v-if="response.needs_manual_validation"
class="tooltip"
data-tip="Needs manual validation"
>
<i class="mdi mdi-alert-circle text-xs text-warning"></i> <i class="mdi mdi-alert-circle text-xs text-warning"></i>
</div> </div>
</div> </div>
@ -141,11 +120,9 @@
</div> </div>
<!-- No responses state --> <!-- No responses state -->
<div <div v-else
v-else
class="mt-6 text-center py-4 border-2 border-dashed border-base-300 rounded-lg hover:border-primary transition-colors duration-300 cursor-pointer" class="mt-6 text-center py-4 border-2 border-dashed border-base-300 rounded-lg hover:border-primary transition-colors duration-300 cursor-pointer"
@click="openSubmissionModal" @click="openSubmissionModal">
>
<i class="mdi mdi-upload text-2xl text-base-content/40"></i> <i class="mdi mdi-upload text-2xl text-base-content/40"></i>
<p class="text-sm text-base-content/60 mt-2">No solutions yet</p> <p class="text-sm text-base-content/60 mt-2">No solutions yet</p>
<p class="text-xs text-base-content/40"> <p class="text-xs text-base-content/40">

View File

@ -9,9 +9,16 @@ interface User {
last_name?: string; last_name?: string;
} }
interface PointsFactor {
cost: number;
cycles: number;
area: number;
}
interface Puzzle { interface Puzzle {
id: number; id: number;
title: string; title: string;
points_factor?: PointsFactor;
} }
interface PuzzleResponse { interface PuzzleResponse {
@ -91,7 +98,6 @@ const getPuzzleRanking = (puzzleId: number) => {
const ranking = resultsData.value.ranking_by_puzzle[puzzleId] || []; const ranking = resultsData.value.ranking_by_puzzle[puzzleId] || [];
return ranking.map((response) => { return ranking.map((response) => {
console.log(response)
const user = resultsData.value!.users.find((u) => u.id === response.user_id); const user = resultsData.value!.users.find((u) => u.id === response.user_id);
return { return {
username: user?.username || "Unknown", username: user?.username || "Unknown",
@ -297,6 +303,28 @@ onMounted(() => {
</div> </div>
<div v-else class="space-y-6"> <div v-else class="space-y-6">
<!-- Points Factor Info -->
<div v-if="puzzle.points_factor" class="bg-base-200 p-4 rounded-lg">
<p class="text-sm text-base-content/70 mb-3 font-semibold">Points Coefficients</p>
<div class="grid grid-cols-3 gap-4">
<div class="text-center">
<span class="text-2xl font-bold text-primary"><small>x</small>{{ puzzle.points_factor.cost
}}</span>
<p class="text-xs text-base-content/70">Cost</p>
</div>
<div class="text-center">
<span class="text-2xl font-bold text-primary"><small>x</small>{{ puzzle.points_factor.cycles
}}</span>
<p class="text-xs text-base-content/70">Cycles</p>
</div>
<div class="text-center">
<span class="text-2xl font-bold text-primary"><small>x</small>{{ puzzle.points_factor.area
}}</span>
<p class="text-xs text-base-content/70">Area</p>
</div>
</div>
</div>
<!-- Top 3 Podium --> <!-- Top 3 Podium -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4"> <div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div v-for="(response, index) in getPuzzleRanking(puzzle.id).slice(0, 3)" :key="index" <div v-for="(response, index) in getPuzzleRanking(puzzle.id).slice(0, 3)" :key="index"
@ -309,15 +337,21 @@ onMounted(() => {
<div class="divider my-2"></div> <div class="divider my-2"></div>
<div class="space-y-2 text-sm"> <div class="space-y-2 text-sm">
<div class="flex justify-between"> <div class="flex justify-between">
<span>Cost</span> <span>Cost<span v-if="puzzle.points_factor" class="text-xs text-base-content/60">
(x{{ puzzle.points_factor.cost }})
</span></span>
<span class="badge badge-sm">{{ response.cost || 'N/A' }}</span> <span class="badge badge-sm">{{ response.cost || 'N/A' }}</span>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<span>Cycles</span> <span>Cycles<span v-if="puzzle.points_factor" class="text-xs text-base-content/60">
(x{{ puzzle.points_factor.cycles }})
</span></span>
<span class="badge badge-sm">{{ response.cycles || 'N/A' }}</span> <span class="badge badge-sm">{{ response.cycles || 'N/A' }}</span>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<span>Area</span> <span>Area<span v-if="puzzle.points_factor" class="text-xs text-base-content/60">
(x{{ puzzle.points_factor.area }})
</span></span>
<span class="badge badge-sm">{{ response.area || 'N/A' }}</span> <span class="badge badge-sm">{{ response.area || 'N/A' }}</span>
</div> </div>
<div class="flex justify-between pt-2 border-t"> <div class="flex justify-between pt-2 border-t">
@ -340,9 +374,21 @@ onMounted(() => {
<tr> <tr>
<th class="w-12">Rank</th> <th class="w-12">Rank</th>
<th>Player</th> <th>Player</th>
<th class="text-center">Cost</th> <th class="text-center">
<th class="text-center">Cycles</th> Cost
<th class="text-center">Area</th> <span v-if="puzzle.points_factor" class="text-xs text-base-content/60 block">(x{{
puzzle.points_factor.cost }})</span>
</th>
<th class="text-center">
Cycles
<span v-if="puzzle.points_factor" class="text-xs text-base-content/60 block">(x{{
puzzle.points_factor.cycles }})</span>
</th>
<th class="text-center">
Area
<span v-if="puzzle.points_factor" class="text-xs text-base-content/60 block">(x{{
puzzle.points_factor.area }})</span>
</th>
<th class="text-center">Total (with coef.)</th> <th class="text-center">Total (with coef.)</th>
<th class="text-right">Points</th> <th class="text-right">Points</th>
</tr> </tr>

View File

@ -11,6 +11,12 @@ export interface SteamCollection {
updated_at: string updated_at: string
} }
export interface PointsFactor {
cost: number
cycles: number
area: number
}
export interface SteamCollectionItem { export interface SteamCollectionItem {
id: number id: number
steam_item_id: string steam_item_id: string
@ -20,6 +26,7 @@ export interface SteamCollectionItem {
tags: string[] tags: string[]
order_index: number order_index: number
collection: number collection: number
points_factor?: PointsFactor
created_at: string created_at: string
updated_at: string updated_at: string
} }

View File

@ -132,10 +132,19 @@ class ValidationIn(Schema):
# Collection Schemas # Collection Schemas
class PuzzlePointsFactorOut(Schema):
"""Schema for puzzle points factor output"""
cost: int
cycles: int
area: int
class SteamCollectionItemOut(ModelSchema): class SteamCollectionItemOut(ModelSchema):
"""Schema for Steam collection item output""" """Schema for Steam collection item output"""
steam_url: str steam_url: str
points_factor: Optional[PuzzlePointsFactorOut] = None
class Meta: class Meta:
model = SteamCollectionItem model = SteamCollectionItem
@ -151,6 +160,16 @@ class SteamCollectionItemOut(ModelSchema):
"updated_at", "updated_at",
] ]
@staticmethod
def resolve_points_factor(obj) -> Optional[PuzzlePointsFactorOut]:
if obj.points_factor:
return PuzzlePointsFactorOut(
cost=obj.points_factor.cost,
cycles=obj.points_factor.cycles,
area=obj.points_factor.area,
)
return None
# Error Schemas # Error Schemas
class ErrorOut(Schema): class ErrorOut(Schema):