Skip to content

Commit dea82f7

Browse files
committed
feat: 후원사 태그에 색상 필드 추가
1 parent 941d798 commit dea82f7

12 files changed

Lines changed: 217 additions & 36 deletions

File tree

apps/pyconkr-2025/src/components/layout/Sponsor/index.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,8 @@ export const Sponsor: React.FC = ErrorBoundary.with(
148148
<Tooltip title={sponsorNameContent} arrow placement="top">
149149
<LogoImageEqualWidthContainer>
150150
<LogoBadgeContainer>
151-
{sponsor.tags.map((tag, i) => (
152-
<LogoBadge key={i} badgeContent={tag} />
151+
{sponsor.tags.map((tag) => (
152+
<LogoBadge key={tag.id} badgeContent={tag.name} />
153153
))}
154154
</LogoBadgeContainer>
155155
<LogoImageContainer>

apps/pyconkr-2025/src/components/pages/sponsor_detail.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ export const SponsorDetailPage: FC = ErrorBoundary.with(
8383
{sponsor.tags.length ? (
8484
<Stack direction="row" spacing={1} sx={{ width: "100%", mt: 1 }} aria-label="후원사 태그 목록">
8585
{sponsor.tags.map((tag) => (
86-
<Chip key={tag} size="small" variant="outlined" color="primary" label={tag} />
86+
<Chip key={tag.id} size="small" variant="outlined" color="primary" label={tag.name} />
8787
))}
8888
</Stack>
8989
) : null}

apps/pyconkr-2026/src/components/layout/Sponsor/index.tsx

Lines changed: 32 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { Badge, CircularProgress, Divider, Stack, Tooltip, Typography, TypographyProps, alpha, styled } from "@mui/material";
1+
import { isHexColor } from "@frontend/common/utils";
2+
import { Badge, CircularProgress, Divider, Stack, Tooltip, Typography, TypographyProps, alpha, darken, styled } from "@mui/material";
23
import { ErrorBoundary, Suspense } from "@suspensive/react";
34
import { Link } from "react-router-dom";
45

@@ -85,30 +86,35 @@ const LogoBadgeContainer = styled(Stack)({
8586
gap: "0.25rem",
8687
});
8788

88-
const LogoBadge = styled(Badge)(({ theme }) => ({
89-
alignItems: "flex-end",
90-
91-
"& .MuiBadge-badge": {
92-
position: "relative",
93-
borderRadius: "0.25rem",
94-
padding: "0 0.5rem",
95-
backgroundColor: theme.palette.primary.main,
96-
color: "white",
97-
borderEndEndRadius: "0",
98-
transform: "none",
99-
100-
"&:after": {
101-
content: '""',
102-
position: "absolute",
103-
bottom: "-8px",
104-
right: "-0.1px",
105-
width: 0,
106-
height: 0,
107-
border: "solid 4px",
108-
borderColor: `${theme.palette.primary.dark} transparent transparent ${theme.palette.primary.dark}`,
89+
const LogoBadge = styled(Badge, { shouldForwardProp: (prop) => prop !== "tagColor" })<{ tagColor?: string | null }>(({ theme, tagColor }) => {
90+
const backgroundColor = isHexColor(tagColor) ? tagColor : theme.palette.primary.main;
91+
const foldColor = isHexColor(tagColor) ? darken(tagColor, 0.3) : theme.palette.primary.dark;
92+
93+
return {
94+
alignItems: "flex-end",
95+
96+
"& .MuiBadge-badge": {
97+
position: "relative",
98+
borderRadius: "0.25rem",
99+
padding: "0 0.5rem",
100+
backgroundColor,
101+
color: theme.palette.getContrastText(backgroundColor),
102+
borderEndEndRadius: "0",
103+
transform: "none",
104+
105+
"&:after": {
106+
content: '""',
107+
position: "absolute",
108+
bottom: "-8px",
109+
right: "-0.1px",
110+
width: 0,
111+
height: 0,
112+
border: "solid 4px",
113+
borderColor: `${foldColor} transparent transparent ${foldColor}`,
114+
},
109115
},
110-
},
111-
}));
116+
};
117+
});
112118

113119
export const Sponsor: React.FC = ErrorBoundary.with(
114120
{
@@ -151,8 +157,8 @@ export const Sponsor: React.FC = ErrorBoundary.with(
151157
<Tooltip title={sponsorNameContent} arrow placement="top">
152158
<LogoImageEqualWidthContainer>
153159
<LogoBadgeContainer>
154-
{sponsor.tags.map((tag, i) => (
155-
<LogoBadge key={i} badgeContent={tag} />
160+
{sponsor.tags.map((tag) => (
161+
<LogoBadge key={tag.id} badgeContent={tag.name} tagColor={tag.color} />
156162
))}
157163
</LogoBadgeContainer>
158164
<LogoImageContainer>

apps/pyconkr-2026/src/components/pages/sponsor_detail.tsx

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { CenteredPage, ErrorFallback, MDXRenderer } from "@frontend/common/components";
22
import { useCommonContext } from "@frontend/common/hooks/useCommonContext";
33
import { SponsorTierSchema } from "@frontend/common/schemas/backendAPI";
4+
import { isHexColor } from "@frontend/common/utils";
45
import { Box, Chip, CircularProgress, Divider, Stack, styled, Typography } from "@mui/material";
56
import { ErrorBoundary, Suspense } from "@suspensive/react";
67
import { FC, useEffect } from "react";
@@ -114,7 +115,14 @@ export const SponsorDetailPage: FC = ErrorBoundary.with(
114115
{sponsor.tags.length ? (
115116
<Stack direction="row" spacing={1} sx={{ width: "100%", mt: 1 }} aria-label="후원사 태그 목록">
116117
{sponsor.tags.map((tag) => (
117-
<Chip key={tag} size="small" variant="outlined" color="primary" label={tag} />
118+
<Chip
119+
key={tag.id}
120+
size="small"
121+
variant="outlined"
122+
color="primary"
123+
label={tag.name}
124+
sx={isHexColor(tag.color) ? { color: tag.color, borderColor: tag.color } : undefined}
125+
/>
118126
))}
119127
</Stack>
120128
) : null}
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { isHexColor } from "@frontend/common/utils";
2+
import { Clear } from "@mui/icons-material";
3+
import { IconButton, Stack, styled, TextField, Tooltip } from "@mui/material";
4+
import { FC, FocusEventHandler } from "react";
5+
6+
/** 미지정 상태에서 색상 선택기를 열었을 때의 시작 색상. 값으로 저장되지는 않는다. */
7+
const PICKER_START_COLOR = "#3498db";
8+
9+
/** 네이티브 색상 입력은 "값 없음"을 표현할 수 없어, 스와치를 직접 그리고 입력은 뒤에 숨긴다. */
10+
const Swatch = styled("label", { shouldForwardProp: (prop) => prop !== "color" && prop !== "disabled" })<{ color?: string; disabled?: boolean }>(
11+
({ theme, color, disabled }) => ({
12+
position: "relative",
13+
flexShrink: 0,
14+
width: "3.5rem",
15+
height: "3.5rem",
16+
borderRadius: theme.shape.borderRadius,
17+
border: `1px solid ${theme.palette.divider}`,
18+
cursor: disabled ? "default" : "pointer",
19+
opacity: disabled ? theme.palette.action.disabledOpacity : 1,
20+
overflow: "hidden",
21+
// 미지정은 대각선 사선으로 표시 — 검정을 고른 상태와 구분한다.
22+
background: color
23+
? color
24+
: `linear-gradient(to top right, transparent calc(50% - 1px), ${theme.palette.text.disabled}, transparent calc(50% + 1px)), ${theme.palette.action.hover}`,
25+
26+
"& input": {
27+
position: "absolute",
28+
width: 0,
29+
height: 0,
30+
opacity: 0,
31+
border: 0,
32+
padding: 0,
33+
},
34+
})
35+
);
36+
37+
export type ColorInputProps = {
38+
id?: string;
39+
label?: string;
40+
value: string | null;
41+
required?: boolean;
42+
disabled?: boolean;
43+
error?: boolean;
44+
helperText?: string;
45+
autoFocus?: boolean;
46+
onChange: (value: string | null) => void;
47+
onBlur?: FocusEventHandler<HTMLInputElement | HTMLTextAreaElement>;
48+
onFocus?: FocusEventHandler<HTMLInputElement | HTMLTextAreaElement>;
49+
};
50+
51+
/** 스와치 색상 선택기 + #RRGGBB 직접 입력. 미지정은 빈 문자열이 아니라 null 로 전달한다. */
52+
export const ColorInput: FC<ColorInputProps> = ({
53+
id,
54+
label,
55+
value,
56+
required,
57+
disabled,
58+
error,
59+
helperText,
60+
autoFocus,
61+
onChange,
62+
onBlur,
63+
onFocus,
64+
}) => {
65+
const selectedColor = isHexColor(value) ? value : undefined;
66+
const handleChange = (newValue: string) => onChange(newValue === "" ? null : newValue);
67+
68+
return (
69+
<Stack direction="row" spacing={1} alignItems="flex-start" sx={{ width: "100%" }}>
70+
<Tooltip title={selectedColor ?? "색상 미지정"}>
71+
<Swatch color={selectedColor} disabled={disabled}>
72+
<input
73+
type="color"
74+
value={selectedColor ?? PICKER_START_COLOR}
75+
disabled={disabled}
76+
onChange={(e) => handleChange(e.target.value)}
77+
aria-label={`${label ?? "색상"} 선택기`}
78+
/>
79+
</Swatch>
80+
</Tooltip>
81+
<TextField
82+
id={id}
83+
label={label}
84+
value={value ?? ""}
85+
required={required}
86+
disabled={disabled}
87+
error={error}
88+
helperText={helperText}
89+
placeholder="#RRGGBB (비워두면 기본색)"
90+
autoFocus={autoFocus}
91+
onChange={(e) => handleChange(e.target.value)}
92+
onBlur={onBlur}
93+
onFocus={onFocus}
94+
slotProps={{ inputLabel: { shrink: true } }}
95+
sx={{ flexGrow: 1 }}
96+
/>
97+
<Tooltip title="색상 비우기">
98+
<span>
99+
<IconButton onClick={() => onChange(null)} disabled={disabled || !value} sx={{ mt: 1 }}>
100+
<Clear />
101+
</IconButton>
102+
</span>
103+
</Tooltip>
104+
</Stack>
105+
);
106+
};
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { WidgetProps } from "@rjsf/utils";
2+
import { FC } from "react";
3+
4+
import { ColorInput } from "@apps/pyconkr-admin/components/elements/color_input";
5+
6+
export const ColorPickerWidget: FC<WidgetProps> = (props) => {
7+
const { id, value, label, schema, required, disabled, readonly, rawErrors, onChange, onBlur, onFocus } = props;
8+
9+
return (
10+
<ColorInput
11+
id={id}
12+
label={label || schema.title}
13+
value={value ?? null}
14+
required={required}
15+
disabled={disabled || readonly}
16+
error={(rawErrors?.length ?? 0) > 0}
17+
onChange={onChange}
18+
onBlur={(e) => onBlur(id, e.target.value)}
19+
onFocus={(e) => onFocus(id, e.target.value)}
20+
/>
21+
);
22+
};

apps/pyconkr-admin/src/components/elements/inline_resource_section.tsx

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ import {
66
useRemovePreparedMutation,
77
useUpdatePreparedMutation,
88
} from "@frontend/common/hooks/useAdminAPI";
9+
import { isHexColor } from "@frontend/common/utils";
910
import { Add, ArrowDownward, ArrowUpward, Delete, Edit } from "@mui/icons-material";
1011
import {
12+
Box,
1113
Button,
1214
CircularProgress,
1315
Dialog,
@@ -27,6 +29,7 @@ import {
2729
import { ErrorBoundary, Suspense } from "@suspensive/react";
2830
import { FC, FormEvent, ReactNode, useEffect, useMemo, useState } from "react";
2931

32+
import { ColorInput } from "@apps/pyconkr-admin/components/elements/color_input";
3033
import { ErrorFallback } from "@apps/pyconkr-admin/components/elements/error_fallback";
3134
import { addErrorSnackbar, addSnackbar } from "@apps/pyconkr-admin/utils/snackbar";
3235

@@ -44,14 +47,14 @@ type Width = string | number;
4447
*
4548
* - `translated` renders as two table columns (한국어/영어) and two form inputs.
4649
* The _ko variant is always treated as required.
47-
* - `text` / `number` render as one column and one input.
50+
* - `text` / `number` / `color` render as one column and one input.
4851
*/
4952
export type ColumnDef =
5053
| { name: string; label: string; type: "translated"; align?: Align; width?: Width }
5154
| {
5255
name: string;
5356
label: string;
54-
type: "text" | "number";
57+
type: "text" | "number" | "color";
5558
align?: Align;
5659
width?: Width;
5760
defaultValue?: (existingCount: number) => string;
@@ -90,6 +93,8 @@ const buildPayload = (columns: ColumnDef[], values: FormValues, filter: { key: s
9093
payload[`${c.name}_en`] = values[`${c.name}_en`] ?? "";
9194
} else if (c.type === "number") {
9295
payload[c.name] = Number(values[c.name]) || 0;
96+
} else if (c.type === "color") {
97+
payload[c.name] = values[c.name] || null;
9398
} else {
9499
payload[c.name] = values[c.name] ?? "";
95100
}
@@ -217,6 +222,19 @@ const ResourceDialog: FC<ResourceDialogProps> = ({ open, onClose, app, resource,
217222
</Stack>
218223
);
219224
}
225+
if (c.type === "color") {
226+
return (
227+
<ColorInput
228+
key={c.name}
229+
label={c.label}
230+
value={values[c.name] || null}
231+
error={!!fieldErrors[c.name]}
232+
helperText={fieldErrors[c.name] || c.helperText}
233+
autoFocus={idx === 0}
234+
onChange={(v) => setField(c.name, v ?? "")}
235+
/>
236+
);
237+
}
220238
return (
221239
<TextField
222240
key={c.name}
@@ -273,6 +291,15 @@ const renderCellValue = (column: ColumnDef, row: ResourceRow, subKey?: "ko" | "e
273291
}
274292
if (column.render) return column.render(row);
275293
const v = row[column.name];
294+
if (column.type === "color") {
295+
if (!isHexColor(v)) return "";
296+
return (
297+
<Stack direction="row" spacing={1} alignItems="center">
298+
<Box sx={{ width: 16, height: 16, borderRadius: "4px", backgroundColor: v, border: 1, borderColor: "divider" }} />
299+
<span>{v}</span>
300+
</Stack>
301+
);
302+
}
276303
return v === null || v === undefined ? "" : String(v);
277304
};
278305

apps/pyconkr-admin/src/components/layouts/admin_editor.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import { isArray, isNonNullish, isObjectType, isString } from "remeda";
5050
import { BackendAdminSignInGuard } from "@apps/pyconkr-admin/components/elements/admin_signin_guard";
5151
import { ChoicePicker } from "@apps/pyconkr-admin/components/elements/choice_picker";
5252
import { ChoicePickerWidget } from "@apps/pyconkr-admin/components/elements/choice_picker_widget";
53+
import { ColorPickerWidget } from "@apps/pyconkr-admin/components/elements/color_picker_widget";
5354
import { ErrorFallback } from "@apps/pyconkr-admin/components/elements/error_fallback";
5455
import { addErrorSnackbar, addSnackbar } from "@apps/pyconkr-admin/utils/snackbar";
5556

@@ -446,7 +447,7 @@ const InnerAdminEditor: FC<AppResourceIdType & AdminEditorPropsType> = ErrorBoun
446447
disabled={disabled}
447448
showErrorList={false}
448449
fields={{ file: FileField, m2m_select: M2MSelect, markdown: MDEditorField }}
449-
widgets={{ SelectWidget: ChoicePickerWidget, choice_picker: ChoicePickerWidget }}
450+
widgets={{ SelectWidget: ChoicePickerWidget, choice_picker: ChoicePickerWidget, color: ColorPickerWidget }}
450451
/>
451452
</Box>
452453
</Stack>

apps/pyconkr-admin/src/components/pages/sponsor/settings.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,10 @@ export const SponsorSettingsTabs: FC<{ eventId: string }> = ({ eventId }) => {
4646
resource="sponsortag"
4747
filter={{ key: "event", value: eventId }}
4848
label="태그"
49-
columns={[{ name: "name", label: "이름", type: "translated" }]}
49+
columns={[
50+
{ name: "name", label: "이름", type: "translated" },
51+
{ name: "color", label: "색상", type: "color", width: 160, helperText: "비워두면 홈페이지 기본색으로 표시됩니다." },
52+
]}
5053
/>
5154
)}
5255
</Box>

packages/common/src/schemas/backendAPI.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,12 @@ export type PageSchema = {
6666
sections: SectionSchema[];
6767
};
6868

69+
export type SponsorTagSchema = {
70+
id: string;
71+
name: string;
72+
color: string | null;
73+
};
74+
6975
export type SponsorTierSchema = {
7076
id: string;
7177
name: string;
@@ -75,7 +81,7 @@ export type SponsorTierSchema = {
7581
name: string;
7682
logo: string;
7783
description: string;
78-
tags: string[];
84+
tags: SponsorTagSchema[];
7985
}[];
8086
};
8187

0 commit comments

Comments
 (0)