Skip to content
Merged
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
29 changes: 20 additions & 9 deletions src/api/workspace/org.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import type {
TApiResult,
TCreateOrgRequest,
TCreateOrgResponse,
TGetOrgResponse,
TMyOrgsData,
TUpdateWorkspaceRequest,
TWorkspace,
TWorkspaceDetail,
import {
type TApiResult,
type TCreateOrgRequest,
type TCreateOrgResponse,
type TGetOrgResponse,
type TMyOrgsData,
type TUpdateWorkspaceRequest,
type TUploadImageResponse,
type TWorkspace,
type TWorkspaceDetail,
} from "@/types/workspace/workspace";

import { axiosInstance } from "@/lib/axiosInstance";
Expand Down Expand Up @@ -47,3 +48,13 @@ export const updateWorkspace = async (
export const deleteWorkspace = async (orgId: number): Promise<void> => {
await axiosInstance.delete<TApiResult<string>>(`/api/org/${orgId}`);
};

export const uploadImage = async (file: File): Promise<string> => {
const formData = new FormData();
formData.append("image", file);
const { data } = await axiosInstance.post<TApiResult<TUploadImageResponse>>(
`/api/images/upload`,
formData,
);
return data.data.url;
};
Comment thread
jjjsun marked this conversation as resolved.
2 changes: 1 addition & 1 deletion src/components/common/input/Input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ const Input = forwardRef<HTMLInputElement, IInputProps>(
{label && (
<label
htmlFor={inputId}
className="text-text-main select-none ml-1 mb-1"
className="text-text-main select-none ml-1 mb-2"
>
{label}
</label>
Expand Down
2 changes: 1 addition & 1 deletion src/components/common/textarea/TextareaField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export default function TextareaField({

return (
<div className="flex flex-col">
<label className="text-text-main select-none ml-1" htmlFor={id}>
<label className="text-text-main select-none ml-1 mb-2" htmlFor={id}>
{label}
</label>
<textarea
Expand Down
26 changes: 20 additions & 6 deletions src/components/workspace/WorkspaceCard.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { useEffect, useState } from "react";

import type { TWorkspace } from "@/types/workspace/workspace";

import {
Expand All @@ -7,27 +9,39 @@ import {

import BuildingIcon from "@/assets/icon/workspace/building.svg?react";
import VectorIcon from "@/assets/icon/workspace/Vector.svg?react";
import { getImageUrl } from "@/lib/getImageUrl";

type TProps = {
workspace: TWorkspace;
menuItems: TMenuItem[];
};

export default function WorkspaceCard({ workspace: w, menuItems }: TProps) {
const [imageError, setImageError] = useState(false);

useEffect(() => {
setImageError(false);
}, [w.logoUrl]);

const imageSrc = w.logoUrl ? getImageUrl(w.logoUrl) : null;
const showPlaceholder = !imageSrc || imageError;
return (
<li className="flex items-center justify-between rounded-component-md bg-white px-6 py-5 shadow-Soft border border-gray-100">
<div className="flex items-center gap-5 min-w-0">
<div className="w-20 h-20 bg-gray-100 shrink-0 rounded-component-sm">
{w.logoUrl ? (
{showPlaceholder ? (
<div className="w-full h-full flex items-center justify-center">
<BuildingIcon className="w-8 h-8 text-text-placeholder" />
</div>
) : (
<img
src={w.logoUrl}
src={imageSrc}
alt={`${w.name} 로고`}
className="w-full h-full object-cover rounded-component-sm"
onError={() => {
setImageError(true);
}}
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<BuildingIcon className="w-8 h-8 text-text-placeholder" />
</div>
)}
</div>

Expand Down
3 changes: 0 additions & 3 deletions src/lib/axiosInstance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,6 @@ if (!BASE_URL) {
const axiosConfig: AxiosRequestConfig = {
baseURL: BASE_URL,
withCredentials: true,
headers: {
"Content-Type": "application/json",
},
};

export const axiosInstance = axios.create(axiosConfig);
Expand Down
9 changes: 9 additions & 0 deletions src/lib/getImageUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export const getImageUrl = (url?: string | null): string | null => {
if (!url) return null;
if (url.startsWith("http://") || url.startsWith("https://")) {
return url;
}
const BASE_URL = import.meta.env.VITE_API_BASE_URL;
if (!BASE_URL) return url;
return new URL(url, BASE_URL).toString();
};
73 changes: 53 additions & 20 deletions src/pages/workspace/Workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ import Modal from "@/components/common/modal/Modal";
import TextareaField from "@/components/common/textarea/TextareaField";
import WorkspaceCard from "@/components/workspace/WorkspaceCard";

import { createWorkspace, getMyWorkspaces } from "@/api/workspace/org";
import {
createWorkspace,
getMyWorkspaces,
uploadImage,
} from "@/api/workspace/org";
import EditContainIcon from "@/assets/icon/workspace/edit-contained.svg?react";
import PlusIcon from "@/assets/icon/workspace/plus.svg?react";
import SearchIcon from "@/assets/icon/workspace/search.svg?react";
Expand All @@ -28,14 +32,27 @@ export default function WorkspacePage() {
const [newName, setNewName] = useState("");
const [newDesc, setNewDesc] = useState("");

const [logoFile, setLogoFile] = useState<File | null>(null);
const [logoPreview, setLogoPreview] = useState<string | null>(null);

const queryClient = useQueryClient();
const workspacesQuery = useQuery({
queryKey: ["my-workspaces"],
queryFn: getMyWorkspaces,
});

const createWorkspaceMutation = useMutation({
mutationFn: createWorkspace,
mutationFn: async () => {
const name = newName.trim();
const description = newDesc.trim();
let logoUrl: string | null = null;

if (logoFile) {
logoUrl = await uploadImage(logoFile);
}
return createWorkspace({ name, description, logoUrl });
},

onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["my-workspaces"] });
setCreateOpen(false);
Expand Down Expand Up @@ -64,8 +81,6 @@ export default function WorkspacePage() {
fileRef.current.click();
};

const [logoPreview, setLogoPreview] = useState<string | null>(null);

const workspaces = workspacesQuery.data ?? [];

const filtered = useMemo(() => {
Expand All @@ -90,23 +105,34 @@ export default function WorkspacePage() {
];

const onCloseCreate = () => {
if (logoPreview) URL.revokeObjectURL(logoPreview);
setLogoPreview(null);
setLogoPreview((prev) => {
if (prev) URL.revokeObjectURL(prev);
return null;
});
setCreateOpen(false);
};
const onOpenCreate = () => {
setNewName("");
setNewDesc("");
if (logoPreview) URL.revokeObjectURL(logoPreview);
setLogoPreview(null);
setLogoFile(null);
setLogoPreview((prev) => {
if (prev) URL.revokeObjectURL(prev);
return null;
});
createWorkspaceMutation.reset();
setCreateOpen(true);
};

const onPickFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const onPickLogo = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setLogoPreview(URL.createObjectURL(file));
setLogoFile(file);
const previewUrl = URL.createObjectURL(file);
setLogoPreview((prev) => {
if (prev) URL.revokeObjectURL(prev);
return previewUrl;
});
};

useEffect(() => {
Expand All @@ -115,11 +141,9 @@ export default function WorkspacePage() {
};
}, [logoPreview]);

const onSubmitCreate = () => {
const name = newName.trim();
const description = newDesc.trim();
if (!name) return;
createWorkspaceMutation.mutate({ name, description, logoUrl: null });
const onSubmitCreate = async () => {
if (!newName.trim()) return;
createWorkspaceMutation.mutate();
};

return (
Expand Down Expand Up @@ -202,9 +226,9 @@ export default function WorkspacePage() {
<input
ref={fileRef}
type="file"
accept="image/*"
accept="image/jpeg,image/jpg,image/png,image/webp"
className="hidden"
onChange={onPickFile}
onChange={onPickLogo}
/>
<div className="flex items-center justify-between mb-2">
<div className="font-label text-text-sub">로고 이미지</div>
Expand All @@ -217,15 +241,24 @@ export default function WorkspacePage() {
업로드
</Button>
</div>

<button
type="button"
aria-label="로고 이미지 업로드"
onClick={openFile}
className="w-full rounded-component-lg border border-gray-100 bg-gray-50 h-65 flex items-center justify-center hover:bg-gray-100 transition-colors"
className="mx-auto aspect-square w-full max-w-65 overflow-hidden rounded-component-lg border border-gray-100 bg-gray-50 flex items-center justify-center hover:bg-gray-100 transition-colors"
>
<span className="text-text-sub">
<UpLoadImgIcon />
</span>
{logoPreview ? (
<img
src={logoPreview}
alt="새 로고 미리 보기"
className="h-full w-full object-cover"
/>
) : (
<span className="text-text-sub">
<UpLoadImgIcon />
</span>
)}
</button>
</div>

Expand Down
Loading
Loading