Skip to content

Commit

Permalink
feat: 초대장 페이지 (#69)
Browse files Browse the repository at this point in the history
* feat: invi page preview

* feat: add setting

* feat: seo

* feat: add page not found

* feat: save subdomain

* feat: navigation > 초대장 보기

* feat: subdomain

* feat: block drag image

* feat: apply subdomain

* feat: seo desc
  • Loading branch information
bepyan authored Aug 18, 2024
1 parent 276e373 commit fe6a13b
Show file tree
Hide file tree
Showing 10 changed files with 218 additions and 44 deletions.
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
2 changes: 2 additions & 0 deletions src/components/editor/sidebar-element-settings-tab/index.tsx
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));
}

0 comments on commit fe6a13b

Please sign in to comment.