Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Stack, styled } from "@mui/material";
import * as React from "react";

const StyledFieldset = styled("fieldset")(({ theme }) => ({
margin: 0,
padding: theme.spacing(1, 2, 2),
border: `1px solid ${theme.palette.divider}`,
borderRadius: theme.shape.borderRadius,
minWidth: 0,
width: "fit-content",
maxWidth: "100%",
}));

const StyledLegend = styled("legend")(({ theme }) => ({
padding: theme.spacing(0, 1),
color: theme.palette.text.secondary,
...theme.typography.caption,
}));

type Props = {
label: string;
children: React.ReactNode;
};

export const AdminFilterFieldset: React.FC<Props> = ({ label, children }) => (
<StyledFieldset>
<StyledLegend>{label}</StyledLegend>
<Stack direction="row" spacing={2} flexWrap="wrap" alignItems="center" sx={{ rowGap: 2 }}>
{children}
</Stack>
</StyledFieldset>
);
68 changes: 68 additions & 0 deletions apps/pyconkr-admin/src/components/elements/admin_pagination.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { FormControl, InputLabel, MenuItem, Pagination, Select, Stack, Typography } from "@mui/material";
import * as React from "react";

type Props = {
count: number;
page: number;
pageSize: number;
onPageChange: (page: number) => void;
onPageSizeChange: (size: number) => void;
pageSizeOptions?: number[];
scrollToTopOnChange?: boolean;
};

const DEFAULT_PAGE_SIZE_OPTIONS = [25, 50, 100, 200];

const scrollToTop = () => window.scrollTo({ top: 0, behavior: "smooth" });

export const AdminPagination: React.FC<Props> = ({
count,
page,
pageSize,
onPageChange,
onPageSizeChange,
pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,
scrollToTopOnChange = true,
}) => {
const pageCount = Math.max(1, Math.ceil(count / pageSize));
const startIdx = count === 0 ? 0 : (page - 1) * pageSize + 1;
const endIdx = Math.min(page * pageSize, count);

const handlePageChange = (newPage: number) => {
onPageChange(newPage);
if (scrollToTopOnChange) scrollToTop();
};

const handlePageSizeChange = (newSize: number) => {
onPageSizeChange(newSize);
if (scrollToTopOnChange) scrollToTop();
};

return (
<Stack direction="row" spacing={2} alignItems="center" justifyContent="space-between" sx={{ flexWrap: "wrap", py: 1 }}>
<Typography variant="body2" color="text.secondary">
{count.toLocaleString()}건 중 {startIdx.toLocaleString()}–{endIdx.toLocaleString()}
</Typography>
<Stack direction="row" spacing={2} alignItems="center">
<Pagination
count={pageCount}
page={Math.min(page, pageCount)}
onChange={(_, p) => handlePageChange(p)}
showFirstButton
showLastButton
size="small"
/>
<FormControl size="small" sx={{ minWidth: 110 }}>
<InputLabel id="admin-page-size-label">페이지당</InputLabel>
<Select labelId="admin-page-size-label" label="페이지당" value={pageSize} onChange={(e) => handlePageSizeChange(Number(e.target.value))}>
{pageSizeOptions.map((size) => (
<MenuItem key={size} value={size}>
{size}개
</MenuItem>
))}
</Select>
</FormControl>
</Stack>
</Stack>
);
};
119 changes: 119 additions & 0 deletions apps/pyconkr-admin/src/components/elements/public_file_picker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { useBackendAdminClient, useChoicesQuery, usePublicFileQuery } from "@frontend/common/src/hooks/useAdminAPI";
import { OpenInNew } from "@mui/icons-material";
import { Autocomplete, Box, Button, CircularProgress, Stack, TextField } from "@mui/material";
import { ErrorBoundary, Suspense } from "@suspensive/react";
import * as React from "react";
import { Link as RouterLink } from "react-router-dom";

type Option = { value: string; label: string };

type Props = {
label?: string;
value: string;
onChange: (value: string) => void;
// choices를 가져올 위치 (RJSF choices endpoint 활용)
choicesApp: string;
choicesResource: string;
choicesField: string;
// 확장자 화이트리스트 (대소문자 무시, 점 prefix 없이). 미지정 시 모든 파일 노출.
acceptExtensions?: string[];
};

const PREVIEW_SIZE = 56;

const previewBoxSx = {
width: PREVIEW_SIZE,
height: PREVIEW_SIZE,
flexShrink: 0,
border: 1,
borderColor: "divider",
borderRadius: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
bgcolor: "action.hover",
overflow: "hidden",
};

const ImagePreview: React.FC<{ id: string }> = ErrorBoundary.with(
{ fallback: () => <Box sx={previewBoxSx} /> },
Suspense.with(
{
fallback: (
<Box sx={previewBoxSx}>
<CircularProgress size={20} />
</Box>
),
},
({ id }) => {
const client = useBackendAdminClient();
const { data } = usePublicFileQuery(client, id);
if (!data) return <Box sx={previewBoxSx} />;
const isImage = data.mimetype?.startsWith("image/");
if (!isImage) {
return (
<Box
component="a"
href={data.file}
target="_blank"
rel="noopener"
sx={{ ...previewBoxSx, fontSize: 11, textDecoration: "none", color: "text.secondary" }}
>
파일
</Box>
);
}
return (
<Box component="a" href={data.file} target="_blank" rel="noopener" sx={previewBoxSx} title="원본 보기">
<Box component="img" src={data.file} alt="" sx={{ maxWidth: "100%", maxHeight: "100%", objectFit: "contain" }} />
</Box>
);
}
)
);

export const PublicFilePicker: React.FC<Props> = ({
label = "이미지",
value,
onChange,
choicesApp,
choicesResource,
choicesField,
acceptExtensions,
}) => {
const client = useBackendAdminClient();
const choicesQuery = useChoicesQuery(client, choicesApp, choicesResource);
const options: Option[] = React.useMemo(() => {
const all = (choicesQuery.data?.[choicesField] ?? []).map((item) => ({ value: item.const ?? "", label: item.title }));
if (!acceptExtensions || acceptExtensions.length === 0) return all;
const allowed = acceptExtensions.map((e) => e.toLowerCase());
return all.filter((opt) => allowed.some((ext) => opt.label.toLowerCase().endsWith(`.${ext}`)));
}, [choicesQuery.data, choicesField, acceptExtensions]);
const selected = options.find((o) => o.value === value) ?? null;

return (
<Stack direction="row" spacing={2} alignItems="center">
{value ? <ImagePreview id={value} /> : <Box sx={previewBoxSx} />}
<Autocomplete
options={options}
value={selected}
onChange={(_, newValue) => onChange(newValue?.value ?? "")}
getOptionLabel={(o) => o.label}
isOptionEqualToValue={(a, b) => a.value === b.value}
sx={{ flexGrow: 1, minWidth: 240 }}
renderInput={(params) => <TextField {...params} label={label} placeholder="파일을 선택하세요" />}
/>
<Button
component={RouterLink}
to="/file/publicfile/create"
target="_blank"
variant="outlined"
size="small"
startIcon={<OpenInNew />}
sx={{ flexShrink: 0 }}
>
새 파일 업로드
</Button>
</Stack>
);
};
Loading