Skip to content

Commit 9101014

Browse files
committed
feat: enhance solution detail, Solarized theme, scrollbar, layout, and i18n fixes
- Add solution vote/comment count tracking and tagsList to SolutionVO - Register Solarized color themes for Monaco editors in code and markdown - Add custom scrollbar styles with CSS variables for light/dark modes - Compact header layout (sidebar 220px, header h-12), tighten data-table controls - Add missing i18n keys: OAuth Google login, solution error messages, keyboard shortcuts - Fix session expired redirect to use imported router directly - Add formatted dates, language labels, tags display to solution cards and detail - Fix edge operations to only fetch interactions when authenticated - Improve LanguageSwitcher and NavUser guest dropdown UX
1 parent 4068e9f commit 9101014

30 files changed

Lines changed: 545 additions & 72 deletions

File tree

backend-spring/src/main/java/com/ulticode/modules/edgeoperations/service/impl/EdgeOperationsServiceImpl.java

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import com.ulticode.modules.edgeoperations.dto.EdgeOperationDTO;
77
import com.ulticode.modules.edgeoperations.dto.EdgeOperationResponseVO;
88
import com.ulticode.modules.edgeoperations.service.EdgeOperationsService;
9+
import com.ulticode.modules.solution.entity.Solution;
10+
import com.ulticode.modules.solution.mapper.SolutionMapper;
911
import com.ulticode.modules.vote.dto.VoteDTO;
1012
import com.ulticode.modules.vote.dto.VoteResultVO;
1113
import com.ulticode.modules.vote.entity.EdgeOperation;
@@ -30,6 +32,7 @@ public class EdgeOperationsServiceImpl implements EdgeOperationsService {
3032
private final VoteService voteService;
3133
private final EdgeOperationMapper edgeOperationMapper;
3234
private final BookmarkMapper bookmarkMapper;
35+
private final SolutionMapper solutionMapper;
3336

3437
@Override
3538
@Transactional
@@ -87,6 +90,9 @@ private EdgeOperationResponseVO handleVoteOperation(String userId, String target
8790

8891
VoteResultVO voteResult = voteService.vote(userId, voteDTO);
8992

93+
// Update denormalized vote counts on solution entity
94+
updateSolutionVoteCounts(targetId, targetType);
95+
9096
// Get favorites count
9197
long favorites = getFavoritesCount(targetId, targetType);
9298

@@ -143,4 +149,32 @@ private long getFavoritesCount(String targetId, EdgeOperationTargetType targetTy
143149
// For other target types, return 0 (can be extended later)
144150
return 0;
145151
}
152+
153+
/**
154+
* Update denormalized vote counts on solution entity.
155+
* Called after vote operations on SOLUTION target type.
156+
*/
157+
private void updateSolutionVoteCounts(String solutionId, EdgeOperationTargetType targetType) {
158+
if (targetType != EdgeOperationTargetType.SOLUTION) {
159+
return;
160+
}
161+
162+
Solution solution = solutionMapper.selectById(solutionId);
163+
if (solution == null) {
164+
log.warn("Solution not found for vote count update: {}", solutionId);
165+
return;
166+
}
167+
168+
// Count likes and dislikes from edge_operations
169+
long likes = edgeOperationMapper.countByTargetAndOperation(
170+
solutionId, EdgeOperationTargetType.SOLUTION.getValue(), EdgeOperationType.VOTE_UP.getValue());
171+
long dislikes = edgeOperationMapper.countByTargetAndOperation(
172+
solutionId, EdgeOperationTargetType.SOLUTION.getValue(), EdgeOperationType.VOTE_DOWN.getValue());
173+
174+
solution.setLikes((int) likes);
175+
solution.setDislikes((int) dislikes);
176+
solutionMapper.updateById(solution);
177+
178+
log.debug("Updated solution {} vote counts: likes={}, dislikes={}", solutionId, likes, dislikes);
179+
}
146180
}

backend-spring/src/main/java/com/ulticode/modules/solution/dto/SolutionVO.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
import lombok.Data;
55

66
import java.time.LocalDateTime;
7+
import java.util.Collections;
8+
import java.util.List;
79

810
/**
911
* Solution View Object for API responses.
@@ -88,6 +90,11 @@ public class SolutionVO {
8890
*/
8991
private Long score;
9092

93+
/**
94+
* Tags as parsed list (from JSON string)
95+
*/
96+
private List<String> tagsList;
97+
9198
/**
9299
* Whether the solution is published
93100
*/

backend-spring/src/main/java/com/ulticode/modules/solution/entity/Solution.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,21 @@ public class Solution {
6161
*/
6262
private Integer views;
6363

64+
/**
65+
* Number of likes (denormalized from edge_operations)
66+
*/
67+
private Integer likes = 0;
68+
69+
/**
70+
* Number of dislikes (denormalized from edge_operations)
71+
*/
72+
private Integer dislikes = 0;
73+
74+
/**
75+
* Number of comments (denormalized for performance)
76+
*/
77+
private Integer commentCount = 0;
78+
6479
/**
6580
* Whether the solution is published
6681
*/

backend-spring/src/main/java/com/ulticode/modules/solution/service/impl/SolutionServiceImpl.java

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818
import com.ulticode.modules.solution.service.SolutionService;
1919
import com.ulticode.modules.user.entity.User;
2020
import com.ulticode.modules.user.mapper.UserMapper;
21+
import com.fasterxml.jackson.core.JsonProcessingException;
22+
import com.fasterxml.jackson.core.type.TypeReference;
23+
import com.fasterxml.jackson.databind.ObjectMapper;
2124
import com.ulticode.modules.vote.mapper.EdgeOperationMapper;
2225
import com.ulticode.modules.vote.entity.enums.EdgeOperationTargetType;
2326
import com.ulticode.modules.vote.entity.enums.EdgeOperationType;
@@ -28,6 +31,7 @@
2831
import org.springframework.transaction.annotation.Transactional;
2932

3033
import java.time.LocalDateTime;
34+
import java.util.Collections;
3135
import java.util.List;
3236
import java.util.Optional;
3337
import java.util.UUID;
@@ -191,6 +195,9 @@ public SolutionVO create(Long problemId, String userId, CreateSolutionDTO create
191195
solution.setLanguage(createDTO.getLanguage());
192196
solution.setTags(createDTO.getTags() != null ? createDTO.getTags() : "[]");
193197
solution.setViews(0);
198+
solution.setLikes(0);
199+
solution.setDislikes(0);
200+
solution.setCommentCount(0);
194201
solution.setIsPublished(true);
195202
solution.setPublishedAt(LocalDateTime.now());
196203
solution.setPublishedBy(userId);
@@ -297,6 +304,9 @@ public SolutionVO toVO(Solution solution) {
297304
SolutionVO vo = new SolutionVO();
298305
BeanUtils.copyProperties(solution, vo);
299306

307+
// Parse tags JSON to list
308+
vo.setTagsList(parseTags(solution.getTags()));
309+
300310
// Fetch author info
301311
User author = userMapper.selectById(solution.getUserId());
302312
if (author != null) {
@@ -321,6 +331,25 @@ public SolutionVO toVO(Solution solution) {
321331
return vo;
322332
}
323333

334+
/**
335+
* Parse tags JSON string to list.
336+
*
337+
* @param tagsJson the JSON string of tags
338+
* @return list of tags
339+
*/
340+
private List<String> parseTags(String tagsJson) {
341+
if (tagsJson == null || tagsJson.isBlank()) {
342+
return Collections.emptyList();
343+
}
344+
try {
345+
ObjectMapper mapper = new ObjectMapper();
346+
return mapper.readValue(tagsJson, new TypeReference<List<String>>() {});
347+
} catch (JsonProcessingException e) {
348+
log.warn("Failed to parse tags JSON: {}", tagsJson, e);
349+
return Collections.emptyList();
350+
}
351+
}
352+
324353
/**
325354
* Build a summary from markdown content.
326355
* Strips markdown formatting and truncates to max length.

console/src/components/LanguageSwitcher.vue

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,27 +8,39 @@ import {
88
} from "@/components/ui/dropdown-menu";
99
import { Button } from "@/components/ui/button";
1010
import IconGlobe from "~icons/lucide/globe";
11+
import { Check } from "lucide-vue-next";
1112
1213
const { availableLocales, setLocale, isCurrentLocale } = useLocale();
1314
</script>
1415

1516
<template>
1617
<DropdownMenu>
1718
<DropdownMenuTrigger as-child>
18-
<Button variant="ghost" size="icon" class="h-8 w-8">
19-
<IconGlobe class="h-4 w-4" />
19+
<Button variant="ghost" size="icon" class="h-8 w-8 hover:bg-accent/50 transition-colors">
20+
<IconGlobe class="h-4 w-4 text-muted-foreground hover:text-foreground transition-colors" />
2021
<span class="sr-only">{{ $t("common.actions.toggleLanguage") }}</span>
2122
</Button>
2223
</DropdownMenuTrigger>
23-
<DropdownMenuContent align="end">
24+
<DropdownMenuContent align="end" class="min-w-40 p-1.5 animate-in fade-in-0 zoom-in-95 duration-200">
2425
<DropdownMenuItem
2526
v-for="localeConfig in availableLocales"
2627
:key="localeConfig.code"
27-
:class="{ 'bg-accent': isCurrentLocale(localeConfig.code) }"
28+
class="flex items-center justify-between cursor-pointer transition-all duration-200 px-3 py-2"
29+
:class="[
30+
isCurrentLocale(localeConfig.code)
31+
? 'bg-accent/50 text-accent-foreground font-bold'
32+
: 'hover:bg-accent/30'
33+
]"
2834
@click="setLocale(localeConfig.code)"
2935
>
30-
<span class="mr-2">{{ localeConfig.flag }}</span>
31-
<span>{{ localeConfig.nativeName }}</span>
36+
<div class="flex items-center gap-3">
37+
<span class="text-base leading-none">{{ localeConfig.flag }}</span>
38+
<span class="text-[11px] uppercase tracking-widest font-data">{{ localeConfig.nativeName }}</span>
39+
</div>
40+
<Check
41+
v-if="isCurrentLocale(localeConfig.code)"
42+
class="size-3.5 text-[var(--accent-primary)] animate-in zoom-in-50 duration-300"
43+
/>
3244
</DropdownMenuItem>
3345
</DropdownMenuContent>
3446
</DropdownMenu>

console/src/components/common/data-table/CategoryFilter.vue

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,23 +18,23 @@ const emit = defineEmits<{
1818
</script>
1919

2020
<template>
21-
<div class="flex flex-wrap gap-2 mb-2">
21+
<div class="flex flex-wrap gap-1.5 mb-1.5">
2222
<button
2323
v-for="cat in categories"
2424
:key="cat.value"
2525
@click="emit('update:modelValue', cat.value)"
26-
class="flex items-center gap-2 px-3.5 py-1.5 rounded-full text-xs font-medium transition-all duration-200"
26+
class="flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-medium transition-all duration-200"
2727
:class="
2828
modelValue === cat.value
2929
? 'bg-zinc-100 dark:bg-zinc-800 text-zinc-900 dark:text-zinc-100 shadow-sm ring-1 ring-black/5 dark:ring-white/10'
3030
: 'text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100 hover:bg-zinc-100/50 dark:hover:bg-zinc-800/50'
3131
"
3232
>
3333
<div
34-
class="p-1 rounded bg-popover shadow-sm"
34+
class="p-0.5 rounded bg-popover shadow-sm"
3535
:class="modelValue === cat.value ? 'text-primary' : 'text-zinc-400'"
3636
>
37-
<component :is="cat.icon" v-if="cat.icon" class="w-3 h-3" />
37+
<component :is="cat.icon" v-if="cat.icon" class="w-2.5 h-2.5" />
3838
</div>
3939
{{ cat.label }}
4040
</button>

console/src/components/common/data-table/DataTableToolbar.vue

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,18 +26,18 @@ const emit = defineEmits<{
2626

2727
<template>
2828
<div
29-
class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"
29+
class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between"
3030
>
3131
<!-- Left: Search -->
3232
<div class="relative w-full max-w-md">
3333
<Search
34-
class="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground"
34+
class="absolute left-3 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground"
3535
/>
3636
<Input
3737
:model-value="modelValue"
3838
@update:model-value="(v) => emit('update:modelValue', v as string)"
3939
:placeholder="placeholder || 'Search...'"
40-
class="pl-9 h-10 rounded-full"
40+
class="pl-8.5 h-9 text-xs rounded-full"
4141
/>
4242
</div>
4343

@@ -47,14 +47,14 @@ const emit = defineEmits<{
4747
<DropdownMenuTrigger as-child>
4848
<Button
4949
variant="outline"
50-
class="h-10 gap-2 border-dashed rounded-full"
50+
class="h-9 gap-1.5 border-dashed rounded-full text-xs"
5151
>
52-
<ListFilter class="h-4 w-4" />
52+
<ListFilter class="h-3.5 w-3.5" />
5353
{{ filterLabel || "Filters" }}
5454
<Badge
5555
v-if="(activeFilterCount || 0) > 0"
5656
variant="secondary"
57-
class="ml-1 h-5 px-1 text-[10px] rounded-full"
57+
class="ml-0.5 h-4 px-1 text-[9px] rounded-full"
5858
>
5959
{{ activeFilterCount }}
6060
</Badge>
@@ -71,11 +71,11 @@ const emit = defineEmits<{
7171
v-if="showClear"
7272
variant="ghost"
7373
size="icon"
74-
class="h-10 w-10 rounded-full"
74+
class="h-9 w-9 rounded-full"
7575
@click="emit('clear')"
7676
:aria-label="clearLabel || 'Clear filters'"
7777
>
78-
<X class="h-4 w-4" />
78+
<X class="h-3.5 w-3.5" />
7979
</Button>
8080
</div>
8181
</div>

console/src/components/common/data-table/TagFilter.vue

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,13 @@ function isTagSelected(tag: string, currentTags: string[]) {
3232
</script>
3333

3434
<template>
35-
<Collapsible class="w-full space-y-3">
36-
<div class="flex flex-wrap items-center gap-2">
35+
<Collapsible class="w-full space-y-2.5">
36+
<div class="flex flex-wrap items-center gap-1.5">
3737
<Badge
3838
v-for="tag in popularTags"
3939
:key="tag"
4040
:variant="isTagSelected(tag, modelValue) ? 'default' : 'outline'"
41-
class="cursor-pointer px-3 py-1 hover:bg-primary/80 hover:text-primary-foreground transition-colors rounded-none"
41+
class="cursor-pointer px-2 py-0.5 text-[11px] hover:bg-primary/80 hover:text-primary-foreground transition-colors rounded-none"
4242
:class="{
4343
'bg-primary text-primary-foreground hover:bg-primary/90':
4444
isTagSelected(tag, modelValue),
@@ -51,20 +51,20 @@ function isTagSelected(tag: string, currentTags: string[]) {
5151
<Button
5252
variant="ghost"
5353
size="sm"
54-
class="gap-1 h-7 text-xs text-muted-foreground hover:text-foreground rounded-none"
54+
class="gap-1 h-6 text-[10px] text-muted-foreground hover:text-foreground rounded-none px-2"
5555
>
5656
{{ showMoreLabel || "Show more" }}
57-
<ChevronDown class="h-3 w-3" />
57+
<ChevronDown class="h-2.5 w-2.5" />
5858
</Button>
5959
</CollapsibleTrigger>
6060
</div>
6161
<CollapsibleContent class="animate-slide-down">
62-
<div class="flex flex-wrap gap-2 pt-2">
62+
<div class="flex flex-wrap gap-1.5 pt-1.5">
6363
<Badge
6464
v-for="tag in otherTags"
6565
:key="tag"
6666
variant="outline"
67-
class="cursor-pointer px-2.5 py-0.5 text-[11px] font-normal border-zinc-200 dark:border-zinc-800 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors rounded-none"
67+
class="cursor-pointer px-2 py-0.5 text-[10px] font-normal border-zinc-200 dark:border-zinc-800 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors rounded-none"
6868
:class="{
6969
'bg-zinc-900 text-zinc-50 border-zinc-900 hover:bg-zinc-800 dark:bg-zinc-50 dark:text-zinc-900':
7070
isTagSelected(tag, modelValue),

console/src/components/edge-operations/ProblemEdgeOperations.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,8 +80,8 @@ watch(
8080
viewerInteraction.value = {
8181
reaction: props.problem.interactions.viewer?.reaction,
8282
};
83-
} else {
84-
// Otherwise fetch from API
83+
} else if (useAuthStore().isAuthenticated) {
84+
// Only fetch from API when authenticated
8585
loadInteractions(problemId);
8686
}
8787
}

console/src/components/markdown/MarkdownEdit.vue

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import * as monaco from "monaco-editor";
2222
import loader from "@monaco-editor/loader";
2323
import { usePreferredDark } from "@vueuse/core";
2424
import { configureMonacoWorkers } from "@/utils/monaco-workers";
25+
import { registerSolarizedThemes } from "@/utils/monaco-solarized-theme";
2526
import { useI18n } from "vue-i18n";
2627
2728
// 确保 Worker 配置生效
@@ -57,6 +58,8 @@ const initEditor = async () => {
5758
loader.config({ monaco });
5859
const monacoInstance = await loader.init();
5960
61+
registerSolarizedThemes(monacoInstance);
62+
6063
const initialValue = props.modelValue || props.defaultValue || "";
6164
6265
editorInstance = monacoInstance.editor.create(editorRef.value, {

0 commit comments

Comments
 (0)