Compare commits
6 Commits
aiassistan
...
538d896b0e
| Author | SHA1 | Date | |
|---|---|---|---|
| 538d896b0e | |||
| be6df0c8ad | |||
| daab745c13 | |||
| c527391259 | |||
| 404062904f | |||
| 4e8538552e |
@@ -77,10 +77,12 @@
|
|||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"glazejs": "^2.0.1",
|
"glazejs": "^2.0.1",
|
||||||
"googleapis": "^171.4.0",
|
"googleapis": "^171.4.0",
|
||||||
|
"gray-matter": "^4.0.3",
|
||||||
"gsap": "^3.14.2",
|
"gsap": "^3.14.2",
|
||||||
"input-otp": "^1.4.2",
|
"input-otp": "^1.4.2",
|
||||||
"lucide-react": "^0.577.0",
|
"lucide-react": "^0.577.0",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
|
"next-mdx-remote": "^6.0.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"postgres": "^3.4.8",
|
"postgres": "^3.4.8",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
@@ -93,6 +95,7 @@
|
|||||||
"recharts": "2.15.4",
|
"recharts": "2.15.4",
|
||||||
"rehype-highlight": "^7.0.2",
|
"rehype-highlight": "^7.0.2",
|
||||||
"rehype-raw": "^7.0.0",
|
"rehype-raw": "^7.0.0",
|
||||||
|
"remark-gfm": "^4.0.1",
|
||||||
"server-only": "^0.0.1",
|
"server-only": "^0.0.1",
|
||||||
"shadcn": "^4.0.2",
|
"shadcn": "^4.0.2",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import type { UseTRPCMutationResult } from "node_modules/@trpc/react-query/dist/getQueryKey.d-CruH3ncI.mjs";
|
|
||||||
import { createContext, useContext, type ReactNode } from "react";
|
import { createContext, useContext, type ReactNode } from "react";
|
||||||
|
|
||||||
interface ToString {
|
interface ToString {
|
||||||
@@ -8,7 +7,7 @@ interface ToString {
|
|||||||
|
|
||||||
|
|
||||||
export interface MutationInterface {
|
export interface MutationInterface {
|
||||||
mutate: (params:{id:string}) => void
|
mutate: (params: any) => void
|
||||||
error: ToString | null
|
error: ToString | null
|
||||||
status: "error" | "idle" | "pending" | "success"
|
status: "error" | "idle" | "pending" | "success"
|
||||||
}
|
}
|
||||||
|
|||||||
224
src/app/_components/Form/Fields/InternalLinkTextarea.tsx
Normal file
224
src/app/_components/Form/Fields/InternalLinkTextarea.tsx
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { forwardRef, useMemo, useRef, useState, type KeyboardEvent, type TextareaHTMLAttributes } from 'react'
|
||||||
|
|
||||||
|
export type InternalLinkSuggestion = {
|
||||||
|
label: string
|
||||||
|
href: string
|
||||||
|
group: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MdeAutocompleteSuggestion = {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
detail: string
|
||||||
|
group: string
|
||||||
|
trigger: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AUTOCOMPLETE_CURSOR_MARKER = '{{cursor}}'
|
||||||
|
|
||||||
|
export type AutocompleteTriggerConfig = {
|
||||||
|
trigger: string
|
||||||
|
label: string
|
||||||
|
isQueryValid?: (query: string) => boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActiveToken = {
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
query: string
|
||||||
|
trigger: MdeAutocompleteSuggestion['trigger']
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultTriggerConfigs: AutocompleteTriggerConfig[] = [
|
||||||
|
{
|
||||||
|
trigger: '[[',
|
||||||
|
label: 'Internal links',
|
||||||
|
isQueryValid: (query) => !query.includes(']'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
trigger: '<',
|
||||||
|
label: 'MDX components',
|
||||||
|
isQueryValid: (query) => !/[\s>]/.test(query),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function findActiveToken(
|
||||||
|
value: string,
|
||||||
|
cursor: number,
|
||||||
|
triggerConfigs: AutocompleteTriggerConfig[],
|
||||||
|
): ActiveToken | null {
|
||||||
|
const beforeCursor = value.slice(0, cursor)
|
||||||
|
const activeTrigger = triggerConfigs
|
||||||
|
.map((config) => ({ config, start: beforeCursor.lastIndexOf(config.trigger) }))
|
||||||
|
.filter((candidate) => candidate.start !== -1)
|
||||||
|
.sort((a, b) => b.start - a.start)[0]
|
||||||
|
|
||||||
|
if (!activeTrigger) return null
|
||||||
|
|
||||||
|
const query = beforeCursor.slice(activeTrigger.start + activeTrigger.config.trigger.length)
|
||||||
|
if (query.includes('\n')) return null
|
||||||
|
if (activeTrigger.config.isQueryValid && !activeTrigger.config.isQueryValid(query)) return null
|
||||||
|
|
||||||
|
return {
|
||||||
|
start: activeTrigger.start,
|
||||||
|
end: cursor,
|
||||||
|
query,
|
||||||
|
trigger: activeTrigger.config.trigger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function linkSuggestionsToAutocomplete(suggestions: InternalLinkSuggestion[]): MdeAutocompleteSuggestion[] {
|
||||||
|
return suggestions.map((suggestion) => ({
|
||||||
|
label: suggestion.label,
|
||||||
|
value: `[${suggestion.label}](${suggestion.href})`,
|
||||||
|
detail: suggestion.href,
|
||||||
|
group: suggestion.group,
|
||||||
|
trigger: '[[',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InternalLinkTextarea = forwardRef<HTMLTextAreaElement, TextareaHTMLAttributes<HTMLTextAreaElement> & {
|
||||||
|
suggestions: MdeAutocompleteSuggestion[]
|
||||||
|
triggerConfigs?: AutocompleteTriggerConfig[]
|
||||||
|
}>(({ suggestions, triggerConfigs, value, onChange, onKeyDown, ...props }, ref) => {
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement | null>(null)
|
||||||
|
const [token, setToken] = useState<ActiveToken | null>(null)
|
||||||
|
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||||
|
|
||||||
|
function setRefs(element: HTMLTextAreaElement | null) {
|
||||||
|
textareaRef.current = element
|
||||||
|
if (typeof ref === 'function') ref(element)
|
||||||
|
else if (ref) ref.current = element
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedTriggerConfigs = useMemo(() => {
|
||||||
|
const configured = triggerConfigs?.length ? triggerConfigs : defaultTriggerConfigs
|
||||||
|
const merged = new Map(configured.map((config) => [config.trigger, config]))
|
||||||
|
|
||||||
|
for (const suggestion of suggestions) {
|
||||||
|
if (!merged.has(suggestion.trigger)) {
|
||||||
|
merged.set(suggestion.trigger, {
|
||||||
|
trigger: suggestion.trigger,
|
||||||
|
label: suggestion.trigger,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(merged.values()).sort((a, b) => b.trigger.length - a.trigger.length)
|
||||||
|
}, [suggestions, triggerConfigs])
|
||||||
|
|
||||||
|
const triggerLabels = useMemo(
|
||||||
|
() => new Map(resolvedTriggerConfigs.map((config) => [config.trigger, config.label])),
|
||||||
|
[resolvedTriggerConfigs],
|
||||||
|
)
|
||||||
|
|
||||||
|
const matches = useMemo(() => {
|
||||||
|
if (!token) return []
|
||||||
|
const query = token.query.toLowerCase()
|
||||||
|
return suggestions
|
||||||
|
.filter((suggestion) => suggestion.trigger === token.trigger)
|
||||||
|
.filter((suggestion) => {
|
||||||
|
const haystack = `${suggestion.group} ${suggestion.label} ${suggestion.detail}`.toLowerCase()
|
||||||
|
return haystack.includes(query)
|
||||||
|
})
|
||||||
|
.slice(0, 8)
|
||||||
|
}, [suggestions, token])
|
||||||
|
|
||||||
|
function updateToken(textarea: HTMLTextAreaElement) {
|
||||||
|
const nextToken = findActiveToken(textarea.value, textarea.selectionStart, resolvedTriggerConfigs)
|
||||||
|
setToken(nextToken)
|
||||||
|
setSelectedIndex(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertSuggestion(textarea: HTMLTextAreaElement, suggestion: MdeAutocompleteSuggestion) {
|
||||||
|
if (!token) return
|
||||||
|
|
||||||
|
const markerIndex = suggestion.value.indexOf(AUTOCOMPLETE_CURSOR_MARKER)
|
||||||
|
const insertedValue = markerIndex === -1
|
||||||
|
? suggestion.value
|
||||||
|
: suggestion.value.replace(AUTOCOMPLETE_CURSOR_MARKER, '')
|
||||||
|
const cursor = token.start + (markerIndex === -1 ? insertedValue.length : markerIndex)
|
||||||
|
const nextValue = `${textarea.value.slice(0, token.start)}${insertedValue}${textarea.value.slice(token.end)}`
|
||||||
|
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set
|
||||||
|
nativeInputValueSetter?.call(textarea, nextValue)
|
||||||
|
textarea.dispatchEvent(new Event('input', { bubbles: true }))
|
||||||
|
textarea.setSelectionRange(cursor, cursor)
|
||||||
|
setToken(null)
|
||||||
|
setSelectedIndex(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
|
||||||
|
if (token && matches.length > 0) {
|
||||||
|
if (event.key === 'ArrowDown') {
|
||||||
|
event.preventDefault()
|
||||||
|
setSelectedIndex((index) => (index + 1) % matches.length)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault()
|
||||||
|
setSelectedIndex((index) => (index - 1 + matches.length) % matches.length)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key === 'Enter' || event.key === 'Tab') {
|
||||||
|
event.preventDefault()
|
||||||
|
const suggestion = matches[selectedIndex]
|
||||||
|
if (suggestion) insertSuggestion(event.currentTarget, suggestion)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
event.preventDefault()
|
||||||
|
setToken(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onKeyDown?.(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<textarea
|
||||||
|
{...props}
|
||||||
|
ref={setRefs}
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => {
|
||||||
|
onChange?.(event)
|
||||||
|
updateToken(event.currentTarget)
|
||||||
|
}}
|
||||||
|
onClick={(event) => updateToken(event.currentTarget)}
|
||||||
|
onKeyUp={(event) => {
|
||||||
|
if (['ArrowDown', 'ArrowUp', 'Enter', 'Tab', 'Escape'].includes(event.key)) return
|
||||||
|
updateToken(event.currentTarget)
|
||||||
|
}}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
/>
|
||||||
|
{token && matches.length > 0 && (
|
||||||
|
<div className='bg-popover text-popover-foreground absolute left-3 top-12 z-50 w-80 overflow-hidden rounded-md border shadow-md'>
|
||||||
|
<div className='border-b px-3 py-2 text-xs text-muted-foreground'>
|
||||||
|
{triggerLabels.get(token.trigger) ?? token.trigger} for {token.trigger}{token.query}
|
||||||
|
</div>
|
||||||
|
<div className='max-h-64 overflow-y-auto py-1'>
|
||||||
|
{matches.map((suggestion, index) => (
|
||||||
|
<button
|
||||||
|
key={`${suggestion.trigger}:${suggestion.group}:${suggestion.label}`}
|
||||||
|
type='button'
|
||||||
|
className={`flex w-full flex-col px-3 py-2 text-left text-sm ${index === selectedIndex ? 'bg-muted' : ''}`}
|
||||||
|
onMouseDown={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (textareaRef.current) insertSuggestion(textareaRef.current, suggestion)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className='font-medium'>{suggestion.label}</span>
|
||||||
|
<span className='text-xs text-muted-foreground'>{suggestion.group} - {suggestion.detail}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
InternalLinkTextarea.displayName = 'InternalLinkTextarea'
|
||||||
@@ -1,25 +1,99 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
import MDEditor from "@uiw/react-md-editor";
|
import MDEditor from "@uiw/react-md-editor";
|
||||||
|
import { Maximize2, Minimize2 } from "lucide-react";
|
||||||
|
import { useEffect, useState, type ReactElement, type TextareaHTMLAttributes } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
import type { Control, FieldValues, Path } from "react-hook-form";
|
import type { Control, FieldValues, Path } from "react-hook-form";
|
||||||
import { FormControl, FormField, FormItem, FormLabel } from "~/components/ui/form";
|
import { FormControl, FormField, FormItem, FormLabel } from "~/components/ui/form";
|
||||||
export default function MdeFormField<T extends FieldValues>(params: { control: Control<T>, name: Path<T>, label: string, dataColorMode: "dark"|"light" }) {
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
import {
|
||||||
|
InternalLinkTextarea,
|
||||||
|
type AutocompleteTriggerConfig,
|
||||||
|
type MdeAutocompleteSuggestion,
|
||||||
|
} from "./InternalLinkTextarea";
|
||||||
|
|
||||||
|
export default function MdeFormField<T extends FieldValues>(params: {
|
||||||
|
control: Control<T>,
|
||||||
|
name: Path<T>,
|
||||||
|
label: string,
|
||||||
|
dataColorMode: "dark"|"light",
|
||||||
|
autocompleteSuggestions?: MdeAutocompleteSuggestion[],
|
||||||
|
triggerConfigs?: AutocompleteTriggerConfig[],
|
||||||
|
renderPreview?: (source: string) => ReactElement,
|
||||||
|
}) {
|
||||||
|
const [fullscreen, setFullscreen] = useState(false)
|
||||||
|
const [mounted, setMounted] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!fullscreen) return
|
||||||
|
|
||||||
|
const originalOverflow = document.body.style.overflow
|
||||||
|
document.body.style.overflow = "hidden"
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.body.style.overflow = originalOverflow
|
||||||
|
}
|
||||||
|
}, [fullscreen])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormField
|
<FormField
|
||||||
control={params.control}
|
control={params.control}
|
||||||
name={params.name}
|
name={params.name}
|
||||||
render={({ field }) => (
|
render={({ field }) => {
|
||||||
<FormItem>
|
const editor = (
|
||||||
|
<FormItem className={cn(fullscreen && "mde-form-field-fullscreen")}>
|
||||||
|
<div className="flex shrink-0 items-center justify-between gap-2">
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
Description
|
{params.label}
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
<FormControl>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={fullscreen ? "Exit fullscreen editor" : "Open fullscreen editor"}
|
||||||
|
onClick={() => setFullscreen((value) => !value)}
|
||||||
|
>
|
||||||
|
{fullscreen ? <Minimize2 /> : <Maximize2 />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<FormControl className={cn(fullscreen && "min-h-0 flex-1")}>
|
||||||
<MDEditor
|
<MDEditor
|
||||||
|
className={cn(fullscreen && "mde-form-field-editor-fullscreen min-h-0 flex-1")}
|
||||||
|
height={fullscreen ? "calc(100vh - 72px)" : undefined}
|
||||||
|
visibleDragbar={!fullscreen}
|
||||||
value={field.value ? field.value : ""}
|
value={field.value ? field.value : ""}
|
||||||
onChange={field.onChange}
|
onChange={field.onChange}
|
||||||
data-color-mode={params.dataColorMode}
|
data-color-mode={params.dataColorMode}
|
||||||
|
commandsFilter={(command) => command.name === "fullscreen" ? false : command}
|
||||||
|
components={{
|
||||||
|
textarea: (props) => (
|
||||||
|
<InternalLinkTextarea
|
||||||
|
{...(props as TextareaHTMLAttributes<HTMLTextAreaElement>)}
|
||||||
|
suggestions={params.autocompleteSuggestions ?? []}
|
||||||
|
triggerConfigs={params.triggerConfigs}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
preview: params.renderPreview
|
||||||
|
? (source) => params.renderPreview?.(source) ?? <></>
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)
|
||||||
|
|
||||||
|
if (fullscreen && mounted) {
|
||||||
|
return createPortal(editor, document.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
return editor
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||||
import { Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider, SidebarTrigger } from "~/components/ui/sidebar";
|
import { Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarProvider, SidebarTrigger } from "~/components/ui/sidebar";
|
||||||
import SimpleSidebarGroup from "~/components/ui/simple-sidebar-group";
|
import SimpleSidebarGroup from "~/components/ui/simple-sidebar-group";
|
||||||
|
|
||||||
export default function AdminSideBar() {
|
export default function AdminSideBar() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SidebarProvider>
|
<Sidebar variant="floating" className="h-[96%] mt-10 z-[51]">
|
||||||
<Sidebar className="z-[51]">
|
|
||||||
<SidebarTrigger className="absolute z-[52] left-65 top-100" />
|
<SidebarTrigger className="absolute z-[52] left-65 top-100" />
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
|
<ScrollArea>
|
||||||
<SimpleSidebarGroup lable="CV">
|
<SimpleSidebarGroup lable="CV">
|
||||||
<Link href={"/admin/cv/category/create"}> Create Category </Link>
|
<Link href={"/admin/cv/category/create"}> Create Category </Link>
|
||||||
<Link href={"/admin/cv/entry/create"}> Create Entry </Link>
|
<Link href={"/admin/cv/entry/create"}> Create Entry </Link>
|
||||||
@@ -24,14 +25,15 @@ export default function AdminSideBar() {
|
|||||||
<Link href={"/admin/music"}> Manage Music </Link>
|
<Link href={"/admin/music"}> Manage Music </Link>
|
||||||
</SimpleSidebarGroup>
|
</SimpleSidebarGroup>
|
||||||
<SimpleSidebarGroup lable="Blog">
|
<SimpleSidebarGroup lable="Blog">
|
||||||
<Link href={"/"}> Some Blog Action </Link>
|
<Link href={"/admin/blog/create"}> Create Post </Link>
|
||||||
|
<Link href={"/admin/blog/list"}> Post List </Link>
|
||||||
</SimpleSidebarGroup>
|
</SimpleSidebarGroup>
|
||||||
<SimpleSidebarGroup lable="Chat">
|
<SimpleSidebarGroup lable="Chat">
|
||||||
<Link href={"/admin/chat"}> System Prompt </Link>
|
<Link href={"/admin/chat"}> System Prompt </Link>
|
||||||
</SimpleSidebarGroup>
|
</SimpleSidebarGroup>
|
||||||
|
</ScrollArea>
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
</SidebarProvider>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
11
src/app/admin/blog/[slug]/page.tsx
Normal file
11
src/app/admin/blog/[slug]/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
'use client'
|
||||||
|
import { trpc } from '~/app/_trpc/Client'
|
||||||
|
import { useParams } from 'next/navigation'
|
||||||
|
import CreateUpdateBlogForm from '../_components/CreateUpdateForm'
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const { slug } = useParams<{ slug: string }>()
|
||||||
|
const { data } = trpc.blog.bySlug.useQuery(slug)
|
||||||
|
if (data) return <CreateUpdateBlogForm entity={data} />
|
||||||
|
return <></>
|
||||||
|
}
|
||||||
58
src/app/admin/blog/_components/BlogMdxEditorPreview.tsx
Normal file
58
src/app/admin/blog/_components/BlogMdxEditorPreview.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { MDXRemote } from 'next-mdx-remote'
|
||||||
|
import { serialize } from 'next-mdx-remote/serialize'
|
||||||
|
import type { MDXRemoteSerializeResult } from 'next-mdx-remote'
|
||||||
|
import { mdxComponents } from '~/app/blog/_components/mdx-components'
|
||||||
|
|
||||||
|
export default function BlogMdxEditorPreview(params: { source: string }) {
|
||||||
|
const [compiled, setCompiled] = useState<MDXRemoteSerializeResult | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
const timeout = window.setTimeout(() => {
|
||||||
|
void serialize(params.source, {
|
||||||
|
parseFrontmatter: false,
|
||||||
|
mdxOptions: {
|
||||||
|
remarkPlugins: [],
|
||||||
|
rehypePlugins: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((result) => {
|
||||||
|
if (cancelled) return
|
||||||
|
setCompiled(result)
|
||||||
|
setError(null)
|
||||||
|
})
|
||||||
|
.catch((nextError: unknown) => {
|
||||||
|
if (cancelled) return
|
||||||
|
setCompiled(null)
|
||||||
|
setError(nextError instanceof Error ? nextError.message : 'Failed to compile MDX preview')
|
||||||
|
})
|
||||||
|
}, 200)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
window.clearTimeout(timeout)
|
||||||
|
}
|
||||||
|
}, [params.source])
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className='rounded-md border border-destructive/40 bg-destructive/10 p-4 text-sm text-destructive'>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!compiled) {
|
||||||
|
return <div className='text-muted-foreground p-4 text-sm'>Rendering preview...</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className='prose dark:prose-invert max-w-none'>
|
||||||
|
<MDXRemote {...compiled} components={mdxComponents} />
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
}
|
||||||
237
src/app/admin/blog/_components/CreateUpdateForm.tsx
Normal file
237
src/app/admin/blog/_components/CreateUpdateForm.tsx
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
'use client'
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
|
import { useForm } from 'react-hook-form'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { trpc } from '~/app/_trpc/Client'
|
||||||
|
import { FormScaffold } from '~/app/_components/Form/Components'
|
||||||
|
import { FormMutationContextProvider } from '~/app/_components/Form/Components/MutationProvider'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { TextInputFormField, MdeFormField } from '~/app/_components/Form/Fields'
|
||||||
|
import { usePathname, useRouter } from 'next/navigation'
|
||||||
|
import { useTheme } from 'next-themes'
|
||||||
|
import type { RouterOutputs } from '~/server/routers/_app'
|
||||||
|
import MdxComponentReference from './MdxComponentReference'
|
||||||
|
import BlogMdxEditorPreview from './BlogMdxEditorPreview'
|
||||||
|
import {
|
||||||
|
AUTOCOMPLETE_CURSOR_MARKER,
|
||||||
|
linkSuggestionsToAutocomplete,
|
||||||
|
type AutocompleteTriggerConfig,
|
||||||
|
type InternalLinkSuggestion,
|
||||||
|
type MdeAutocompleteSuggestion,
|
||||||
|
} from '~/app/_components/Form/Fields/InternalLinkTextarea'
|
||||||
|
|
||||||
|
type BlogPost = RouterOutputs['blog']['bySlug']
|
||||||
|
|
||||||
|
const blogPostSchema = z.object({
|
||||||
|
slug: z.string().min(1),
|
||||||
|
title: z.string().min(1),
|
||||||
|
date: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
tags: z.string().optional(),
|
||||||
|
content: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
|
function parseTags(value: string | undefined): string[] {
|
||||||
|
return value?.split(',').map((tag) => tag.trim()).filter(Boolean) ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
function internalLinkSuggestions(params: {
|
||||||
|
posts?: RouterOutputs['blog']['list'],
|
||||||
|
projects?: RouterOutputs['projectv2']['listWithStack'],
|
||||||
|
}): InternalLinkSuggestion[] {
|
||||||
|
const postLinks = params.posts?.map((post) => ({
|
||||||
|
label: post.title,
|
||||||
|
href: `/blog/${post.slug}`,
|
||||||
|
group: 'Blog',
|
||||||
|
})) ?? []
|
||||||
|
|
||||||
|
const projectLinks = params.projects?.map((project) => ({
|
||||||
|
label: project.title,
|
||||||
|
href: `/projects#${project.id}`,
|
||||||
|
group: 'Project',
|
||||||
|
})) ?? []
|
||||||
|
|
||||||
|
return [...postLinks, ...projectLinks]
|
||||||
|
}
|
||||||
|
|
||||||
|
const blogAutocompleteSuggestions: MdeAutocompleteSuggestion[] = [
|
||||||
|
{
|
||||||
|
label: 'Lead',
|
||||||
|
value: `<Lead>\n${AUTOCOMPLETE_CURSOR_MARKER}\n</Lead>`,
|
||||||
|
detail: 'Intro paragraph with larger muted text.',
|
||||||
|
group: 'Component',
|
||||||
|
trigger: '<',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Callout note',
|
||||||
|
value: `<Callout title="Heads up" variant="note">\n${AUTOCOMPLETE_CURSOR_MARKER}\n</Callout>`,
|
||||||
|
detail: 'Highlighted note block.',
|
||||||
|
group: 'Component',
|
||||||
|
trigger: '<',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Callout tip',
|
||||||
|
value: `<Callout title="Tip" variant="tip">\n${AUTOCOMPLETE_CURSOR_MARKER}\n</Callout>`,
|
||||||
|
detail: 'Highlighted tip block.',
|
||||||
|
group: 'Component',
|
||||||
|
trigger: '<',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Callout warning',
|
||||||
|
value: `<Callout title="Careful" variant="warning">\n${AUTOCOMPLETE_CURSOR_MARKER}\n</Callout>`,
|
||||||
|
detail: 'Highlighted warning block.',
|
||||||
|
group: 'Component',
|
||||||
|
trigger: '<',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'ButtonLink',
|
||||||
|
value: `<ButtonLink href="${AUTOCOMPLETE_CURSOR_MARKER}">\nView projects\n</ButtonLink>`,
|
||||||
|
detail: 'Button-styled internal or external link.',
|
||||||
|
group: 'Component',
|
||||||
|
trigger: '<',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Figure',
|
||||||
|
value: `<Figure\n src="${AUTOCOMPLETE_CURSOR_MARKER}"\n alt="Describe the image"\n caption="Optional caption"\n/>`,
|
||||||
|
detail: 'Image with optional caption.',
|
||||||
|
group: 'Component',
|
||||||
|
trigger: '<',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'PullQuote',
|
||||||
|
value: `<PullQuote>\n${AUTOCOMPLETE_CURSOR_MARKER}\n</PullQuote>`,
|
||||||
|
detail: 'Large emphasized quote.',
|
||||||
|
group: 'Component',
|
||||||
|
trigger: '<',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'TagList',
|
||||||
|
value: `<TagList tags={[${AUTOCOMPLETE_CURSOR_MARKER}]} />`,
|
||||||
|
detail: 'Inline list of tag badges.',
|
||||||
|
group: 'Component',
|
||||||
|
trigger: '<',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Badge',
|
||||||
|
value: `<Badge variant="outline">${AUTOCOMPLETE_CURSOR_MARKER}</Badge>`,
|
||||||
|
detail: 'Small inline label.',
|
||||||
|
group: 'Component',
|
||||||
|
trigger: '<',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Image',
|
||||||
|
value: ``,
|
||||||
|
detail: 'Markdown image',
|
||||||
|
group: 'Markdown',
|
||||||
|
trigger: '!',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const blogTriggerConfigs: AutocompleteTriggerConfig[] = [
|
||||||
|
{
|
||||||
|
trigger: '[[',
|
||||||
|
label: 'Internal links',
|
||||||
|
isQueryValid: (query) => !query.includes(']'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
trigger: '<',
|
||||||
|
label: 'MDX components',
|
||||||
|
isQueryValid: (query) => !/[\s>]/.test(query),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
trigger: '!',
|
||||||
|
label: 'Markdown',
|
||||||
|
isQueryValid: (query) => !/[\s\)]/.test(query),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function CreateUpdateBlogForm(params: { className?: string, entity?: BlogPost }) {
|
||||||
|
const [slug, setSlug] = useState<string | undefined>(params.entity?.slug)
|
||||||
|
const [originalSlug, setOriginalSlug] = useState<string | undefined>(params.entity?.slug)
|
||||||
|
const { theme } = useTheme()
|
||||||
|
const form = useForm<z.infer<typeof blogPostSchema>>({
|
||||||
|
resolver: zodResolver(blogPostSchema),
|
||||||
|
defaultValues: {
|
||||||
|
slug: params.entity?.slug ?? '',
|
||||||
|
title: params.entity?.title ?? '',
|
||||||
|
date: params.entity?.date ?? '',
|
||||||
|
description: params.entity?.description ?? '',
|
||||||
|
tags: params.entity?.tags?.join(', ') ?? '',
|
||||||
|
content: params.entity?.content ?? '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const path = usePathname()
|
||||||
|
const router = useRouter()
|
||||||
|
const posts = trpc.blog.list.useQuery(undefined, { refetchInterval: 5000 })
|
||||||
|
const projects = trpc.projectv2.listWithStack.useQuery()
|
||||||
|
const autocompleteSuggestions = [
|
||||||
|
...linkSuggestionsToAutocomplete(internalLinkSuggestions({ posts: posts.data, projects: projects.data })),
|
||||||
|
...blogAutocompleteSuggestions,
|
||||||
|
]
|
||||||
|
|
||||||
|
const createMutation = trpc.blog.insert.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
if (data[0]) {
|
||||||
|
setSlug(data[0].slug)
|
||||||
|
setOriginalSlug(data[0].slug)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const updateMutation = trpc.blog.update.useMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
if (data[0]) {
|
||||||
|
setSlug(data[0].slug)
|
||||||
|
setOriginalSlug(data[0].slug)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const deleteMutation = trpc.blog.delete.useMutation({
|
||||||
|
onSuccess: () => {
|
||||||
|
if (path.includes('list')) { router.refresh(); return }
|
||||||
|
router.back()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function onSubmit(values: z.infer<typeof blogPostSchema>) {
|
||||||
|
const input = { ...values, tags: parseTags(values.tags) }
|
||||||
|
if (slug && originalSlug) {
|
||||||
|
updateMutation.mutate({ ...input, originalSlug })
|
||||||
|
} else {
|
||||||
|
createMutation.mutate(input)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormMutationContextProvider value={{
|
||||||
|
createMutation: createMutation,
|
||||||
|
updateMutation: updateMutation,
|
||||||
|
deleteMutation: deleteMutation,
|
||||||
|
}}>
|
||||||
|
<div className='flex flex-col gap-4'>
|
||||||
|
<MdxComponentReference />
|
||||||
|
<FormScaffold
|
||||||
|
form={form}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
title='Blog Post'
|
||||||
|
id={slug}
|
||||||
|
className={params.className}
|
||||||
|
>
|
||||||
|
<TextInputFormField control={form.control} name='slug' label='Slug' />
|
||||||
|
<TextInputFormField control={form.control} name='title' label='Title' />
|
||||||
|
<TextInputFormField control={form.control} name='date' label='Date (YYYY-MM-DD)' />
|
||||||
|
<TextInputFormField control={form.control} name='description' label='Description' />
|
||||||
|
<TextInputFormField control={form.control} name='tags' label='Tags (comma separated)' />
|
||||||
|
<MdeFormField
|
||||||
|
control={form.control}
|
||||||
|
name='content'
|
||||||
|
label='Content'
|
||||||
|
dataColorMode={(theme as 'dark' | 'light') ?? 'dark'}
|
||||||
|
autocompleteSuggestions={autocompleteSuggestions}
|
||||||
|
triggerConfigs={blogTriggerConfigs}
|
||||||
|
renderPreview={(source) => <BlogMdxEditorPreview source={source} />}
|
||||||
|
/>
|
||||||
|
</FormScaffold>
|
||||||
|
</div>
|
||||||
|
</FormMutationContextProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
91
src/app/admin/blog/_components/MdxComponentReference.tsx
Normal file
91
src/app/admin/blog/_components/MdxComponentReference.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "~/components/ui/accordion";
|
||||||
|
|
||||||
|
const examples = [
|
||||||
|
{
|
||||||
|
name: "Lead",
|
||||||
|
description: "Intro paragraph with larger muted text.",
|
||||||
|
code: `<Lead>
|
||||||
|
Short opening summary for the post.
|
||||||
|
</Lead>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Callout",
|
||||||
|
description: "Highlighted note, tip, or warning block.",
|
||||||
|
code: `<Callout title="Heads up" variant="note">
|
||||||
|
Important context for readers.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
<Callout title="Tip" variant="tip">
|
||||||
|
A practical recommendation.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
<Callout title="Careful" variant="warning">
|
||||||
|
A caveat or tradeoff.
|
||||||
|
</Callout>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ButtonLink",
|
||||||
|
description: "Button-styled internal or external link.",
|
||||||
|
code: `<ButtonLink href="/projects">
|
||||||
|
View projects
|
||||||
|
</ButtonLink>
|
||||||
|
|
||||||
|
<ButtonLink href="https://example.com" variant="outline">
|
||||||
|
External resource
|
||||||
|
</ButtonLink>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Figure",
|
||||||
|
description: "Image with optional caption.",
|
||||||
|
code: `<Figure
|
||||||
|
src="https://example.com/image.jpg"
|
||||||
|
alt="Describe the image"
|
||||||
|
caption="Optional caption"
|
||||||
|
/>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "PullQuote",
|
||||||
|
description: "Large emphasized quote or takeaway.",
|
||||||
|
code: `<PullQuote>
|
||||||
|
A highlighted quote or strong takeaway.
|
||||||
|
</PullQuote>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "TagList",
|
||||||
|
description: "Inline list of tag badges inside the post body.",
|
||||||
|
code: `<TagList tags={["nextjs", "mdx", "uploadthing"]} />`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Badge",
|
||||||
|
description: "Small inline label.",
|
||||||
|
code: `<Badge variant="outline">Next.js</Badge>`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function MdxComponentReference() {
|
||||||
|
return (
|
||||||
|
<section className="rounded-lg border p-4">
|
||||||
|
<h2 className="text-base font-semibold">MDX Components</h2>
|
||||||
|
<p className="text-muted-foreground mt-1 text-sm">
|
||||||
|
Components available inside blog post content. Type <code className="rounded bg-muted px-1">[[</code> for internal links or <code className="rounded bg-muted px-1"><</code> for component snippets.
|
||||||
|
</p>
|
||||||
|
<Accordion type="single" collapsible className="mt-3">
|
||||||
|
{examples.map((example) => (
|
||||||
|
<AccordionItem key={example.name} value={example.name}>
|
||||||
|
<AccordionTrigger>
|
||||||
|
<span>
|
||||||
|
<span className="block">{example.name}</span>
|
||||||
|
<span className="text-muted-foreground block text-xs font-normal">{example.description}</span>
|
||||||
|
</span>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent>
|
||||||
|
<pre className="bg-muted overflow-x-auto rounded-md p-3 text-xs">
|
||||||
|
<code>{example.code}</code>
|
||||||
|
</pre>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
))}
|
||||||
|
</Accordion>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
src/app/admin/blog/create/page.tsx
Normal file
6
src/app/admin/blog/create/page.tsx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
'use client'
|
||||||
|
import CreateUpdateBlogForm from '../_components/CreateUpdateForm'
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <CreateUpdateBlogForm />
|
||||||
|
}
|
||||||
61
src/app/admin/blog/list/page.tsx
Normal file
61
src/app/admin/blog/list/page.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
'use client'
|
||||||
|
import Link from 'next/link'
|
||||||
|
import { trpc } from '~/app/_trpc/Client'
|
||||||
|
import * as Card from '~/components/ui/card'
|
||||||
|
import { CollapsibleForm } from '~/app/_components/Form/Components'
|
||||||
|
import CreateUpdateBlogForm from '../_components/CreateUpdateForm'
|
||||||
|
import { Badge } from '~/components/ui/badge'
|
||||||
|
import { Button } from '~/components/ui/button'
|
||||||
|
import { RefreshCw } from 'lucide-react'
|
||||||
|
|
||||||
|
export default function BlogListPage() {
|
||||||
|
const posts = trpc.blog.list.useQuery(undefined, { refetchInterval: 5000 })
|
||||||
|
const syncMutation = trpc.blog.syncFromUploadThing.useMutation({
|
||||||
|
onSuccess: () => posts.refetch(),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='w-5/6 lg:w-1/2 flex flex-col gap-3'>
|
||||||
|
<div className='flex justify-end'>
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='outline'
|
||||||
|
onClick={() => syncMutation.mutate(undefined)}
|
||||||
|
disabled={syncMutation.status === 'pending'}
|
||||||
|
>
|
||||||
|
<RefreshCw />
|
||||||
|
Sync
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{syncMutation.data && (
|
||||||
|
<p className='text-sm text-muted-foreground'>
|
||||||
|
Synced {syncMutation.data.created} created, {syncMutation.data.updated} updated, {syncMutation.data.skipped} skipped.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{posts.data == undefined ?
|
||||||
|
<div className='gsapan' /> :
|
||||||
|
<>
|
||||||
|
{posts.data.map((post) => (
|
||||||
|
<Card.Card className='gsapan' key={post.slug}>
|
||||||
|
<Link href={`/admin/blog/${post.slug}`}>
|
||||||
|
<Card.CardHeader>
|
||||||
|
<Card.CardTitle>{post.title}</Card.CardTitle>
|
||||||
|
{post.date && <p className='text-sm text-muted-foreground'>{post.date}</p>}
|
||||||
|
{post.description && <p className='text-sm text-muted-foreground'>{post.description}</p>}
|
||||||
|
{post.tags && post.tags.length > 0 && (
|
||||||
|
<div className='flex flex-wrap gap-1.5'>
|
||||||
|
{post.tags.map((tag) => (
|
||||||
|
<Badge key={tag} variant='outline'>{tag}</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card.CardHeader>
|
||||||
|
</Link>
|
||||||
|
</Card.Card>
|
||||||
|
))}
|
||||||
|
<CollapsibleForm entityName='Blog Post' form={CreateUpdateBlogForm} />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -11,13 +11,8 @@ import CreateUpdateCvCategoryForm from "../_components/CreateUpdateForm";
|
|||||||
export default function CvPage() {
|
export default function CvPage() {
|
||||||
const categories = trpc.category.select.useQuery({}, { refetchInterval: 1000 });
|
const categories = trpc.category.select.useQuery({}, { refetchInterval: 1000 });
|
||||||
const entires = trpc.entry.select.useSuspenseQuery({}, { refetchInterval: 1000 })
|
const entires = trpc.entry.select.useSuspenseQuery({}, { refetchInterval: 1000 })
|
||||||
const gsap = useGsapContext()
|
|
||||||
const container = useRef<HTMLDivElement>(null);
|
|
||||||
useGSAP(() => {
|
|
||||||
gsap?.from('.gsapan', { x: -100, opacity: 0, duration: 0.5, stagger: { each: 0.3 } });
|
|
||||||
}, { scope: container, dependencies: [categories.status], revertOnUpdate: true });
|
|
||||||
return (
|
return (
|
||||||
<div ref={container} className="w-5/6 lg:w-1/2 flex flex-col gap-3">
|
<>
|
||||||
{categories.data == undefined ?
|
{categories.data == undefined ?
|
||||||
<div className="gsapan"></div>
|
<div className="gsapan"></div>
|
||||||
:
|
:
|
||||||
@@ -64,6 +59,6 @@ export default function CvPage() {
|
|||||||
<CollapsibleForm entityName="Category" form={CreateUpdateCvCategoryForm} />
|
<CollapsibleForm entityName="Category" form={CreateUpdateCvCategoryForm} />
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,13 +8,9 @@ import { useGsapContext } from "~/app/_providers/GsapProvicer";
|
|||||||
|
|
||||||
export default function CvPage() {
|
export default function CvPage() {
|
||||||
const entires = trpc.entry.select.useQuery({});
|
const entires = trpc.entry.select.useQuery({});
|
||||||
const gsap = useGsapContext()
|
|
||||||
const container = useRef<HTMLDivElement>(null);
|
const container = useRef<HTMLDivElement>(null);
|
||||||
useGSAP(() => {
|
|
||||||
gsap?.from('.gsapan', { x: -100, opacity: 0, duration: 0.5, stagger: { each: 0.3 } })
|
|
||||||
}, { scope: container, dependencies: [entires.status], revertOnUpdate: true });
|
|
||||||
return (
|
return (
|
||||||
<div ref={container} className="w-5/6 lg:w-1/2 flex flex-col gap-3">
|
<>
|
||||||
{entires.data == undefined ?
|
{entires.data == undefined ?
|
||||||
<div className="gsapan"></div>
|
<div className="gsapan"></div>
|
||||||
:
|
:
|
||||||
@@ -40,6 +36,6 @@ export default function CvPage() {
|
|||||||
})}
|
})}
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
|
import { SidebarProvider } from "~/components/ui/sidebar";
|
||||||
import AdminSideBar from "./_components/AdminSideBar";
|
import AdminSideBar from "./_components/AdminSideBar";
|
||||||
|
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export default function Admin({children}: Readonly<{children: React.ReactNode}>) {
|
export default function Admin({children}: Readonly<{children: React.ReactNode}>) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<SidebarProvider>
|
||||||
<AdminSideBar/>
|
<AdminSideBar/>
|
||||||
<main className="absolute flex items-center content-center justify-center flex-wrap w-[100vw] left-0 top-15">
|
<ScrollArea className="px-10 lg:px-0 w-full h-screen pb-10 max-w-4xl mx-auto pt-10">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</ScrollArea>
|
||||||
|
</SidebarProvider>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export default function ProjectList() {
|
|||||||
const projects = trpc.project.select.useQuery({}, { refetchInterval: 1000 })
|
const projects = trpc.project.select.useQuery({}, { refetchInterval: 1000 })
|
||||||
const techStacks = trpc.techStack.select.useSuspenseQuery({}, { refetchInterval: 1000 })
|
const techStacks = trpc.techStack.select.useSuspenseQuery({}, { refetchInterval: 1000 })
|
||||||
return (
|
return (
|
||||||
<div className="w-5/6 lg:w-1/2 flex flex-col gap-3">
|
<>
|
||||||
{
|
{
|
||||||
projects.data == undefined ?
|
projects.data == undefined ?
|
||||||
<></> :
|
<></> :
|
||||||
@@ -55,6 +55,6 @@ export default function ProjectList() {
|
|||||||
<CollapsibleForm entityName="Project" form={CreateUpdateProjectForm} />
|
<CollapsibleForm entityName="Project" form={CreateUpdateProjectForm} />
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
</div>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
49
src/app/blog/[slug]/page.tsx
Normal file
49
src/app/blog/[slug]/page.tsx
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { MDXRemote } from "next-mdx-remote/rsc";
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { servTrpc } from "~/app/_trpc/ServerClient";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import { mdxComponents } from "../_components/mdx-components";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
params: Promise<{ slug: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function BlogPostPage({ params }: Props) {
|
||||||
|
const { slug } = await params;
|
||||||
|
|
||||||
|
let post: Awaited<ReturnType<typeof servTrpc.blog.bySlug>>;
|
||||||
|
try {
|
||||||
|
post = await servTrpc.blog.bySlug(slug);
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof TRPCError && e.code === "NOT_FOUND") notFound();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="mx-auto max-w-2xl px-4 py-12">
|
||||||
|
<header className="mb-8">
|
||||||
|
<h1 className="text-3xl font-bold">{post.title}</h1>
|
||||||
|
{post.date && (
|
||||||
|
<time className="text-muted-foreground text-sm">
|
||||||
|
{new Date(post.date).toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
})}
|
||||||
|
</time>
|
||||||
|
)}
|
||||||
|
{post.tags.length > 0 && (
|
||||||
|
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||||
|
{post.tags.map((tag) => (
|
||||||
|
<Badge key={tag} variant="outline">{tag}</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
<article className="prose dark:prose-invert max-w-none">
|
||||||
|
<MDXRemote source={post.content} components={mdxComponents} />
|
||||||
|
</article>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
128
src/app/blog/_components/mdx-components.tsx
Normal file
128
src/app/blog/_components/mdx-components.tsx
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Children, isValidElement, type ComponentPropsWithoutRef, type ReactNode } from "react";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
import { Button } from "~/components/ui/button";
|
||||||
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
|
type CalloutVariant = "note" | "tip" | "warning";
|
||||||
|
|
||||||
|
const calloutStyles: Record<CalloutVariant, string> = {
|
||||||
|
note: "border-sky-500/40 bg-sky-500/10 text-sky-950 dark:text-sky-100",
|
||||||
|
tip: "border-emerald-500/40 bg-emerald-500/10 text-emerald-950 dark:text-emerald-100",
|
||||||
|
warning: "border-amber-500/40 bg-amber-500/10 text-amber-950 dark:text-amber-100",
|
||||||
|
};
|
||||||
|
|
||||||
|
function Callout({
|
||||||
|
title,
|
||||||
|
variant = "note",
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title?: string;
|
||||||
|
variant?: CalloutVariant;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<aside className={cn("my-6 rounded-md border px-4 py-3", calloutStyles[variant])}>
|
||||||
|
{title && <p className="mb-2 font-semibold">{title}</p>}
|
||||||
|
<div className="[&>*:first-child]:mt-0 [&>*:last-child]:mb-0">{children}</div>
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Lead({ children }: { children: ReactNode }) {
|
||||||
|
return <span className="text-muted-foreground my-6 block text-lg leading-8">{children}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TagList({ tags }: { tags: string[] }) {
|
||||||
|
return (
|
||||||
|
<div className="my-4 flex flex-wrap gap-1.5">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<Badge key={tag} variant="outline">
|
||||||
|
{tag}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ButtonLink({
|
||||||
|
href,
|
||||||
|
children,
|
||||||
|
variant = "default",
|
||||||
|
}: {
|
||||||
|
href: string;
|
||||||
|
children: ReactNode;
|
||||||
|
variant?: ComponentPropsWithoutRef<typeof Button>["variant"];
|
||||||
|
}) {
|
||||||
|
const isExternal = /^https?:\/\//.test(href);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button asChild variant={variant}>
|
||||||
|
{isExternal ? (
|
||||||
|
<a href={href} target="_blank" rel="noreferrer">
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<Link href={href}>{children}</Link>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Figure({
|
||||||
|
src,
|
||||||
|
alt,
|
||||||
|
caption,
|
||||||
|
}: {
|
||||||
|
src: string;
|
||||||
|
alt: string;
|
||||||
|
caption?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<figure className="my-8">
|
||||||
|
<img src={src} alt={alt} className="w-full rounded-md border object-cover" />
|
||||||
|
{caption && <figcaption className="text-muted-foreground mt-2 text-center text-sm">{caption}</figcaption>}
|
||||||
|
</figure>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PullQuote({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<blockquote className="border-primary my-8 border-l-4 pl-5 text-xl leading-8 font-medium">
|
||||||
|
{children}
|
||||||
|
</blockquote>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExternalLink(props: ComponentPropsWithoutRef<"a">) {
|
||||||
|
const href = props.href ?? "";
|
||||||
|
const isExternal = /^https?:\/\//.test(href);
|
||||||
|
|
||||||
|
if (!isExternal) return <a {...props} />;
|
||||||
|
|
||||||
|
return <a {...props} target="_blank" rel="noreferrer" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockComponents = new Set<unknown>([Callout, Figure, PullQuote, TagList]);
|
||||||
|
|
||||||
|
function Paragraph({ children }: { children: ReactNode }) {
|
||||||
|
const containsBlockComponent = Children.toArray(children).some(
|
||||||
|
(child) => isValidElement(child) && blockComponents.has(child.type),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (containsBlockComponent) return <>{children}</>;
|
||||||
|
|
||||||
|
return <p>{children}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mdxComponents = {
|
||||||
|
a: ExternalLink,
|
||||||
|
p: Paragraph,
|
||||||
|
Badge,
|
||||||
|
ButtonLink,
|
||||||
|
Callout,
|
||||||
|
Figure,
|
||||||
|
Lead,
|
||||||
|
PullQuote,
|
||||||
|
TagList,
|
||||||
|
};
|
||||||
@@ -1,10 +1,3 @@
|
|||||||
'use client'
|
export default function BlogLayout({ children }: { children: React.ReactNode }) {
|
||||||
export default function RootLayout({
|
return <>{children}</>;
|
||||||
children,
|
|
||||||
}: Readonly<{ children: React.ReactNode}>) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{children}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,45 @@
|
|||||||
'use client'
|
import Link from "next/link";
|
||||||
|
import { servTrpc } from "~/app/_trpc/ServerClient";
|
||||||
|
import { Badge } from "~/components/ui/badge";
|
||||||
|
|
||||||
import { usePathname } from "next/navigation"
|
export default async function BlogPage() {
|
||||||
|
const posts = await servTrpc.blog.list();
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
const pathName = usePathname()
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<main className="mx-auto max-w-2xl px-4 py-12">
|
||||||
{pathName}
|
<h1 className="mb-8 text-3xl font-bold">Blog</h1>
|
||||||
|
{posts.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground">No posts yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-6">
|
||||||
|
{posts.map((post) => (
|
||||||
|
<li key={post.slug}>
|
||||||
|
<Link href={`/blog/${post.slug}`} className="group block">
|
||||||
|
<h2 className="text-xl font-semibold group-hover:underline">{post.title}</h2>
|
||||||
|
{post.date && (
|
||||||
|
<time className="text-muted-foreground text-sm">
|
||||||
|
{new Date(post.date).toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
})}
|
||||||
|
</time>
|
||||||
|
)}
|
||||||
|
{post.description && (
|
||||||
|
<p className="text-muted-foreground mt-1">{post.description}</p>
|
||||||
|
)}
|
||||||
|
{post.tags && post.tags.length > 0 && (
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
{post.tags.map((tag) => (
|
||||||
|
<Badge key={tag} variant="outline">{tag}</Badge>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import AnimateTextIn from "../_components/Animated/AnimateIn";
|
|||||||
import { useTimeLine } from "../_providers/GsapProvicer";
|
import { useTimeLine } from "../_providers/GsapProvicer";
|
||||||
import AnimatePopUp from "../_components/Animated/AnimatePopUp";
|
import AnimatePopUp from "../_components/Animated/AnimatePopUp";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
|
import remarkGfm from "remark-gfm"
|
||||||
|
|
||||||
export default function ProjectsPage() {
|
export default function ProjectsPage() {
|
||||||
const { data: projects, isLoading } = trpc.projectv2.listWithStack.useQuery();
|
const { data: projects, isLoading } = trpc.projectv2.listWithStack.useQuery();
|
||||||
@@ -36,7 +37,7 @@ export default function ProjectsPage() {
|
|||||||
<AnimatedPageTitle position={0}><span>Project I've Been</span><span> Working on</span> </AnimatedPageTitle>
|
<AnimatedPageTitle position={0}><span>Project I've Been</span><span> Working on</span> </AnimatedPageTitle>
|
||||||
<div className="pt-10" />
|
<div className="pt-10" />
|
||||||
{projects.map((project, i) => (
|
{projects.map((project, i) => (
|
||||||
<div key={i}>
|
<div id={project.id} key={i} className="scroll-mt-10">
|
||||||
<Card.AnimatedCard position={i + 1.2} key={project.id}>
|
<Card.AnimatedCard position={i + 1.2} key={project.id}>
|
||||||
<Card.CardHeader>
|
<Card.CardHeader>
|
||||||
<div className="flex items-start justify-between gap-2 flex-wrap">
|
<div className="flex items-start justify-between gap-2 flex-wrap">
|
||||||
@@ -61,8 +62,9 @@ export default function ProjectsPage() {
|
|||||||
<Card.CardContent className="flex flex-col gap-3">
|
<Card.CardContent className="flex flex-col gap-3">
|
||||||
{project.description && (
|
{project.description && (
|
||||||
<div className="prose prose-sm dark:prose-invert max-w-none text-muted-foreground">
|
<div className="prose prose-sm dark:prose-invert max-w-none text-muted-foreground">
|
||||||
<AnimatePopUp position={i + 1.4} duration={project.description.length / 20}>
|
<AnimatePopUp position={i + 1.4} duration={10}>
|
||||||
<AnimateTextIn position={i + 1.5} animation="slide"><Markdown>{project.description}</Markdown></AnimateTextIn></AnimatePopUp>
|
<Markdown remarkPlugins={[remarkGfm]}>{project.description}</Markdown>
|
||||||
|
</AnimatePopUp>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-row">
|
<div className="flex flex-row">
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ export const env = createEnv({
|
|||||||
* isn't built with invalid env vars.
|
* isn't built with invalid env vars.
|
||||||
*/
|
*/
|
||||||
server: {
|
server: {
|
||||||
|
UPLOADTHING_TOKEN: z.string(),
|
||||||
|
BLOG_MDX_PREFIX: z.string().default("blog"),
|
||||||
|
|
||||||
DATABASE_URL: z.string().url(),
|
DATABASE_URL: z.string().url(),
|
||||||
DATABASE_URL_UNPOOLED: z.string().url(),
|
DATABASE_URL_UNPOOLED: z.string().url(),
|
||||||
|
|
||||||
@@ -27,7 +30,6 @@ export const env = createEnv({
|
|||||||
|
|
||||||
CLERK_SECRET_KEY: z.string(),
|
CLERK_SECRET_KEY: z.string(),
|
||||||
ADMIN_USER_CLERK_ID: z.string(),
|
ADMIN_USER_CLERK_ID: z.string(),
|
||||||
UPLOADTHING_TOKEN: z.string(),
|
|
||||||
OPENAI_API_KEY: z.string(),
|
OPENAI_API_KEY: z.string(),
|
||||||
NODE_ENV: z
|
NODE_ENV: z
|
||||||
.enum(["development", "test", "production"])
|
.enum(["development", "test", "production"])
|
||||||
@@ -50,6 +52,9 @@ export const env = createEnv({
|
|||||||
* middlewares) or client-side so we need to destruct manually.
|
* middlewares) or client-side so we need to destruct manually.
|
||||||
*/
|
*/
|
||||||
runtimeEnv: {
|
runtimeEnv: {
|
||||||
|
UPLOADTHING_TOKEN: process.env.UPLOADTHING_TOKEN,
|
||||||
|
BLOG_MDX_PREFIX: process.env.BLOG_MDX_PREFIX,
|
||||||
|
|
||||||
DATABASE_URL: process.env.DATABASE_URL,
|
DATABASE_URL: process.env.DATABASE_URL,
|
||||||
DATABASE_URL_UNPOOLED: process.env.DATABASE_URL,
|
DATABASE_URL_UNPOOLED: process.env.DATABASE_URL,
|
||||||
PGHOST: process.env.PGHOST,
|
PGHOST: process.env.PGHOST,
|
||||||
@@ -66,7 +71,6 @@ export const env = createEnv({
|
|||||||
POSTGRES_URL_NO_SSL: process.env.POSTGRES_URL_NO_SSL,
|
POSTGRES_URL_NO_SSL: process.env.POSTGRES_URL_NO_SSL,
|
||||||
POSTGRES_PRISMA_URL: process.env.POSTGRES_PRISMA_URL,
|
POSTGRES_PRISMA_URL: process.env.POSTGRES_PRISMA_URL,
|
||||||
ADMIN_USER_CLERK_ID: process.env.ADMIN_USER_CLERK_ID,
|
ADMIN_USER_CLERK_ID: process.env.ADMIN_USER_CLERK_ID,
|
||||||
UPLOADTHING_TOKEN: process.env.UPLOADTHING_TOKEN,
|
|
||||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
|
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
|
||||||
NEXT_PUBLIC_ADMIN_USER_CLERK_ID: process.env.NEXT_PUBLIC_ADMIN_USER_CLERK_ID,
|
NEXT_PUBLIC_ADMIN_USER_CLERK_ID: process.env.NEXT_PUBLIC_ADMIN_USER_CLERK_ID,
|
||||||
CLERK_SECRET_KEY: process.env.CLERK_SECRET_KEY,
|
CLERK_SECRET_KEY: process.env.CLERK_SECRET_KEY,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// https://orm.drizzle.team/docs/sql-schema-declaration
|
// https://orm.drizzle.team/docs/sql-schema-declaration
|
||||||
|
|
||||||
import { relations, sql } from "drizzle-orm";
|
import { relations, sql } from "drizzle-orm";
|
||||||
import { index, pgEnum, pgSchema, pgTableCreator } from "drizzle-orm/pg-core";
|
import { index, pgEnum, pgSchema, pgTableCreator, uniqueIndex } from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
|
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
|
||||||
@@ -104,6 +104,33 @@ export const music = createTable(
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const blogPost = createTable(
|
||||||
|
"blog_post",
|
||||||
|
(d) => ({
|
||||||
|
id: d.uuid().primaryKey().defaultRandom(),
|
||||||
|
slug: d.varchar({ length: 200 }).notNull(),
|
||||||
|
title: d.varchar({ length: 200 }).notNull(),
|
||||||
|
date: d.varchar({ length: 20 }),
|
||||||
|
description: d.text(),
|
||||||
|
tags: d.text().array(),
|
||||||
|
fileKey: d.varchar("file_key", { length: 200 }).notNull(),
|
||||||
|
fileUrl: d.varchar("file_url", { length: 500 }).notNull(),
|
||||||
|
fileName: d.varchar("file_name", { length: 255 }).notNull(),
|
||||||
|
customId: d.varchar("custom_id", { length: 255 }).notNull(),
|
||||||
|
createdAt: d
|
||||||
|
.timestamp({ withTimezone: true })
|
||||||
|
.default(sql`CURRENT_TIMESTAMP`)
|
||||||
|
.notNull()
|
||||||
|
.$type<Date>(),
|
||||||
|
updatedAt: d.timestamp({ withTimezone: true }).$onUpdate(() => new Date()).$type<Date>(),
|
||||||
|
}),
|
||||||
|
(t) => [
|
||||||
|
uniqueIndex("blog_post_slug_idx").on(t.slug),
|
||||||
|
uniqueIndex("blog_post_file_key_idx").on(t.fileKey),
|
||||||
|
uniqueIndex("blog_post_custom_id_idx").on(t.customId),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
export const messageRoleEnum = pgEnum('message_role', ['user', 'assistant'])
|
export const messageRoleEnum = pgEnum('message_role', ['user', 'assistant'])
|
||||||
|
|
||||||
export const chatSession = createTable(
|
export const chatSession = createTable(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { inferRouterOutputs } from "@trpc/server";
|
import type { inferRouterOutputs } from "@trpc/server";
|
||||||
import { router } from "../trpc";
|
import { router } from "../trpc";
|
||||||
import type { inferReactQueryProcedureOptions } from "@trpc/react-query";
|
import type { inferReactQueryProcedureOptions } from "@trpc/react-query";
|
||||||
|
import { blogRouter } from "./blog";
|
||||||
import { projectRouter } from "./project";
|
import { projectRouter } from "./project";
|
||||||
import { techStackRouter } from "./techStack";
|
import { techStackRouter } from "./techStack";
|
||||||
import { cvCategoryRouter } from "./cvCategory";
|
import { cvCategoryRouter } from "./cvCategory";
|
||||||
@@ -11,6 +12,7 @@ import { cvCategory } from "../dbschema/schema";
|
|||||||
import { chatRouter } from "./chat";
|
import { chatRouter } from "./chat";
|
||||||
|
|
||||||
export const trpcRouter = router({
|
export const trpcRouter = router({
|
||||||
|
blog: blogRouter,
|
||||||
project: trpcCrudRouterFromDrizzleEntity('project').router,
|
project: trpcCrudRouterFromDrizzleEntity('project').router,
|
||||||
projectv2: projectRouter,
|
projectv2: projectRouter,
|
||||||
techStack: trpcCrudRouterFromDrizzleEntity('techStack').router,
|
techStack: trpcCrudRouterFromDrizzleEntity('techStack').router,
|
||||||
|
|||||||
360
src/server/routers/blog.ts
Normal file
360
src/server/routers/blog.ts
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { desc, eq, or } from "drizzle-orm";
|
||||||
|
import matter from "gray-matter";
|
||||||
|
import { UTApi, UTFile } from "uploadthing/server";
|
||||||
|
import z from "zod";
|
||||||
|
import { isAdmin } from "~/app/actions";
|
||||||
|
import { env } from "~/env.js";
|
||||||
|
import { db } from "~/server/db";
|
||||||
|
import { blogPost } from "~/server/dbschema/schema";
|
||||||
|
import { publicProcedure, router } from "~/server/trpc";
|
||||||
|
|
||||||
|
const utapi = new UTApi({ token: env.UPLOADTHING_TOKEN });
|
||||||
|
|
||||||
|
const blogPostInput = z.object({
|
||||||
|
slug: z.string().min(1),
|
||||||
|
title: z.string().min(1),
|
||||||
|
date: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
|
content: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type BlogPostInput = z.infer<typeof blogPostInput>;
|
||||||
|
|
||||||
|
type UploadThingFile = Awaited<ReturnType<typeof utapi.listFiles>>["files"][number];
|
||||||
|
|
||||||
|
function cleanPrefix(): string {
|
||||||
|
return env.BLOG_MDX_PREFIX.trim().replace(/^\/+|\/+$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function blogFileName(slug: string): string {
|
||||||
|
const prefix = cleanPrefix();
|
||||||
|
return prefix ? `${prefix}-${slug}.mdx` : `${slug}.mdx`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function blogCustomId(slug: string): string {
|
||||||
|
const prefix = cleanPrefix();
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
return prefix ? `${prefix}:${slug}:${id}` : `${slug}:${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalText(value: string | undefined): string | null {
|
||||||
|
const trimmed = value?.trim();
|
||||||
|
return trimmed ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function frontmatterText(value: unknown): string | null {
|
||||||
|
if (value instanceof Date) return value.toISOString().slice(0, 10);
|
||||||
|
return optionalText(typeof value === "string" ? value : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTags(tags: unknown): string[] {
|
||||||
|
const values = Array.isArray(tags)
|
||||||
|
? tags
|
||||||
|
: typeof tags === "string"
|
||||||
|
? tags.split(",")
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
values
|
||||||
|
.map((tag) => String(tag).trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMdxContent(input: BlogPostInput): string {
|
||||||
|
const date = optionalText(input.date);
|
||||||
|
const description = optionalText(input.description);
|
||||||
|
const tags = normalizeTags(input.tags);
|
||||||
|
const frontmatter: Record<string, unknown> = { slug: input.slug, title: input.title };
|
||||||
|
if (date) frontmatter.date = date;
|
||||||
|
if (description) frontmatter.description = description;
|
||||||
|
if (tags.length > 0) frontmatter.tags = tags;
|
||||||
|
return matter.stringify(input.content, frontmatter);
|
||||||
|
}
|
||||||
|
|
||||||
|
function summaryFromInput(input: BlogPostInput) {
|
||||||
|
return {
|
||||||
|
slug: input.slug,
|
||||||
|
title: input.title,
|
||||||
|
date: optionalText(input.date),
|
||||||
|
description: optionalText(input.description),
|
||||||
|
tags: normalizeTags(input.tags),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertAdmin() {
|
||||||
|
const admin = await isAdmin();
|
||||||
|
if (!admin) throw new TRPCError({ code: "FORBIDDEN", message: "Access denied" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadMdx(input: BlogPostInput) {
|
||||||
|
const mdxContent = createMdxContent(input);
|
||||||
|
const customId = blogCustomId(input.slug);
|
||||||
|
const file = new UTFile([mdxContent], blogFileName(input.slug), {
|
||||||
|
customId,
|
||||||
|
type: "text/plain",
|
||||||
|
});
|
||||||
|
const result = await utapi.uploadFiles(file);
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: result.error.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.data.ufsUrl) {
|
||||||
|
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "UploadThing did not return a file URL" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
fileKey: result.data.key,
|
||||||
|
fileUrl: result.data.ufsUrl,
|
||||||
|
fileName: result.data.name,
|
||||||
|
customId: result.data.customId ?? customId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchMdx(fileUrl: string): Promise<string> {
|
||||||
|
const res = await fetch(fileUrl, { next: { revalidate: 3600 } });
|
||||||
|
if (!res.ok) throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Failed to fetch MDX file" });
|
||||||
|
return res.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileUrl(file: Pick<UploadThingFile, "key">): string {
|
||||||
|
return `https://utfs.io/f/${file.key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugFromFileName(name: string): string {
|
||||||
|
const prefix = cleanPrefix();
|
||||||
|
const withoutExtension = name.replace(/\.mdx?$/, "");
|
||||||
|
if (prefix && withoutExtension.startsWith(`${prefix}-`)) return withoutExtension.slice(prefix.length + 1);
|
||||||
|
if (prefix && withoutExtension.startsWith(`${prefix}/`)) return withoutExtension.slice(prefix.length + 1);
|
||||||
|
return withoutExtension;
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugFromCustomId(customId: string): string {
|
||||||
|
const prefix = cleanPrefix();
|
||||||
|
const value = prefix && customId.startsWith(`${prefix}:`)
|
||||||
|
? customId.slice(prefix.length + 1)
|
||||||
|
: customId;
|
||||||
|
return value.split(":")[0] ?? value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileMatchesPrefix(file: Pick<UploadThingFile, "name">): boolean {
|
||||||
|
if (!/\.mdx?$/.test(file.name)) return false;
|
||||||
|
|
||||||
|
const prefix = cleanPrefix();
|
||||||
|
if (!prefix) return true;
|
||||||
|
|
||||||
|
return file.name.startsWith(`${prefix}-`) || file.name.startsWith(`${prefix}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function metadataFromFile(file: UploadThingFile, raw: string) {
|
||||||
|
const parsed = matter(raw);
|
||||||
|
const fallbackSlug = file.customId ? slugFromCustomId(file.customId) : slugFromFileName(file.name);
|
||||||
|
|
||||||
|
return {
|
||||||
|
slug: String(parsed.data.slug ?? fallbackSlug),
|
||||||
|
title: String(parsed.data.title ?? fallbackSlug),
|
||||||
|
date: frontmatterText(parsed.data.date),
|
||||||
|
description: frontmatterText(parsed.data.description),
|
||||||
|
tags: normalizeTags(parsed.data.tags),
|
||||||
|
fileKey: file.key,
|
||||||
|
fileUrl: fileUrl(file),
|
||||||
|
fileName: file.name,
|
||||||
|
customId: file.customId ?? blogCustomId(fallbackSlug),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listAllFiles(): Promise<readonly UploadThingFile[]> {
|
||||||
|
const files: UploadThingFile[] = [];
|
||||||
|
let offset = 0;
|
||||||
|
const limit = 500;
|
||||||
|
let hasMore = true;
|
||||||
|
|
||||||
|
while (hasMore) {
|
||||||
|
const page = await utapi.listFiles({ limit, offset });
|
||||||
|
files.push(...page.files);
|
||||||
|
hasMore = page.hasMore;
|
||||||
|
offset += limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const blogRouter = router({
|
||||||
|
insert: publicProcedure
|
||||||
|
.input(blogPostInput)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
await assertAdmin();
|
||||||
|
|
||||||
|
const existing = await db.query.blogPost.findFirst({
|
||||||
|
where(fields, operators) {
|
||||||
|
return operators.eq(fields.slug, input.slug);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (existing) throw new TRPCError({ code: "CONFLICT", message: `Post "${input.slug}" already exists` });
|
||||||
|
|
||||||
|
const uploaded = await uploadMdx(input);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.insert(blogPost).values({
|
||||||
|
slug: input.slug,
|
||||||
|
title: input.title,
|
||||||
|
date: optionalText(input.date),
|
||||||
|
description: optionalText(input.description),
|
||||||
|
tags: normalizeTags(input.tags),
|
||||||
|
...uploaded,
|
||||||
|
});
|
||||||
|
return [summaryFromInput(input)];
|
||||||
|
} catch (error) {
|
||||||
|
await utapi.deleteFiles(uploaded.fileKey);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
update: publicProcedure
|
||||||
|
.input(blogPostInput.extend({ originalSlug: z.string().min(1) }))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
await assertAdmin();
|
||||||
|
|
||||||
|
const existing = await db.query.blogPost.findFirst({
|
||||||
|
where(fields, operators) {
|
||||||
|
return operators.eq(fields.slug, input.originalSlug);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!existing) throw new TRPCError({ code: "NOT_FOUND", message: `Post "${input.originalSlug}" not found` });
|
||||||
|
|
||||||
|
if (input.slug !== input.originalSlug) {
|
||||||
|
const slugConflict = await db.query.blogPost.findFirst({
|
||||||
|
where(fields, operators) {
|
||||||
|
return operators.eq(fields.slug, input.slug);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (slugConflict) throw new TRPCError({ code: "CONFLICT", message: `Post "${input.slug}" already exists` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploaded = await uploadMdx(input);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.update(blogPost).set({
|
||||||
|
slug: input.slug,
|
||||||
|
title: input.title,
|
||||||
|
date: optionalText(input.date),
|
||||||
|
description: optionalText(input.description),
|
||||||
|
tags: normalizeTags(input.tags),
|
||||||
|
...uploaded,
|
||||||
|
}).where(eq(blogPost.id, existing.id));
|
||||||
|
|
||||||
|
await utapi.deleteFiles(existing.fileKey);
|
||||||
|
return [summaryFromInput(input)];
|
||||||
|
} catch (error) {
|
||||||
|
await utapi.deleteFiles(uploaded.fileKey);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
delete: publicProcedure
|
||||||
|
.input(z.object({ id: z.string() }))
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
await assertAdmin();
|
||||||
|
|
||||||
|
const post = await db.query.blogPost.findFirst({
|
||||||
|
where(fields, operators) {
|
||||||
|
return operators.eq(fields.slug, input.id);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!post) throw new TRPCError({ code: "NOT_FOUND", message: `Post "${input.id}" not found` });
|
||||||
|
|
||||||
|
await db.delete(blogPost).where(eq(blogPost.id, post.id));
|
||||||
|
await utapi.deleteFiles(post.fileKey);
|
||||||
|
return [];
|
||||||
|
}),
|
||||||
|
|
||||||
|
list: publicProcedure.query(async () => {
|
||||||
|
return db.select({
|
||||||
|
slug: blogPost.slug,
|
||||||
|
title: blogPost.title,
|
||||||
|
date: blogPost.date,
|
||||||
|
description: blogPost.description,
|
||||||
|
tags: blogPost.tags,
|
||||||
|
}).from(blogPost).orderBy(desc(blogPost.date), desc(blogPost.createdAt));
|
||||||
|
}),
|
||||||
|
|
||||||
|
bySlug: publicProcedure.input(z.string()).query(async ({ input: slug }) => {
|
||||||
|
const post = await db.query.blogPost.findFirst({
|
||||||
|
where(fields, operators) {
|
||||||
|
return operators.eq(fields.slug, slug);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!post) throw new TRPCError({ code: "NOT_FOUND", message: `Post "${slug}" not found` });
|
||||||
|
|
||||||
|
const raw = await fetchMdx(post.fileUrl);
|
||||||
|
const { content, data } = matter(raw);
|
||||||
|
|
||||||
|
return {
|
||||||
|
slug: post.slug,
|
||||||
|
content,
|
||||||
|
title: (data.title as string | undefined) ?? post.title,
|
||||||
|
date: frontmatterText(data.date) ?? post.date,
|
||||||
|
description: frontmatterText(data.description) ?? post.description,
|
||||||
|
tags: normalizeTags(data.tags).length > 0 ? normalizeTags(data.tags) : (post.tags ?? []),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
|
||||||
|
syncFromUploadThing: publicProcedure.mutation(async () => {
|
||||||
|
await assertAdmin();
|
||||||
|
|
||||||
|
const files = (await listAllFiles()).filter(fileMatchesPrefix);
|
||||||
|
const seenFileKeys = new Set<string>();
|
||||||
|
const seenSlugs = new Set<string>();
|
||||||
|
let created = 0;
|
||||||
|
let updated = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
let deleted = 0;
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
const raw = await fetchMdx(fileUrl(file));
|
||||||
|
const metadata = metadataFromFile(file, raw);
|
||||||
|
seenFileKeys.add(file.key);
|
||||||
|
seenSlugs.add(metadata.slug);
|
||||||
|
const existing = await db.query.blogPost.findFirst({
|
||||||
|
where(fields, operators) {
|
||||||
|
return or(operators.eq(fields.fileKey, file.key), operators.eq(fields.slug, metadata.slug));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await db.update(blogPost).set(metadata).where(eq(blogPost.id, existing.id));
|
||||||
|
updated += 1;
|
||||||
|
} else {
|
||||||
|
await db.insert(blogPost).values(metadata);
|
||||||
|
created += 1;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
skipped += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const posts = await db.select({
|
||||||
|
id: blogPost.id,
|
||||||
|
fileKey: blogPost.fileKey,
|
||||||
|
slug: blogPost.slug,
|
||||||
|
}).from(blogPost);
|
||||||
|
|
||||||
|
const stalePostIds = posts
|
||||||
|
.filter((post) => !seenFileKeys.has(post.fileKey) && !seenSlugs.has(post.slug))
|
||||||
|
.map((post) => post.id);
|
||||||
|
|
||||||
|
for (const id of stalePostIds) {
|
||||||
|
await db.delete(blogPost).where(eq(blogPost.id, id));
|
||||||
|
deleted += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { created, updated, skipped, deleted };
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -1,12 +1,92 @@
|
|||||||
import { publicProcedure, router } from "~/server/trpc";
|
import { publicProcedure, router } from "~/server/trpc";
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
|
|
||||||
|
type ReadmeRequest = {
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getReadmeRequest(sourceLink: string): ReadmeRequest | null {
|
||||||
|
let url: URL;
|
||||||
|
|
||||||
|
try {
|
||||||
|
url = new URL(sourceLink);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pathParts = url.pathname.split("/").filter(Boolean);
|
||||||
|
const [owner, repo] = pathParts;
|
||||||
|
|
||||||
|
if (!owner || !repo) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const repoName = repo.replace(/\.git$/, "");
|
||||||
|
|
||||||
|
if (url.hostname === "github.com" || url.hostname === "www.github.com") {
|
||||||
|
return {
|
||||||
|
url: `https://raw.githubusercontent.com/${owner}/${repoName}/main/README.md`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.hostname.includes("gitea.")) {
|
||||||
|
return {
|
||||||
|
url: `${url.origin}/${owner}/${repoName}/raw/branch/main/README.md`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchReadme(sourceLink: string) {
|
||||||
|
const readmeRequest = getReadmeRequest(sourceLink);
|
||||||
|
|
||||||
|
if (!readmeRequest) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(readmeRequest.url, {
|
||||||
|
headers: {
|
||||||
|
Accept: "text/plain",
|
||||||
|
},
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.text();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const projectRouter = router({
|
export const projectRouter = router({
|
||||||
listWithStack: publicProcedure.query(async () => {
|
listWithStack: publicProcedure.query(async () => {
|
||||||
return db.query.project.findMany({
|
const projects = await db.query.project.findMany({
|
||||||
with: {
|
with: {
|
||||||
techStack: true,
|
techStack: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
projects.map(async (project) => {
|
||||||
|
if (project.description?.length !== 0 || !project.sourceLink) {
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...project,
|
||||||
|
description: await fetchReadme(project.sourceLink),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -144,3 +144,43 @@
|
|||||||
.cl-button__google {
|
.cl-button__google {
|
||||||
display: none
|
display: none
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mde-form-field-fullscreen {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 100000;
|
||||||
|
display: flex !important;
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--background);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mde-form-field-editor-fullscreen.w-md-editor {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
height: calc(100vh - 72px) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mde-form-field-editor-fullscreen .w-md-editor-toolbar {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mde-form-field-editor-fullscreen .w-md-editor-content {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
height: auto !important;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mde-form-field-editor-fullscreen .w-md-editor-input,
|
||||||
|
.mde-form-field-editor-fullscreen .w-md-editor-area,
|
||||||
|
.mde-form-field-editor-fullscreen .w-md-editor-text,
|
||||||
|
.mde-form-field-editor-fullscreen .w-md-editor-text-pre,
|
||||||
|
.mde-form-field-editor-fullscreen .w-md-editor-text-input,
|
||||||
|
.mde-form-field-editor-fullscreen .w-md-editor-preview {
|
||||||
|
min-height: 0;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user