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:
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user