Skip to content

Commit

Permalink
tiptap embed node view 구현
Browse files Browse the repository at this point in the history
  • Loading branch information
devunt committed Jan 21, 2024
1 parent b742a42 commit 80f3a10
Show file tree
Hide file tree
Showing 19 changed files with 381 additions and 7 deletions.
1 change: 1 addition & 0 deletions apps/penxle.com/.env
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ PRIVATE_GOOGLE_CLIENT_SECRET=
PRIVATE_DATABASE_URL=
PRIVATE_ELASTICSEARCH_API_KEY=
PRIVATE_ELASTICSEARCH_CLOUD_ID=
PRIVATE_IFRAMELY_API_KEY=
PRIVATE_NAVER_CLIENT_ID=
PRIVATE_NAVER_CLIENT_SECRET=
PRIVATE_OPENSEARCH_URL=
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
-- AlterTable
ALTER TABLE "users" ALTER COLUMN "role" SET DEFAULT 'USER';

-- CreateTable
CREATE TABLE "embeds" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"url" TEXT NOT NULL,
"title" TEXT,
"description" TEXT,
"image_url" TEXT,
"html" TEXT,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "embeds_pkey" PRIMARY KEY ("id")
);

-- AddForeignKey
ALTER TABLE "embeds" ADD CONSTRAINT "embeds_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/*
Warnings:
- A unique constraint covering the columns `[url]` on the table `embeds` will be added. If there are existing duplicate values, this will fail.
*/

-- CreateIndex
CREATE UNIQUE INDEX "embeds_url_key" ON "embeds"("url");
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/*
Warnings:
- You are about to drop the column `image_url` on the `embeds` table. All the data in the column will be lost.
- You are about to drop the column `user_id` on the `embeds` table. All the data in the column will be lost.
- The `content_filters` column on the `posts` table would be dropped and recreated. This will lead to data loss if there is data in the column.
- Added the required column `site` to the `embeds` table without a default value. This is not possible if the table is not empty.
- Added the required column `type` to the `embeds` table without a default value. This is not possible if the table is not empty.
*/
-- DropForeignKey
ALTER TABLE "embeds" DROP CONSTRAINT "embeds_user_id_fkey";

-- AlterTable
ALTER TABLE "embeds" DROP COLUMN "image_url",
DROP COLUMN "user_id",
ADD COLUMN "site" TEXT NOT NULL,
ADD COLUMN "thumbnail_url" TEXT,
ADD COLUMN "type" TEXT NOT NULL;
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
/*
Warnings:
- You are about to drop the column `site` on the `embeds` table. All the data in the column will be lost.
- The `content_filters` column on the `posts` table would be dropped and recreated. This will lead to data loss if there is data in the column.
*/
-- AlterTable
ALTER TABLE "embeds" DROP COLUMN "site";
13 changes: 13 additions & 0 deletions apps/penxle.com/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,19 @@ model BookmarkGroupPost {
@@map("bookmark_posts")
}

model Embed {
id String @id
type String
url String @unique
title String?
description String?
thumbnailUrl String? @map("thumbnail_url")
html String?
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
@@map("embeds")
}

model File {
id String @id
userId String @map("user_id")
Expand Down
15 changes: 15 additions & 0 deletions apps/penxle.com/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,16 @@ input DeleteUserInput {
email: String!
}

type Embed {
description: String
html: String
id: ID!
thumbnailUrl: String
title: String
type: String!
url: String!
}

type File {
id: ID!
name: String!
Expand Down Expand Up @@ -234,6 +244,7 @@ type Mutation {
unbookmarkPost(input: UnbookmarkPostInput!): Post!
unfollowSpace(input: UnfollowSpaceInput!): Space!
unfollowTag(input: UnfollowTagInput!): Tag!
unfurlEmbed(input: UnfurlEmbedInput!): Embed
unlikePost(input: UnlikePostInput!): Post!
unlinkUserSingleSignOn(input: UnlinkUserSingleSignOnInput!): User!
unlockPasswordedPost(input: UnlockPasswordedPostInput!): Post!
Expand Down Expand Up @@ -692,6 +703,10 @@ input UnfollowTagInput {
tagId: ID!
}

input UnfurlEmbedInput {
url: String!
}

input UnlikePostInput {
postId: ID!
}
Expand Down
26 changes: 26 additions & 0 deletions apps/penxle.com/src/lib/server/external-api/iframely.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import got from 'got';
import { env } from '$env/dynamic/private';

export const unfurl = async (url: string) => {
const resp = await got({
url: 'https://iframe.ly/api/oembed',
method: 'GET',
searchParams: {
api_key: env.PRIVATE_IFRAMELY_API_KEY,
url,
omit_script: 1,
},
}).json<Record<string, string>>();

if (resp.error) {
throw new Error(resp.error);
}

return {
type: resp.type,
title: resp.title,
description: resp.description,
thumbnailUrl: resp.thumbnail_url,
html: resp.html,
};
};
1 change: 1 addition & 0 deletions apps/penxle.com/src/lib/server/external-api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@ export * as aws from './aws';
export * as coocon from './coocon';
export * as exim from './exim';
export * as google from './google';
export * as iframely from './iframely';
export * as naver from './naver';
export * as portone from './portone';
79 changes: 79 additions & 0 deletions apps/penxle.com/src/lib/server/graphql/schemas/embed.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { iframely } from '$lib/server/external-api';
import { createId } from '$lib/utils';
import { defineSchema } from '../builder';

export const embedSchema = defineSchema((builder) => {
/**
* * Types
*/

builder.prismaObject('Embed', {
fields: (t) => ({
id: t.exposeID('id'),
type: t.exposeString('type'),
url: t.exposeString('url'),
title: t.exposeString('title', { nullable: true }),
description: t.exposeString('description', { nullable: true }),
thumbnailUrl: t.exposeString('thumbnailUrl', { nullable: true }),
html: t.exposeString('html', { nullable: true }),
}),
});

/**
* * Inputs
*/

const UnfurlEmbedInput = builder.inputType('UnfurlEmbedInput', {
fields: (t) => ({
url: t.string(),
}),
});

/**
* * Queries
*/

// builder.queryFields((t) => ({
// }));

/**
* * Mutations
*/

builder.mutationFields((t) => ({
unfurlEmbed: t.withAuth({ user: true }).prismaField({
type: 'Embed',
nullable: true,
args: { input: t.arg({ type: UnfurlEmbedInput }) },
resolve: async (query, _, { input }, { db }) => {
const embed = await db.embed.findUnique({
...query,
where: { url: input.url },
});

if (embed) {
return embed;
}

try {
const meta = await iframely.unfurl(input.url);

return await db.embed.create({
...query,
data: {
id: createId(),
type: meta.type,
url: input.url,
title: meta.title,
description: meta.description,
thumbnailUrl: meta.thumbnailUrl,
html: meta.html,
},
});
} catch {
return null;
}
},
}),
}));
});
2 changes: 2 additions & 0 deletions apps/penxle.com/src/lib/server/graphql/schemas/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { addSchema, createBuilder } from '../builder';
import { bookmarkSchema } from './bookmark';
import { collectionSchema } from './collection';
import { devSchema } from './dev';
import { embedSchema } from './embed';
import { enumsSchema } from './enums';
import { feedSchema } from './feed';
import { fileSchema } from './file';
Expand All @@ -23,6 +24,7 @@ addSchema(builder, [
bookmarkSchema,
collectionSchema,
devSchema,
embedSchema,
enumsSchema,
feedSchema,
fileSchema,
Expand Down
14 changes: 9 additions & 5 deletions apps/penxle.com/src/lib/server/prisma/logging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,13 @@ const interpolateQuery = (query: string, params: unknown[]) => {
};

export const logging = (e: Prisma.QueryEvent) => {
logger.trace({
context: 'database',
query: interpolateQuery(e.query, JSON.parse(e.params)),
duration: e.duration,
});
try {
logger.trace({
context: 'database',
query: interpolateQuery(e.query, JSON.parse(e.params)),
duration: e.duration,
});
} catch {
// pass
}
};
29 changes: 29 additions & 0 deletions apps/penxle.com/src/lib/server/utils/tiptap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,35 @@ export const decorateContent = async (
};
}

if (node.type === 'embed') {
if (!node.attrs?.url) {
return node;
}

const embed = await db.embed.findUnique({
select: { type: true, title: true, description: true, thumbnailUrl: true, html: true },
where: { url: node.attrs.url },
});

if (!embed) {
return node;
}

return {
...node,
attrs: {
...node.attrs,
__data: {
type: embed.type,
title: embed.title,
description: embed.description,
thumbnailUrl: embed.thumbnailUrl,
html: embed.html,
},
},
};
}

return node;
}),
);
Expand Down
88 changes: 88 additions & 0 deletions apps/penxle.com/src/lib/tiptap/node-views/embed/Component.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<script lang="ts">
import { Link } from '@penxle/ui';
import { RingSpinner } from '@penxle/ui/spinners';
import { onMount } from 'svelte';
import { graphql } from '$glitch';
import { NodeView } from '$lib/tiptap';
import type { NodeViewProps } from '$lib/tiptap';
type $$Props = NodeViewProps;
$$restProps;
export let node: NodeViewProps['node'];
export let editor: NodeViewProps['editor'] | undefined;
// export let selected: NodeViewProps['selected'] | undefined;
export let deleteNode: NodeViewProps['deleteNode'] | undefined;
export let getPos: NodeViewProps['getPos'] | undefined;
export let updateAttributes: NodeViewProps['updateAttributes'] | undefined;
const unfurlEmbed = graphql(`
mutation TiptapEmbed_UnfurlEmbed_Mutation($input: UnfurlEmbedInput!) {
unfurlEmbed(input: $input) {
id
type
url
title
description
thumbnailUrl
html
}
}
`);
const unfurl = async () => {
try {
const resp = await unfurlEmbed({ url: node.attrs.url });
if (!resp) {
throw new Error('Unfurl failed');
}
updateAttributes?.({
url: resp.url,
__data: resp,
});
} catch {
if (getPos) {
editor?.commands.insertContentAt(getPos(), node.attrs.url, { updateSelection: false });
deleteNode?.();
}
}
};
onMount(() => {
if (!node.attrs.__data) {
unfurl();
}
});
</script>

<svelte:head>
<script async src="https://cdn.iframe.ly/embed.js"></script>
</svelte:head>

<NodeView class="tiptap-embedded">
{#if node.attrs.__data}
{#if node.attrs.__data.html}
<div class="w-full">
{@html node.attrs.__data.html}
</div>
{:else}
<Link class="border flex w-full items-center gap-4" href={node.attrs.url}>
{#if node.attrs.__data.thumbnailUrl}
<div class="w-100px">
<img class="aspect-1/1 object-cover" alt="" src={node.attrs.__data.thumbnailUrl} />
</div>
{/if}
<div class="grow p-4">
<div class="font-bold">{node.attrs.__data.title}</div>
<div class="text-sm text-gray-50">{node.attrs.__data.description}</div>
</div>
</Link>
{/if}
{:else}
<p class="flex gap-2 items-center text-gray-50 py-1">
{node.attrs.url}
<RingSpinner class="square-4" />
</p>
{/if}
</NodeView>
Loading

0 comments on commit 80f3a10

Please sign in to comment.