mirror of
https://github.com/linsa-io/linsa.git
synced 2026-01-12 12:20:23 +01:00
chore(start): update to alpha and fix editor image removed (#187)
* chore(start): update to alpha and fix editor image removed * chore: fix image editor
This commit is contained in:
@@ -1,36 +1,38 @@
|
||||
import { defineConfig } from "@tanstack/start/config"
|
||||
import tsConfigPaths from "vite-tsconfig-paths"
|
||||
|
||||
const is_tauri = process.env.TAURI_ENV_TARGET_TRIPLE !== undefined;
|
||||
const is_tauri = process.env.TAURI_ENV_TARGET_TRIPLE !== undefined
|
||||
|
||||
let config = is_tauri ? defineConfig({
|
||||
vite: {
|
||||
envPrefix: ['VITE_', 'TAURI_ENV_*'],
|
||||
build: {
|
||||
target:
|
||||
process.env.TAURI_ENV_PLATFORM == 'windows'
|
||||
? 'chrome105'
|
||||
: 'safari13',
|
||||
minify: !process.env.TAURI_ENV_DEBUG ? 'esbuild' : false,
|
||||
sourcemap: !!process.env.TAURI_ENV_DEBUG,
|
||||
},
|
||||
plugins: [
|
||||
tsConfigPaths({
|
||||
projects: ["./tsconfig.json"],
|
||||
}),
|
||||
],
|
||||
},
|
||||
server: {
|
||||
preset: "static"
|
||||
}
|
||||
}) : defineConfig({
|
||||
vite: {
|
||||
plugins: [
|
||||
tsConfigPaths({
|
||||
projects: ["./tsconfig.json"],
|
||||
}),
|
||||
],
|
||||
},
|
||||
})
|
||||
const config = is_tauri
|
||||
? defineConfig({
|
||||
vite: {
|
||||
envPrefix: ["VITE_", "TAURI_ENV_*"],
|
||||
build: {
|
||||
target:
|
||||
process.env.TAURI_ENV_PLATFORM == "windows"
|
||||
? "chrome105"
|
||||
: "safari13",
|
||||
minify: !process.env.TAURI_ENV_DEBUG ? "esbuild" : false,
|
||||
sourcemap: !!process.env.TAURI_ENV_DEBUG,
|
||||
},
|
||||
plugins: [
|
||||
tsConfigPaths({
|
||||
projects: ["./tsconfig.json"],
|
||||
}),
|
||||
],
|
||||
},
|
||||
server: {
|
||||
preset: "static",
|
||||
},
|
||||
})
|
||||
: defineConfig({
|
||||
vite: {
|
||||
plugins: [
|
||||
tsConfigPaths({
|
||||
projects: ["./tsconfig.json"],
|
||||
}),
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
export default config;
|
||||
export default config
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { createServerFn } from "@tanstack/start"
|
||||
import { get } from "ronin"
|
||||
|
||||
export const getFeatureFlag = createServerFn(
|
||||
"GET",
|
||||
async (data: { name: string }) => {
|
||||
export const getFeatureFlag = createServerFn({ method: "GET" })
|
||||
.validator((input: string) => input)
|
||||
.handler(async ({ data }) => {
|
||||
const response = await get.featureFlag.with({
|
||||
name: data.name,
|
||||
name: data,
|
||||
})
|
||||
|
||||
return response
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
@@ -5,4 +5,4 @@ import { createRouter } from "./router"
|
||||
|
||||
const router = createRouter()
|
||||
|
||||
hydrateRoot(document.getElementById("root")!, <StartClient router={router} />)
|
||||
hydrateRoot(document!, <StartClient router={router} />)
|
||||
|
||||
@@ -32,11 +32,12 @@ import { Editor } from "@tiptap/react"
|
||||
import { createServerFn } from "@tanstack/start"
|
||||
import { clerkClient, getAuth } from "@clerk/tanstack-start/server"
|
||||
import { create } from "ronin"
|
||||
import { getWebRequest } from "vinxi/http"
|
||||
|
||||
export const sendFeedbackFn = createServerFn(
|
||||
"POST",
|
||||
async (data: { content: string }, { request }) => {
|
||||
const auth = await getAuth(request)
|
||||
export const sendFeedbackFn = createServerFn({ method: "POST" })
|
||||
.validator((content: string) => content)
|
||||
.handler(async ({ data }) => {
|
||||
const auth = await getAuth(getWebRequest())
|
||||
|
||||
if (!auth.userId) {
|
||||
throw new Error("User not authenticated")
|
||||
@@ -46,11 +47,10 @@ export const sendFeedbackFn = createServerFn(
|
||||
telemetry: { disabled: true },
|
||||
}).users.getUser(auth.userId)
|
||||
await create.feedback.with({
|
||||
message: data.content,
|
||||
message: data,
|
||||
emailFrom: user.emailAddresses[0].emailAddress,
|
||||
})
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
const formSchema = z.object({
|
||||
content: z.string().min(1, {
|
||||
@@ -83,7 +83,7 @@ export function Feedback() {
|
||||
async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
try {
|
||||
setIsPending(true)
|
||||
await sendFeedbackFn(values)
|
||||
await sendFeedbackFn({ data: values.content })
|
||||
|
||||
form.reset({ content: "" })
|
||||
editorRef.current?.commands.clearContent()
|
||||
|
||||
@@ -20,7 +20,7 @@ export const JournalSection: React.FC = () => {
|
||||
setIsFetching(true)
|
||||
|
||||
if (isLoaded && isSignedIn) {
|
||||
const response = await getFeatureFlag({ name: "JOURNAL" })
|
||||
const response = await getFeatureFlag({ data: "JOURNAL" })
|
||||
|
||||
if (
|
||||
user?.emailAddresses.some((email) =>
|
||||
|
||||
@@ -32,7 +32,7 @@ export const TaskSection: React.FC = () => {
|
||||
setIsFetching(true)
|
||||
|
||||
if (isLoaded && isSignedIn) {
|
||||
const response = await getFeatureFlag({ name: "TASK" })
|
||||
const response = await getFeatureFlag({ data: "TASK" })
|
||||
|
||||
if (
|
||||
user?.emailAddresses.some((email) =>
|
||||
|
||||
@@ -5,15 +5,9 @@ import {
|
||||
ScrollRestoration,
|
||||
createRootRouteWithContext,
|
||||
} from "@tanstack/react-router"
|
||||
import {
|
||||
Body,
|
||||
createServerFn,
|
||||
Head,
|
||||
Html,
|
||||
Meta,
|
||||
Scripts,
|
||||
} from "@tanstack/start"
|
||||
import { createServerFn, Meta, Scripts } from "@tanstack/start"
|
||||
import * as React from "react"
|
||||
import { getWebRequest } from "vinxi/http"
|
||||
import { DefaultCatchBoundary } from "~/components/DefaultCatchBoundary.js"
|
||||
import { NotFound } from "~/components/NotFound.js"
|
||||
import { seo } from "~/lib/utils/seo"
|
||||
@@ -28,8 +22,8 @@ export const TanStackRouterDevtools =
|
||||
})),
|
||||
)
|
||||
|
||||
export const fetchClerkAuth = createServerFn("GET", async (_, ctx) => {
|
||||
const auth = await getAuth(ctx.request)
|
||||
export const fetchClerkAuth = createServerFn().handler(async () => {
|
||||
const auth = await getAuth(getWebRequest())
|
||||
|
||||
return auth
|
||||
})
|
||||
@@ -37,44 +31,49 @@ export const fetchClerkAuth = createServerFn("GET", async (_, ctx) => {
|
||||
export const Route = createRootRouteWithContext<{
|
||||
auth?: ReturnType<typeof getAuth> | null
|
||||
}>()({
|
||||
meta: () => [
|
||||
{
|
||||
charSet: "utf-8",
|
||||
},
|
||||
{
|
||||
name: "viewport",
|
||||
content: "width=device-width, initial-scale=1",
|
||||
},
|
||||
...seo({
|
||||
title: "Learn Anything",
|
||||
description:
|
||||
"Discover and learn about any topic with Learn-Anything. Our free, comprehensive platform connects you to the best resources for every subject. Start learning today!",
|
||||
keywords:
|
||||
"learn anything, online learning, free education, educational resources, self-study, knowledge discovery, topic exploration, skill development, lifelong learning",
|
||||
}),
|
||||
],
|
||||
links: () => [
|
||||
{ rel: "stylesheet", href: appCss },
|
||||
{
|
||||
rel: "apple-touch-icon",
|
||||
sizes: "180x180",
|
||||
href: "/apple-touch-icon.png",
|
||||
},
|
||||
{
|
||||
rel: "icon",
|
||||
type: "image/png",
|
||||
sizes: "32x32",
|
||||
href: "/favicon-32x32.png",
|
||||
},
|
||||
{
|
||||
rel: "icon",
|
||||
type: "image/png",
|
||||
sizes: "16x16",
|
||||
href: "/favicon-16x16.png",
|
||||
},
|
||||
{ rel: "manifest", href: "/site.webmanifest", color: "#fffff" },
|
||||
{ rel: "icon", href: "/favicon.ico" },
|
||||
],
|
||||
head() {
|
||||
return {
|
||||
meta: [
|
||||
{
|
||||
charSet: "utf-8",
|
||||
},
|
||||
{
|
||||
name: "viewport",
|
||||
content: "width=device-width, initial-scale=1",
|
||||
},
|
||||
...seo({
|
||||
title: "Learn Anything",
|
||||
description:
|
||||
"Discover and learn about any topic with Learn-Anything. Our free, comprehensive platform connects you to the best resources for every subject. Start learning today!",
|
||||
keywords:
|
||||
"learn anything, online learning, free education, educational resources, self-study, knowledge discovery, topic exploration, skill development, lifelong learning",
|
||||
}),
|
||||
],
|
||||
links: [
|
||||
{ rel: "stylesheet", href: appCss },
|
||||
{
|
||||
rel: "apple-touch-icon",
|
||||
sizes: "180x180",
|
||||
href: "/apple-touch-icon.png",
|
||||
},
|
||||
{
|
||||
rel: "icon",
|
||||
type: "image/png",
|
||||
sizes: "32x32",
|
||||
href: "/favicon-32x32.png",
|
||||
},
|
||||
{
|
||||
rel: "icon",
|
||||
type: "image/png",
|
||||
sizes: "16x16",
|
||||
href: "/favicon-16x16.png",
|
||||
},
|
||||
{ rel: "manifest", href: "/site.webmanifest", color: "#fffff" },
|
||||
{ rel: "icon", href: "/favicon.ico" },
|
||||
],
|
||||
}
|
||||
},
|
||||
|
||||
beforeLoad: async (ctx) => {
|
||||
try {
|
||||
// Handle explicit null auth (logged out state)
|
||||
@@ -121,20 +120,17 @@ function RootComponent() {
|
||||
|
||||
function RootDocument({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<Html>
|
||||
<Head>
|
||||
<html lang="en" suppressHydrationWarning={true}>
|
||||
<head>
|
||||
<Meta />
|
||||
</Head>
|
||||
<Body>
|
||||
</head>
|
||||
<body>
|
||||
{children}
|
||||
|
||||
<React.Suspense>
|
||||
<TanStackRouterDevtools position="bottom-right" />
|
||||
</React.Suspense>
|
||||
|
||||
<ScrollRestoration />
|
||||
<TanStackRouterDevtools position="bottom-right" />
|
||||
<Scripts />
|
||||
</Body>
|
||||
</Html>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"
|
||||
|
||||
export const Route = createFileRoute("/_layout/(auth)/_auth")({
|
||||
beforeLoad({ context }) {
|
||||
if (context.auth?.userId) {
|
||||
beforeLoad: async ({ context }) => {
|
||||
const auth = await context.auth
|
||||
if (auth?.userId) {
|
||||
throw redirect({ to: "/links", replace: true })
|
||||
}
|
||||
},
|
||||
|
||||
@@ -228,7 +228,7 @@ export const LinkItem = React.forwardRef<HTMLDivElement, LinkItemProps>(
|
||||
<Link
|
||||
to={ensureUrlProtocol(link.url)}
|
||||
target="_blank"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||||
className="text-xs text-muted-foreground hover:text-primary"
|
||||
>
|
||||
<span className="line-clamp-1">{link.url}</span>
|
||||
|
||||
@@ -191,7 +191,9 @@ export const LinkItem = React.forwardRef<HTMLDivElement, LinkItemProps>(
|
||||
<Link
|
||||
to={ensureUrlProtocol(personalLink.url)}
|
||||
target="_blank"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onClick={(e: React.MouseEvent<HTMLAnchorElement>) =>
|
||||
e.stopPropagation()
|
||||
}
|
||||
className="mr-1 truncate text-xs hover:text-primary"
|
||||
>
|
||||
{personalLink.url}
|
||||
|
||||
@@ -44,70 +44,72 @@ export const globalLinkFormExceptionRefsAtom = atom<
|
||||
React.RefObject<HTMLElement>[]
|
||||
>([])
|
||||
|
||||
export const getMetadata = createServerFn("GET", async (url: string) => {
|
||||
if (!url) {
|
||||
return new Response('Missing "url" query parameter', {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
const result = urlSchema.safeParse(decodeURIComponent(url))
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
result.error.issues.map((issue) => issue.message).join(", "),
|
||||
)
|
||||
}
|
||||
|
||||
url = ensureUrlProtocol(decodeURIComponent(url))
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
export const getMetadata = createServerFn()
|
||||
.validator((url: string) => url)
|
||||
.handler(async ({ data: url }) => {
|
||||
if (!url) {
|
||||
return new Response('Missing "url" query parameter', {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
const data = await response.text()
|
||||
const $ = cheerio.load(data)
|
||||
const result = urlSchema.safeParse(decodeURIComponent(url))
|
||||
|
||||
const metadata: Metadata = {
|
||||
title:
|
||||
$("title").text() ||
|
||||
$('meta[property="og:title"]').attr("content") ||
|
||||
DEFAULT_VALUES.TITLE,
|
||||
description:
|
||||
$('meta[name="description"]').attr("content") ||
|
||||
$('meta[property="og:description"]').attr("content") ||
|
||||
DEFAULT_VALUES.DESCRIPTION,
|
||||
icon:
|
||||
$('link[rel="icon"]').attr("href") ||
|
||||
$('link[rel="shortcut icon"]').attr("href") ||
|
||||
DEFAULT_VALUES.FAVICON,
|
||||
url: url,
|
||||
if (!result.success) {
|
||||
throw new Error(
|
||||
result.error.issues.map((issue) => issue.message).join(", "),
|
||||
)
|
||||
}
|
||||
|
||||
if (metadata.icon && !metadata.icon.startsWith("http")) {
|
||||
metadata.icon = new URL(metadata.icon, url).toString()
|
||||
}
|
||||
url = ensureUrlProtocol(decodeURIComponent(url))
|
||||
|
||||
return metadata
|
||||
} catch (error) {
|
||||
console.error("Error fetching metadata:", error)
|
||||
const defaultMetadata: Metadata = {
|
||||
title: DEFAULT_VALUES.TITLE,
|
||||
description: DEFAULT_VALUES.DESCRIPTION,
|
||||
icon: DEFAULT_VALUES.FAVICON,
|
||||
url: url,
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.text()
|
||||
const $ = cheerio.load(data)
|
||||
|
||||
const metadata: Metadata = {
|
||||
title:
|
||||
$("title").text() ||
|
||||
$('meta[property="og:title"]').attr("content") ||
|
||||
DEFAULT_VALUES.TITLE,
|
||||
description:
|
||||
$('meta[name="description"]').attr("content") ||
|
||||
$('meta[property="og:description"]').attr("content") ||
|
||||
DEFAULT_VALUES.DESCRIPTION,
|
||||
icon:
|
||||
$('link[rel="icon"]').attr("href") ||
|
||||
$('link[rel="shortcut icon"]').attr("href") ||
|
||||
DEFAULT_VALUES.FAVICON,
|
||||
url: url,
|
||||
}
|
||||
|
||||
if (metadata.icon && !metadata.icon.startsWith("http")) {
|
||||
metadata.icon = new URL(metadata.icon, url).toString()
|
||||
}
|
||||
|
||||
return metadata
|
||||
} catch (error) {
|
||||
console.error("Error fetching metadata:", error)
|
||||
const defaultMetadata: Metadata = {
|
||||
title: DEFAULT_VALUES.TITLE,
|
||||
description: DEFAULT_VALUES.DESCRIPTION,
|
||||
icon: DEFAULT_VALUES.FAVICON,
|
||||
url: url,
|
||||
}
|
||||
return defaultMetadata
|
||||
}
|
||||
return defaultMetadata
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
interface LinkFormProps extends React.ComponentPropsWithoutRef<"form"> {
|
||||
onClose?: () => void
|
||||
@@ -196,7 +198,7 @@ export const LinkForm: React.FC<LinkFormProps> = ({
|
||||
const fetchMetadata = async (url: string) => {
|
||||
setIsFetching(true)
|
||||
try {
|
||||
const data = await getMetadata(encodeURIComponent(url))
|
||||
const data = await getMetadata({ data: encodeURIComponent(url) })
|
||||
setUrlFetched(data.url)
|
||||
form.setValue("url", data.url, {
|
||||
shouldValidate: true,
|
||||
|
||||
@@ -67,7 +67,7 @@ export const Route = createFileRoute("/_layout/_pages/_protected/tasks/")({
|
||||
// throw new Error("Unauthorized")
|
||||
// }
|
||||
|
||||
// const flag = await getFeatureFlag({ name: "TASK" })
|
||||
// const flag = await getFeatureFlag({ data: "TASK" })
|
||||
// const canAccess = context.user?.emailAddresses.some((email) =>
|
||||
// flag?.emails.includes(email.emailAddress),
|
||||
// )
|
||||
|
||||
@@ -49,7 +49,7 @@ const SearchItem: React.FC<SearchItemProps> = ({
|
||||
<div className="group flex items-center justify-between">
|
||||
<Link
|
||||
to={href}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||||
className="text-sm font-medium hover:text-primary hover:opacity-70"
|
||||
>
|
||||
{title}
|
||||
@@ -57,7 +57,7 @@ const SearchItem: React.FC<SearchItemProps> = ({
|
||||
{subtitle && (
|
||||
<Link
|
||||
to={href}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||||
className="ml-2 truncate text-xs text-muted-foreground hover:underline"
|
||||
>
|
||||
{subtitle}
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
"lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@clerk/tanstack-start": "^0.4.26",
|
||||
"@clerk/themes": "^2.1.43",
|
||||
"@clerk/tanstack-start": "^0.4.28",
|
||||
"@clerk/themes": "^2.1.45",
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@dnd-kit/modifiers": "^7.0.0",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
@@ -28,7 +28,7 @@
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
"@radix-ui/react-dismissable-layer": "^1.1.1",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||
"@radix-ui/react-icons": "^1.3.1",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-label": "^2.1.0",
|
||||
"@radix-ui/react-popover": "^1.1.2",
|
||||
"@radix-ui/react-scroll-area": "^1.2.1",
|
||||
@@ -39,10 +39,10 @@
|
||||
"@radix-ui/react-toggle": "^1.1.0",
|
||||
"@radix-ui/react-toggle-group": "^1.1.0",
|
||||
"@radix-ui/react-tooltip": "^1.1.4",
|
||||
"@tanstack/react-router": "^1.81.4",
|
||||
"@tanstack/react-router": "^1.82.0",
|
||||
"@tanstack/react-virtual": "^3.10.9",
|
||||
"@tanstack/router-zod-adapter": "^1.81.4",
|
||||
"@tanstack/start": "^1.81.4",
|
||||
"@tanstack/router-zod-adapter": "^1.81.5",
|
||||
"@tanstack/start": "^1.82.0",
|
||||
"@tiptap/core": "^2.9.1",
|
||||
"@tiptap/extension-code-block-lowlight": "^2.9.1",
|
||||
"@tiptap/extension-color": "^2.9.1",
|
||||
@@ -65,11 +65,11 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "1.0.2",
|
||||
"date-fns": "^3.6.0",
|
||||
"framer-motion": "^11.11.15",
|
||||
"jazz-browser-media-images": "^0.8.20",
|
||||
"jazz-react": "^0.8.20",
|
||||
"jazz-react-auth-clerk": "^0.8.20",
|
||||
"jazz-tools": "^0.8.19",
|
||||
"framer-motion": "^11.11.17",
|
||||
"jazz-browser-media-images": "^0.8.22",
|
||||
"jazz-react": "^0.8.22",
|
||||
"jazz-react-auth-clerk": "^0.8.22",
|
||||
"jazz-tools": "^0.8.21",
|
||||
"jotai": "^2.10.2",
|
||||
"lowlight": "^3.1.0",
|
||||
"lucide-react": "^0.446.0",
|
||||
@@ -92,9 +92,9 @@
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ronin/learn-anything": "^0.0.0-3461302210631",
|
||||
"@ronin/learn-anything": "^0.0.0-3461795804931",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@tanstack/router-devtools": "^1.81.4",
|
||||
"@tanstack/router-devtools": "^1.82.0",
|
||||
"@types/node": "^22.9.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
@@ -109,7 +109,7 @@
|
||||
"postcss": "^8.4.49",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier-plugin-tailwindcss": "^0.5.14",
|
||||
"tailwindcss": "^3.4.14",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "^5.6.3",
|
||||
"vite-tsconfig-paths": "^5.1.2"
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createServerFn } from "@tanstack/start"
|
||||
import { create, drop } from "ronin"
|
||||
import { z } from "zod"
|
||||
import { ALLOWED_FILE_TYPES, MAX_FILE_SIZE } from "./constants"
|
||||
import { getWebRequest } from "vinxi/http"
|
||||
|
||||
const ImageRuleSchema = z.object({
|
||||
file: z
|
||||
@@ -16,10 +17,10 @@ const ImageRuleSchema = z.object({
|
||||
}),
|
||||
})
|
||||
|
||||
export const storeImageFn = createServerFn(
|
||||
"POST",
|
||||
async (data: FormData, { request }) => {
|
||||
const auth = await getAuth(request)
|
||||
export const storeImageFn = createServerFn({ method: "POST" })
|
||||
.validator((data: FormData) => data)
|
||||
.handler(async ({ data }) => {
|
||||
const auth = await getAuth(getWebRequest())
|
||||
|
||||
if (!auth.userId) {
|
||||
throw new Error("Unauthorized")
|
||||
@@ -37,18 +38,16 @@ export const storeImageFn = createServerFn(
|
||||
})
|
||||
|
||||
return { fileModel }
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
export const deleteImageFn = createServerFn(
|
||||
"POST",
|
||||
async (data: { id: string }, { request }) => {
|
||||
const auth = await getAuth(request)
|
||||
export const deleteImageFn = createServerFn({ method: "POST" })
|
||||
.validator((id: string) => id)
|
||||
.handler(async ({ data }) => {
|
||||
const auth = await getAuth(getWebRequest())
|
||||
|
||||
if (!auth.userId) {
|
||||
throw new Error("Unauthorized")
|
||||
}
|
||||
|
||||
await drop.image.with.id(data.id)
|
||||
},
|
||||
)
|
||||
await drop.image.with.id(data)
|
||||
})
|
||||
|
||||
@@ -202,7 +202,6 @@ export const ImageViewBlock: React.FC<NodeViewProps> = ({
|
||||
|
||||
updateAttributes(normalizedData)
|
||||
} catch (error) {
|
||||
console.error("Image upload failed:", error)
|
||||
setImageState((prev) => ({
|
||||
...prev,
|
||||
error: true,
|
||||
@@ -240,7 +239,7 @@ export const ImageViewBlock: React.FC<NodeViewProps> = ({
|
||||
>
|
||||
<div className="h-full contain-paint">
|
||||
<div className="relative h-full">
|
||||
{!imageState.imageLoaded && !imageState.error && (
|
||||
{imageState.isServerUploading && !imageState.error && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Spinner className="size-7" />
|
||||
</div>
|
||||
@@ -279,6 +278,8 @@ export const ImageViewBlock: React.FC<NodeViewProps> = ({
|
||||
onError={handleImageError}
|
||||
onLoad={handleImageLoad}
|
||||
alt={node.attrs.alt || ""}
|
||||
title={node.attrs.title || ""}
|
||||
id={node.attrs.id}
|
||||
/>
|
||||
</ControlledZoom>
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import type { ImageOptions } from "@tiptap/extension-image"
|
||||
import { Image as TiptapImage } from "@tiptap/extension-image"
|
||||
import type { Editor } from "@tiptap/react"
|
||||
import type { Node } from "@tiptap/pm/model"
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react"
|
||||
import { ImageViewBlock } from "./components/image-view-block"
|
||||
import {
|
||||
FileError,
|
||||
FileValidationOptions,
|
||||
filterFiles,
|
||||
isUrl,
|
||||
randomId,
|
||||
type FileError,
|
||||
type FileValidationOptions,
|
||||
} from "@shared/editor/lib/utils"
|
||||
import { ReplaceStep } from "@tiptap/pm/transform"
|
||||
import type { Attrs } from "@tiptap/pm/model"
|
||||
|
||||
type ImageAction = "download" | "copyImage" | "copyLink"
|
||||
|
||||
@@ -23,11 +23,6 @@ interface ImageActionProps extends DownloadImageCommandProps {
|
||||
action: ImageAction
|
||||
}
|
||||
|
||||
type ImageInfo = {
|
||||
id?: string | number
|
||||
src: string
|
||||
}
|
||||
|
||||
export type UploadReturnType =
|
||||
| string
|
||||
| {
|
||||
@@ -39,18 +34,18 @@ interface CustomImageOptions
|
||||
extends ImageOptions,
|
||||
Omit<FileValidationOptions, "allowBase64"> {
|
||||
uploadFn?: (file: File, editor: Editor) => Promise<UploadReturnType>
|
||||
onImageRemoved?: (props: ImageInfo) => void
|
||||
onImageRemoved?: (props: Attrs) => void
|
||||
onActionSuccess?: (props: ImageActionProps) => void
|
||||
onActionError?: (error: Error, props: ImageActionProps) => void
|
||||
customDownloadImage?: (
|
||||
downloadImage?: (
|
||||
props: ImageActionProps,
|
||||
options: CustomImageOptions,
|
||||
) => Promise<void>
|
||||
customCopyImage?: (
|
||||
copyImage?: (
|
||||
props: ImageActionProps,
|
||||
options: CustomImageOptions,
|
||||
) => Promise<void>
|
||||
customCopyLink?: (
|
||||
copyLink?: (
|
||||
props: ImageActionProps,
|
||||
options: CustomImageOptions,
|
||||
) => Promise<void>
|
||||
@@ -133,7 +128,7 @@ const saveImage = async (
|
||||
URL.revokeObjectURL(imageURL)
|
||||
}
|
||||
|
||||
const defaultDownloadImage = async (
|
||||
const downloadImage = async (
|
||||
props: ImageActionProps,
|
||||
options: CustomImageOptions,
|
||||
): Promise<void> => {
|
||||
@@ -149,7 +144,7 @@ const defaultDownloadImage = async (
|
||||
}
|
||||
}
|
||||
|
||||
const defaultCopyImage = async (
|
||||
const copyImage = async (
|
||||
props: ImageActionProps,
|
||||
options: CustomImageOptions,
|
||||
): Promise<void> => {
|
||||
@@ -164,7 +159,7 @@ const defaultCopyImage = async (
|
||||
}
|
||||
}
|
||||
|
||||
const defaultCopyLink = async (
|
||||
const copyLink = async (
|
||||
props: ImageActionProps,
|
||||
options: CustomImageOptions,
|
||||
): Promise<void> => {
|
||||
@@ -187,23 +182,34 @@ export const Image = TiptapImage.extend<CustomImageOptions>({
|
||||
maxFileSize: 0,
|
||||
uploadFn: undefined,
|
||||
onToggle: undefined,
|
||||
downloadImage: undefined,
|
||||
copyImage: undefined,
|
||||
copyLink: undefined,
|
||||
}
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
src: {
|
||||
default: null,
|
||||
},
|
||||
alt: {
|
||||
default: null,
|
||||
},
|
||||
title: {
|
||||
default: null,
|
||||
},
|
||||
id: {
|
||||
default: undefined,
|
||||
default: null,
|
||||
},
|
||||
width: {
|
||||
default: undefined,
|
||||
default: null,
|
||||
},
|
||||
height: {
|
||||
default: undefined,
|
||||
default: null,
|
||||
},
|
||||
fileName: {
|
||||
default: undefined,
|
||||
default: null,
|
||||
},
|
||||
}
|
||||
},
|
||||
@@ -228,10 +234,12 @@ export const Image = TiptapImage.extend<CustomImageOptions>({
|
||||
validImages.map((image) => {
|
||||
if (image.src instanceof File) {
|
||||
const blobUrl = URL.createObjectURL(image.src)
|
||||
const id = randomId()
|
||||
|
||||
return {
|
||||
type: this.type.name,
|
||||
attrs: {
|
||||
id: randomId(),
|
||||
id,
|
||||
src: blobUrl,
|
||||
alt: image.alt,
|
||||
title: image.title,
|
||||
@@ -256,96 +264,81 @@ export const Image = TiptapImage.extend<CustomImageOptions>({
|
||||
|
||||
return false
|
||||
},
|
||||
|
||||
downloadImage: (attrs) => () => {
|
||||
const downloadFunc =
|
||||
this.options.customDownloadImage || defaultDownloadImage
|
||||
const downloadFunc = this.options.downloadImage || downloadImage
|
||||
void downloadFunc({ ...attrs, action: "download" }, this.options)
|
||||
return true
|
||||
},
|
||||
|
||||
copyImage: (attrs) => () => {
|
||||
const copyImageFunc = this.options.customCopyImage || defaultCopyImage
|
||||
const copyImageFunc = this.options.copyImage || copyImage
|
||||
void copyImageFunc({ ...attrs, action: "copyImage" }, this.options)
|
||||
return true
|
||||
},
|
||||
|
||||
copyLink: (attrs) => () => {
|
||||
const copyLinkFunc = this.options.customCopyLink || defaultCopyLink
|
||||
const copyLinkFunc = this.options.copyLink || copyLink
|
||||
void copyLinkFunc({ ...attrs, action: "copyLink" }, this.options)
|
||||
return true
|
||||
},
|
||||
toggleImage: () => (props) => {
|
||||
const input = document.createElement("input")
|
||||
input.type = "file"
|
||||
input.accept = this.options.allowedMimeTypes.join(",")
|
||||
input.onchange = () => {
|
||||
const files = input.files
|
||||
if (!files) return
|
||||
|
||||
const [validImages, errors] = filterFiles(Array.from(files), {
|
||||
allowedMimeTypes: this.options.allowedMimeTypes,
|
||||
maxFileSize: this.options.maxFileSize,
|
||||
allowBase64: this.options.allowBase64,
|
||||
})
|
||||
toggleImage:
|
||||
() =>
|
||||
({ editor }) => {
|
||||
const input = document.createElement("input")
|
||||
input.type = "file"
|
||||
input.accept = this.options.allowedMimeTypes.join(",")
|
||||
input.onchange = () => {
|
||||
const files = input.files
|
||||
if (!files) return
|
||||
|
||||
const [validImages, errors] = filterFiles(Array.from(files), {
|
||||
allowedMimeTypes: this.options.allowedMimeTypes,
|
||||
maxFileSize: this.options.maxFileSize,
|
||||
allowBase64: this.options.allowBase64,
|
||||
})
|
||||
|
||||
if (errors.length > 0 && this.options.onValidationError) {
|
||||
this.options.onValidationError(errors)
|
||||
return false
|
||||
}
|
||||
|
||||
if (validImages.length === 0) return false
|
||||
|
||||
if (this.options.onToggle) {
|
||||
this.options.onToggle(
|
||||
editor,
|
||||
validImages,
|
||||
editor.state.selection.from,
|
||||
)
|
||||
}
|
||||
|
||||
if (errors.length > 0 && this.options.onValidationError) {
|
||||
this.options.onValidationError(errors)
|
||||
return false
|
||||
}
|
||||
|
||||
if (validImages.length === 0) return false
|
||||
|
||||
if (this.options.onToggle) {
|
||||
this.options.onToggle(
|
||||
props.editor,
|
||||
validImages,
|
||||
props.editor.state.selection.from,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
input.click()
|
||||
return true
|
||||
},
|
||||
input.click()
|
||||
return true
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
onTransaction({ transaction }) {
|
||||
if (!transaction.docChanged) return
|
||||
transaction.steps.forEach((step) => {
|
||||
if (step instanceof ReplaceStep && step.slice.size === 0) {
|
||||
const deletedPages = transaction.before.content.cut(step.from, step.to)
|
||||
|
||||
const oldDoc = transaction.before
|
||||
const newDoc = transaction.doc
|
||||
deletedPages.forEach((node) => {
|
||||
if (node.type.name === "image") {
|
||||
const attrs = node.attrs
|
||||
|
||||
const oldImages = new Map<string, ImageInfo>()
|
||||
const newImages = new Map<string, ImageInfo>()
|
||||
if (attrs.src.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(attrs.src)
|
||||
}
|
||||
|
||||
const addToMap = (node: Node, map: Map<string, ImageInfo>) => {
|
||||
if (node.type.name === "image") {
|
||||
const attrs = node.attrs
|
||||
if (attrs.src) {
|
||||
const key = attrs.id || attrs.src
|
||||
map.set(key, { id: attrs.id, src: attrs.src })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
oldDoc.descendants((node) => addToMap(node, oldImages))
|
||||
newDoc.descendants((node) => addToMap(node, newImages))
|
||||
|
||||
oldImages.forEach((imageInfo, key) => {
|
||||
if (!newImages.has(key)) {
|
||||
if (imageInfo.src.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(imageInfo.src)
|
||||
}
|
||||
|
||||
if (
|
||||
!imageInfo.src.startsWith("blob:") &&
|
||||
!imageInfo.src.startsWith("data:") &&
|
||||
isUrl(imageInfo.src)
|
||||
) {
|
||||
this.options.onImageRemoved?.({
|
||||
id: imageInfo.id,
|
||||
src: imageInfo.src,
|
||||
})
|
||||
}
|
||||
this.options.onImageRemoved?.(attrs)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Content, EditorOptions, UseEditorOptions } from "@tiptap/react"
|
||||
import { useEditor } from "@tiptap/react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useThrottleCallback } from "@shared/hooks/use-throttle-callback"
|
||||
import { getOutput } from "@shared/editor/lib/utils"
|
||||
import { getOutput, randomId } from "@shared/editor/lib/utils"
|
||||
import { StarterKit } from "@shared/editor/extensions/starter-kit"
|
||||
import { TaskList } from "@shared/editor/extensions/task-list"
|
||||
import { TaskItem } from "@shared/editor/extensions/task-item"
|
||||
@@ -80,25 +80,39 @@ const createExtensions = ({ placeholder }: { placeholder: string }) => [
|
||||
formData.append("width", dimensions.width.toString())
|
||||
formData.append("height", dimensions.height.toString())
|
||||
|
||||
const store = await storeImageFn(formData)
|
||||
const store = await storeImageFn({ data: formData })
|
||||
|
||||
return { id: store.fileModel.id, src: store.fileModel.content.src }
|
||||
},
|
||||
onImageRemoved({ id }) {
|
||||
if (id) {
|
||||
deleteImageFn({ id: id?.toString() })
|
||||
}
|
||||
onImageRemoved(props) {
|
||||
if (props.id) {
|
||||
deleteImageFn({ data: props.id })
|
||||
|
||||
toast.success("Image removed", {
|
||||
position: "bottom-right",
|
||||
description: "Image removed successfully",
|
||||
})
|
||||
console.log("Image removed", props)
|
||||
|
||||
toast.success("Image removed", {
|
||||
position: "bottom-right",
|
||||
description: "Image removed successfully",
|
||||
})
|
||||
}
|
||||
},
|
||||
onToggle(editor, files, pos) {
|
||||
files.forEach((file) =>
|
||||
editor.commands.insertContentAt(pos, {
|
||||
type: "image",
|
||||
attrs: { src: URL.createObjectURL(file) },
|
||||
editor.commands.insertContentAt(
|
||||
pos,
|
||||
files.map((image) => {
|
||||
const blobUrl = URL.createObjectURL(image)
|
||||
const id = randomId()
|
||||
|
||||
return {
|
||||
type: "image",
|
||||
attrs: {
|
||||
id,
|
||||
src: blobUrl,
|
||||
alt: image.name,
|
||||
title: image.name,
|
||||
fileName: image.name,
|
||||
},
|
||||
}
|
||||
}),
|
||||
)
|
||||
},
|
||||
|
||||
@@ -45,7 +45,7 @@ const ImageEditBlock = ({
|
||||
formData.append("file", files[0])
|
||||
|
||||
try {
|
||||
const response = await storeImageFn(formData)
|
||||
const response = await storeImageFn({ data: formData })
|
||||
|
||||
editor
|
||||
.chain()
|
||||
|
||||
Reference in New Issue
Block a user