feat: 태그 수정/삭제 API + 2D 그라데이션 색상표

- backend: tags PATCH 엔드포인트 추가 (태그 이름 수정 가능)
- frontend: 과목 색상 선택을 HSL 기반 2D 그라데이션 피커로 교체
- 프리셋 10색 빠른 선택 + 캔버스 드래그 + HEX 직접 입력

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
reloop
2026-04-19 16:14:36 +09:00
parent f5214d20b2
commit 629f2465e0
3 changed files with 230 additions and 35 deletions

View File

@@ -5,10 +5,11 @@ import {
Get,
Param,
ParseIntPipe,
Patch,
Post,
UseGuards,
} from '@nestjs/common';
import { IsInt, IsString, MaxLength, MinLength } from 'class-validator';
import { IsInt, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import { AuthUser } from '../auth/jwt.strategy';
@@ -24,6 +25,14 @@ class CreateTagDto {
name: string;
}
class UpdateTagDto {
@IsOptional()
@IsString()
@MinLength(1)
@MaxLength(40)
name?: string;
}
@Controller('tags')
@UseGuards(JwtAuthGuard)
export class TagsController {
@@ -42,6 +51,15 @@ export class TagsController {
return this.svc.create(user.id, dto.subjectId, dto.name);
}
@Patch(':id')
update(
@CurrentUser() user: AuthUser,
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateTagDto,
) {
return this.svc.update(user.id, id, dto);
}
@Delete(':id')
remove(
@CurrentUser() user: AuthUser,

View File

@@ -24,6 +24,15 @@ export class TagsService {
return this.prisma.tag.create({ data: { subjectId, name } });
}
async update(userId: number, id: number, data: { name?: string }) {
const tag = await this.prisma.tag.findUnique({
where: { id },
include: { subject: true },
});
if (!tag || tag.subject.userId !== userId) throw new NotFoundException();
return this.prisma.tag.update({ where: { id }, data });
}
async remove(userId: number, id: number) {
const tag = await this.prisma.tag.findUnique({
where: { id },

View File

@@ -1,6 +1,6 @@
'use client';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AxiosError } from 'axios';
import styled, { css } from 'styled-components';
import AppShell from '@/components/layout/AppShell';
@@ -17,6 +17,11 @@ const PRESET_COLORS = [
'#0EA5E9',
'#F59E0B',
'#10B981',
'#EF4444',
'#EC4899',
'#8B5CF6',
'#06B6D4',
'#84CC16',
] as const;
type SubjectTab = 'all' | number;
@@ -714,6 +719,148 @@ function isMethodNotAllowed(error: unknown) {
return (error as AxiosError | undefined)?.response?.status === 405;
}
function hslToHex(h: number, s: number, l: number): string {
const sNorm = s / 100;
const lNorm = l / 100;
const a = sNorm * Math.min(lNorm, 1 - lNorm);
const f = (n: number) => {
const k = (n + h / 30) % 12;
const color = lNorm - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return Math.round(255 * color)
.toString(16)
.padStart(2, '0');
};
return `#${f(0)}${f(8)}${f(4)}`;
}
function hexToHsl(hex: string): { h: number; s: number; l: number } {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
if (!result) return { h: 0, s: 70, l: 50 };
const r = parseInt(result[1], 16) / 255;
const g = parseInt(result[2], 16) / 255;
const b = parseInt(result[3], 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2;
if (max === min) return { h: 0, s: 0, l: Math.round(l * 100) };
const d = max - min;
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
let h: number;
switch (max) {
case r:
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
break;
case g:
h = ((b - r) / d + 2) / 6;
break;
default:
h = ((r - g) / d + 4) / 6;
break;
}
return { h: Math.round(h * 360), s: Math.round(s * 100), l: Math.round(l * 100) };
}
function GradientColorPicker({
value,
onChange,
}: {
value: string;
onChange: (hex: string) => void;
}) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const dragging = useRef(false);
const currentHsl = hexToHsl(value);
const CANVAS_W = 280;
const CANVAS_H = 160;
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
// X axis = hue (0..360), Y axis = lightness (85..20)
for (let x = 0; x < CANVAS_W; x++) {
for (let y = 0; y < CANVAS_H; y++) {
const hue = (x / CANVAS_W) * 360;
const lightness = 85 - (y / CANVAS_H) * 65;
ctx.fillStyle = `hsl(${hue}, 70%, ${lightness}%)`;
ctx.fillRect(x, y, 1, 1);
}
}
}, []);
const pickColor = (clientX: number, clientY: number) => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const x = Math.max(0, Math.min(CANVAS_W - 1, ((clientX - rect.left) / rect.width) * CANVAS_W));
const y = Math.max(0, Math.min(CANVAS_H - 1, ((clientY - rect.top) / rect.height) * CANVAS_H));
const hue = (x / CANVAS_W) * 360;
const lightness = 85 - (y / CANVAS_H) * 65;
onChange(hslToHex(hue, 70, lightness));
};
const handlePointerDown = (e: React.PointerEvent) => {
dragging.current = true;
(e.target as HTMLElement).setPointerCapture(e.pointerId);
pickColor(e.clientX, e.clientY);
};
const handlePointerMove = (e: React.PointerEvent) => {
if (!dragging.current) return;
pickColor(e.clientX, e.clientY);
};
const handlePointerUp = () => {
dragging.current = false;
};
// cursor position
const cursorX = (currentHsl.h / 360) * 100;
const cursorY = ((85 - currentHsl.l) / 65) * 100;
return (
<PickerWrap>
<CanvasWrap>
<PickerCanvas
ref={canvasRef}
width={CANVAS_W}
height={CANVAS_H}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
/>
<PickerCursor
style={{
left: `${Math.max(0, Math.min(100, cursorX))}%`,
top: `${Math.max(0, Math.min(100, cursorY))}%`,
background: value,
}}
/>
</CanvasWrap>
<PickerFooter>
<QuickSwatches>
{PRESET_COLORS.map((color) => (
<MiniSwatch
key={color}
type="button"
$color={color}
$active={value.toLowerCase() === color.toLowerCase()}
onClick={() => onChange(color)}
/>
))}
</QuickSwatches>
<HexInput
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="#4F46E5"
maxLength={7}
/>
</PickerFooter>
</PickerWrap>
);
}
function SubjectModal({
open,
mode,
@@ -758,29 +905,9 @@ function SubjectModal({
<div>
<Label> </Label>
<SwatchGrid>
{PRESET_COLORS.map((color) => (
<ColorSwatch
key={color}
type="button"
$color={color}
$active={value.color.toLowerCase() === color.toLowerCase()}
onClick={() => onChange({ ...value, color })}
aria-label={color}
/>
))}
</SwatchGrid>
</div>
<div>
<Label> HEX</Label>
<HexInput
<GradientColorPicker
value={value.color}
onChange={(event) =>
onChange({ ...value, color: event.target.value })
}
placeholder="#4F46E5"
maxLength={7}
onChange={(color) => onChange({ ...value, color })}
/>
</div>
</FieldGroup>
@@ -1359,30 +1486,71 @@ const FieldGroup = styled.div`
gap: 18px;
`;
const SwatchGrid = styled.div`
const PickerWrap = styled.div`
display: flex;
gap: 10px;
flex-wrap: wrap;
flex-direction: column;
gap: 12px;
`;
const ColorSwatch = styled.button<{ $color: string; $active: boolean }>`
width: 36px;
height: 36px;
const CanvasWrap = styled.div`
position: relative;
width: 100%;
border-radius: 12px;
overflow: hidden;
border: 1px solid ${theme.color.borderSoftAlpha};
cursor: crosshair;
`;
const PickerCanvas = styled.canvas`
display: block;
width: 100%;
height: 160px;
`;
const PickerCursor = styled.div`
position: absolute;
width: 18px;
height: 18px;
border-radius: 999px;
border: 2px solid #fff;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.3), 0 2px 8px rgba(0, 0, 0, 0.4);
transform: translate(-50%, -50%);
pointer-events: none;
`;
const PickerFooter = styled.div`
display: flex;
align-items: center;
gap: 12px;
`;
const QuickSwatches = styled.div`
display: flex;
gap: 4px;
flex-wrap: wrap;
flex: 1;
min-width: 0;
`;
const MiniSwatch = styled.button<{ $color: string; $active: boolean }>`
width: 22px;
height: 22px;
border-radius: 6px;
flex-shrink: 0;
border: 2px solid
${({ $active }) => ($active ? 'rgba(255, 255, 255, 0.95)' : 'transparent')};
${({ $active }) => ($active ? 'rgba(255, 255, 255, 0.9)' : 'transparent')};
background: ${({ $color }) => $color};
box-shadow: ${({ $active }) =>
$active ? `0 0 0 3px rgba(129, 140, 248, 0.28)` : 'none'};
transition: transform 0.16s ease;
transition: transform 0.12s ease;
&:hover {
transform: scale(1.06);
transform: scale(1.15);
}
`;
const HexInput = styled(Input)`
font-family: ${theme.font.mono};
width: 100px;
flex-shrink: 0;
`;
const ModalError = styled.p`