Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: 초대장 페이지 #69

Merged
merged 11 commits into from
Aug 18, 2024
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
5 changes: 5 additions & 0 deletions src/app/(main)/i/[subdomain]/edit/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { notFound } from "next/navigation";
import Editor from "~/components/editor";
import { getInvitationByEventUrl } from "~/lib/db/schema/invitations.query";

Expand All @@ -8,6 +9,10 @@ export default async function Page({
}) {
const invitation = await getInvitationByEventUrl(params.subdomain);

if (!invitation) {
notFound();
}

return (
<Editor
editorConfig={{
Expand Down
56 changes: 56 additions & 0 deletions src/app/(main)/i/[subdomain]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { Metadata, ResolvingMetadata } from "next";
import { notFound } from "next/navigation";
import Recursive from "~/components/editor/elements/recursive";
import EditorProvider from "~/components/editor/provider";
import { getInvitationByEventUrl } from "~/lib/db/schema/invitations.query";

type Props = {
params: { subdomain: string };
};

export async function generateMetadata(
{ params }: Props,
parent: ResolvingMetadata,
): Promise<Metadata> {
const invitation = await getInvitationByEventUrl(params.subdomain);

if (!invitation) {
return {};
}
const previousImages = (await parent).openGraph?.images || [];

return {
title: {
default: invitation.title,
template: "%s | 인비",
},
};
}

export default async function Page({ params }: Props) {
const invitation = await getInvitationByEventUrl(params.subdomain);

if (!invitation) {
notFound();
}

return (
<EditorProvider
editorConfig={{
invitationId: invitation.id,
invitationTitle: invitation.title,
invitationSubdomain: invitation.eventUrl,
invitationThumbnail: invitation.thumbnailUrl ?? undefined,
}}
editorData={invitation.customFields}
editorState={{ isPreviewMode: true }}
>
<main className="mx-auto max-w-md">
{Array.isArray(invitation.customFields) &&
invitation.customFields.map((childElement) => (
<Recursive key={childElement.id} element={childElement} />
))}
</main>
</EditorProvider>
);
}
4 changes: 2 additions & 2 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import { pretendard } from "~/lib/fonts";
import "./globals.css";

export const metadata: Metadata = {
title: "인비",
description: "당신의 환대, 초대장 플랫폼 '인비' 입니다.",
title: "초대장 플랫폼, 인비",
description: "따뜻한 마음을 담아 당신의 환대를 전해보세요.",
};

export default function RootLayout({
Expand Down
3 changes: 2 additions & 1 deletion src/components/editor/elements/image-element.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ export default function ImageElement({ element }: Props) {
<ElementWrapper element={element}>
{element.content.src ? (
<img
className="h-full w-full object-cover"
className="h-full w-full select-none object-cover"
draggable={false}
src={element.content.src}
alt={element.content.alt ?? "이미지"}
/>
Expand Down
24 changes: 21 additions & 3 deletions src/components/editor/elements/navigation-element.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,31 @@ export default function NavigationElement({ element }: Props) {
return (
<ElementWrapper element={element}>
<button onClick={() => openMap("naver", address)}>
<Image src="/naver-map.png" alt="naver-map" width={42} height={42} />
<Image
src="/naver-map.png"
alt="naver-map"
width={42}
height={42}
draggable={false}
/>
</button>
<button onClick={() => openMap("kakao", address)}>
<Image src="/kakao-map.png" alt="kakao-map" width={42} height={42} />
<Image
src="/kakao-map.png"
alt="kakao-map"
width={42}
height={42}
draggable={false}
/>
</button>
<button onClick={() => handleCopy(address)}>
<Image src="/copy.png" alt="copy" width={42} height={42} />
<Image
src="/copy.png"
alt="copy"
width={42}
height={42}
draggable={false}
/>
</button>
</ElementWrapper>
);
Expand Down
8 changes: 5 additions & 3 deletions src/components/editor/navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import {
EllipsisVerticalIcon,
EyeIcon,
Laptop,
MailOpenIcon,
Redo2,
Share2Icon,
Smartphone,
Tablet,
Undo2,
Expand Down Expand Up @@ -145,8 +145,10 @@ export default function EditorNavigation() {
<Button variant="secondary" onClick={handleOnSave} className="gap-1">
<DownloadIcon className="h-4 w-4" /> 저장
</Button>
<Button onClick={handleOnSave} className="gap-1">
<Share2Icon className="h-4 w-4" /> 공유
<Button className="gap-1" asChild>
<Link href="/" target="_blank">
<MailOpenIcon className="h-4 w-4" /> 초대장 보기
</Link>
</Button>
<DropdownMenu>
<TooltipSimple text="더보기">
Expand Down
6 changes: 6 additions & 0 deletions src/components/editor/provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export const EditorContext = createContext<{
export type EditorProps = {
editorData?: EditorData;
editorConfig?: Partial<EditorConfig>;
editorState?: Partial<Editor["state"]>;
};

export type EditorProviderProps = EditorProps & {
Expand All @@ -33,6 +34,7 @@ export default function EditorProvider({
children,
editorConfig,
editorData,
editorState,
}: EditorProviderProps) {
const [editor, dispatch] = useReducer(editorReducer, {
...initialEditor,
Expand All @@ -41,6 +43,10 @@ export default function EditorProvider({
...initialEditorConfig,
...editorConfig,
},
state: {
...initialEditor.state,
...editorState,
},
});

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export default function SidebarElementSettingsTab(props: Props) {

{selectedElement.type === "text" && (
<>
<LayoutSetting />
<TextSetting />
</>
)}
Expand All @@ -53,6 +54,7 @@ export default function SidebarElementSettingsTab(props: Props) {
selectedElement.type === "2Col") && (
<>
<LayoutSetting />
<TextSetting />
<BackgroundSetting />
<BorderSetting />
</>
Expand Down
110 changes: 92 additions & 18 deletions src/components/editor/sidebar-settings-tab.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";

import { useForm } from "@tanstack/react-form";
import { useMutation } from "@tanstack/react-query";
import { LinkIcon } from "lucide-react";
import { toast } from "sonner";
Expand All @@ -13,6 +14,10 @@ import {
SheetHeader,
SheetTitle,
} from "~/components/ui/sheet";
import {
existsByEventUrl,
updateInvitation,
} from "~/lib/db/schema/invitations.query";
import { uploadImage } from "~/lib/image";

type Props = {};
Expand All @@ -34,36 +39,105 @@ export default function SidebarSettingsTab(props: Props) {
}

function CustomDomainSection() {
const { editor } = useEditor();
const { editor, dispatch } = useEditor();

const updateSubdomainMutation = useMutation({
mutationFn: async (subdomain: string) => {
const isExist = await existsByEventUrl(subdomain);

if (isExist) {
throw new Error("이미 사용중인 도메인입니다.");
}

await updateInvitation({
id: editor.config.invitationId,
eventUrl: subdomain,
});

dispatch({
type: "UPDATE_CONFIG",
payload: {
invitationSubdomain: subdomain,
},
});

return subdomain;
},
onSuccess: (subdomain) => {
toast.success("도메인이 변경되었습니다.", {
description: `https://${subdomain}.invi.my`,
});
},
onError: () => {
toast.error("도메인 변경에 실패했습니다.");
},
});

const form = useForm({
defaultValues: {
subdomain: editor.config.invitationSubdomain,
},
onSubmit: async ({ value }) => {
if (value.subdomain === editor.config.invitationSubdomain) {
return;
}

await updateSubdomainMutation.mutateAsync(value.subdomain);
},
});

return (
<div className="grid w-full grid-cols-9 gap-1 border-t p-6">
<div className="col-span-9 mb-2 flex items-center">
<h4 className="text-sm font-medium">도메인 설정</h4>
</div>
<div className="col-span-9">
<EditorInput
id="subdomain"
className="pr-0.5 ring-1"
componentSuffix={
<div>
<span>.invi.my</span>
<Button size="sm" className="ml-2 h-6">
저장
</Button>
</div>
}
defaultValue={editor.config.invitationSubdomain}
onDebounceChange={() => {}}
<form
className="col-span-9"
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
form.handleSubmit();
}}
>
<form.Field
name="subdomain"
children={(field) => {
return (
<EditorInput
id={field.name}
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
className="pr-0.5 ring-1"
componentSuffix={
<div>
<span>.invi.my</span>
<Button
type="submit"
size="sm"
className="ml-2 h-6"
disabled={
updateSubdomainMutation.isPending ||
field.state.value === editor.config.invitationSubdomain
}
>
저장
</Button>
</div>
}
/>
);
}}
/>
<div className="h-5">
{true && (
{updateSubdomainMutation.isError && (
<span className="text-xs text-destructive">
중복된 도메인입니다. 다른 주소를 사용해주세요.
{updateSubdomainMutation.error?.message}
</span>
)}
</div>
</div>
</form>
</div>
);
}
Expand Down
44 changes: 27 additions & 17 deletions src/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,30 +5,40 @@ export const config = {
matcher: ["/((?!api/|_next/|_static/|_vercel|[\\w-]+\\.\\w+).*)"],
};

function isSubdomain(hostname: string) {
const subdomain = hostname.split(".")[0];

if (!subdomain) {
return false;
}

return (
!hostname.startsWith("localhost:") && !["www", "invi"].includes(hostname)
);
}

export function middleware(request: NextRequest) {
const url = request.nextUrl;
const hostname = request.headers.get("host")!;
const searchParams = url.searchParams.toString();
const path = `${url.pathname}${searchParams ? `?${searchParams}` : ""}`;

const isSubdomain = (str: string) => {
return !str.startsWith("localhost:") && !["www", "invi"].includes(str);
};

const hostnameParts = hostname.split(".");
const potentialSubdomain = hostnameParts[0];

let subdomain: string | null = null;

if (isSubdomain(potentialSubdomain)) {
subdomain = potentialSubdomain;
// 서브도메인으로 접근한 경우
if (isSubdomain(hostname)) {
const subdomain = hostname.split(".")[0];
return NextResponse.rewrite(new URL(`/i/${subdomain}${path}`, request.url));
}

if (subdomain) {
return NextResponse.rewrite(
new URL(`/pg/${subdomain}${path}`, request.url),
);
} else {
return NextResponse.rewrite(new URL(path, request.url));
// /i/* 경로로 접근한 경우
const match = path.match(/^\/i\/([^\/]+)(\/.*)?$/);
if (match) {
const subdomain = match[1];
const remainingPath = match[2] || "";
const newUrl = new URL(request.url);
newUrl.hostname = `${subdomain}.${newUrl.hostname}`;
newUrl.pathname = remainingPath;
return NextResponse.redirect(newUrl);
}

return NextResponse.rewrite(new URL(path, request.url));
}
Loading