lightnet 3.10.6 → 3.10.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/__e2e__/admin.spec.ts +19 -19
  3. package/__e2e__/fixtures/basics/node_modules/.bin/astro +2 -2
  4. package/__e2e__/fixtures/basics/package.json +2 -2
  5. package/__tests__/utils/markdown.spec.ts +16 -0
  6. package/package.json +7 -7
  7. package/src/admin/components/form/DynamicArray.tsx +10 -7
  8. package/src/admin/components/form/Input.tsx +31 -16
  9. package/src/admin/components/form/LazyLoadedMarkdownEditor.tsx +34 -10
  10. package/src/admin/components/form/MarkdownEditor.tsx +10 -8
  11. package/src/admin/components/form/Select.tsx +21 -6
  12. package/src/admin/components/form/SubmitButton.tsx +12 -7
  13. package/src/admin/components/form/atoms/ErrorMessage.tsx +1 -1
  14. package/src/admin/components/form/atoms/FileUpload.tsx +115 -0
  15. package/src/admin/components/form/atoms/Hint.tsx +12 -3
  16. package/src/admin/components/form/atoms/Label.tsx +15 -12
  17. package/src/admin/components/form/hooks/use-field-dirty.tsx +12 -0
  18. package/src/admin/components/form/hooks/use-field-error.tsx +23 -2
  19. package/src/admin/i18n/admin-i18n.ts +21 -0
  20. package/src/admin/i18n/translations/en.yml +15 -9
  21. package/src/admin/pages/AdminRoute.astro +0 -2
  22. package/src/admin/pages/media/EditForm.tsx +16 -13
  23. package/src/admin/pages/media/EditRoute.astro +31 -15
  24. package/src/admin/pages/media/fields/Authors.tsx +4 -28
  25. package/src/admin/pages/media/fields/Categories.tsx +5 -38
  26. package/src/admin/pages/media/fields/Collections.tsx +19 -78
  27. package/src/admin/pages/media/fields/Image.tsx +86 -0
  28. package/src/admin/pages/media/file-system.ts +6 -2
  29. package/src/admin/pages/media/media-item-store.ts +32 -3
  30. package/src/admin/types/media-item.ts +20 -0
  31. package/src/astro-integration/config.ts +10 -0
  32. package/src/astro-integration/integration.ts +7 -3
  33. package/src/components/HighlightSection.astro +1 -1
  34. package/src/content/get-media-items.ts +2 -1
  35. package/src/i18n/react/i18n-context.ts +16 -5
  36. package/src/layouts/Page.astro +5 -4
  37. package/src/layouts/components/LanguagePicker.astro +11 -5
  38. package/src/layouts/components/Menu.astro +76 -10
  39. package/src/pages/details-page/components/main-details/EditButton.astro +1 -1
  40. package/src/pages/details-page/components/main-details/OpenButton.astro +1 -1
  41. package/src/pages/search-page/components/LoadingSkeleton.tsx +1 -1
  42. package/src/pages/search-page/components/SearchListItem.tsx +1 -1
  43. package/src/utils/markdown.ts +6 -0
  44. package/src/admin/components/form/atoms/Legend.tsx +0 -20
  45. /package/src/i18n/react/{useI18n.ts → use-i18n.ts} +0 -0
@@ -1,9 +1,8 @@
1
1
  import { type Control } from "react-hook-form"
2
2
 
3
- import ErrorMessage from "../../../components/form/atoms/ErrorMessage"
4
- import Label from "../../../components/form/atoms/Label"
5
3
  import DynamicArray from "../../../components/form/DynamicArray"
6
- import { useFieldError } from "../../../components/form/hooks/use-field-error"
4
+ import Input from "../../../components/form/Input"
5
+ import Select from "../../../components/form/Select"
7
6
  import type { MediaItem } from "../../../types/media-item"
8
7
 
9
8
  export default function Collections({
@@ -21,17 +20,28 @@ export default function Collections({
21
20
  name="collections"
22
21
  label="ln.admin.collections"
23
22
  renderElement={(index) => (
24
- <div className="flex w-full flex-col py-2">
25
- <CollectionSelect
26
- collections={collections}
23
+ <div className="flex w-full flex-col gap-4 py-2">
24
+ <Select
25
+ options={collections}
26
+ label="ln.admin.name"
27
+ labelSize="small"
28
+ preserveHintSpace={false}
29
+ name={`collections.${index}.collection`}
27
30
  control={control}
28
- index={index}
29
31
  defaultValue={defaultValue[index]?.collection}
30
32
  />
31
- <CollectionIndex
33
+ <Input
34
+ type="number"
32
35
  control={control}
33
- index={index}
36
+ label="ln.admin.position-in-collection"
37
+ labelSize="small"
38
+ step={1}
39
+ min={0}
40
+ preserveHintSpace={false}
34
41
  defaultValue={defaultValue[index]?.index}
42
+ {...control.register(`collections.${index}.index`, {
43
+ setValueAs: (value) => (value === "" ? undefined : Number(value)),
44
+ })}
35
45
  />
36
46
  </div>
37
47
  )}
@@ -46,72 +56,3 @@ export default function Collections({
46
56
  />
47
57
  )
48
58
  }
49
-
50
- function CollectionSelect({
51
- control,
52
- collections,
53
- index,
54
- defaultValue,
55
- }: {
56
- control: Control<MediaItem>
57
- collections: { id: string; labelText: string }[]
58
- defaultValue?: string
59
- index: number
60
- }) {
61
- const name = `collections.${index}.collection` as const
62
- const errorMessage = useFieldError({ name, control })
63
- return (
64
- <>
65
- <Label for={name} label="ln.admin.name" size="xs" />
66
- <select
67
- {...control.register(name)}
68
- id={name}
69
- defaultValue={defaultValue}
70
- aria-invalid={!!errorMessage}
71
- className={`dy-select dy-select-bordered text-base shadow-sm ${errorMessage ? "dy-select-error" : ""}`}
72
- >
73
- {collections.map(({ id, labelText }) => (
74
- <option key={id} value={id}>
75
- {labelText}
76
- </option>
77
- ))}
78
- </select>
79
- <ErrorMessage message={errorMessage} />
80
- </>
81
- )
82
- }
83
-
84
- function CollectionIndex({
85
- control,
86
- index,
87
- defaultValue,
88
- }: {
89
- control: Control<MediaItem>
90
- index: number
91
- defaultValue?: number
92
- }) {
93
- const name = `collections.${index}.index` as const
94
- const errorMessage = useFieldError({ name, control })
95
- return (
96
- <>
97
- <Label
98
- for={name}
99
- label="ln.admin.position-in-collection"
100
- size="xs"
101
- className="mt-3"
102
- />
103
- <input
104
- className={`dy-input dy-input-bordered shadow-inner ${errorMessage ? "dy-input-error" : ""}`}
105
- aria-invalid={!!errorMessage}
106
- type="number"
107
- id={name}
108
- defaultValue={defaultValue}
109
- step={1}
110
- {...control.register(name, {
111
- setValueAs: (value) => (value === "" ? undefined : Number(value)),
112
- })}
113
- />
114
- <ErrorMessage message={errorMessage} />
115
- </>
116
- )
117
- }
@@ -0,0 +1,86 @@
1
+ import { useEffect, useRef, useState } from "react"
2
+ import { type Control } from "react-hook-form"
3
+
4
+ import ErrorMessage from "../../../components/form/atoms/ErrorMessage"
5
+ import FileUpload from "../../../components/form/atoms/FileUpload"
6
+ import Hint from "../../../components/form/atoms/Hint"
7
+ import Label from "../../../components/form/atoms/Label"
8
+ import { useFieldDirty } from "../../../components/form/hooks/use-field-dirty"
9
+ import { useFieldError } from "../../../components/form/hooks/use-field-error"
10
+ import type { MediaItem } from "../../../types/media-item"
11
+
12
+ const acceptedFileTypes = ["image/jpeg", "image/png", "image/webp"] as const
13
+
14
+ export default function Image({
15
+ control,
16
+ defaultValue,
17
+ mediaId,
18
+ }: {
19
+ control: Control<MediaItem>
20
+ defaultValue: MediaItem["image"]
21
+ mediaId: string
22
+ }) {
23
+ const objectUrlRef = useRef<string | null>(null)
24
+ const [previewSrc, setPreviewSrc] = useState<string | undefined>(
25
+ defaultValue.previewSrc,
26
+ )
27
+ const isDirty = useFieldDirty({ control, name: "image" })
28
+ const errorMessage = useFieldError({ control, name: "image", exact: false })
29
+
30
+ useEffect(() => {
31
+ // cleanup on component unmount
32
+ return () => {
33
+ if (objectUrlRef.current) {
34
+ URL.revokeObjectURL(objectUrlRef.current)
35
+ }
36
+ }
37
+ }, [])
38
+
39
+ const updateImage = (file?: File) => {
40
+ if (!file) {
41
+ return
42
+ }
43
+ if (!acceptedFileTypes.includes(file.type as any)) {
44
+ return
45
+ }
46
+ if (objectUrlRef.current) {
47
+ URL.revokeObjectURL(objectUrlRef.current)
48
+ }
49
+ const objectUrl = URL.createObjectURL(file)
50
+ objectUrlRef.current = objectUrl
51
+ setPreviewSrc(objectUrl)
52
+ }
53
+
54
+ return (
55
+ <div className="group flex w-full flex-col">
56
+ <label htmlFor="image">
57
+ <Label
58
+ label="ln.admin.image"
59
+ isDirty={isDirty}
60
+ isInvalid={!!errorMessage}
61
+ />
62
+ </label>
63
+ <div
64
+ className={`flex w-full items-stretch gap-4 rounded-lg rounded-ss-none border bg-gray-50 px-4 py-3 shadow-inner outline-none transition-colors ${isDirty && !errorMessage ? "border-gray-700" : "border-gray-300"} ${errorMessage ? "border-rose-800" : ""} `}
65
+ >
66
+ <div className="flex h-32 w-32 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-gray-200 p-1">
67
+ <img
68
+ src={previewSrc}
69
+ alt=""
70
+ className="h-full w-full object-contain"
71
+ />
72
+ </div>
73
+ <FileUpload
74
+ name="image"
75
+ control={control}
76
+ onFileChange={updateImage}
77
+ destinationPath="./images"
78
+ acceptedFileTypes={acceptedFileTypes}
79
+ fileName={mediaId}
80
+ />
81
+ </div>
82
+ <ErrorMessage message={errorMessage} />
83
+ <Hint preserveSpace={true} label="ln.admin.image-hint" />
84
+ </div>
85
+ )
86
+ }
@@ -1,9 +1,13 @@
1
- export const writeFile = (path: string, body: string) => {
1
+ export const writeFile = (
2
+ path: string,
3
+ body: BodyInit,
4
+ contentType?: string,
5
+ ) => {
2
6
  return fetch(
3
7
  `/api/internal/fs/write-file?path=${encodeURIComponent(path.replace(/^\//, ""))}`,
4
8
  {
5
9
  method: "POST",
6
- headers: { "Content-Type": resolveContentType(path) },
10
+ headers: { "Content-Type": contentType ?? resolveContentType(path) },
7
11
  body,
8
12
  },
9
13
  )
@@ -1,13 +1,42 @@
1
1
  import { type MediaItem } from "../../types/media-item"
2
- import { writeJson } from "./file-system"
2
+ import { writeFile, writeJson } from "./file-system"
3
3
 
4
4
  export const updateMediaItem = async (id: string, item: MediaItem) => {
5
- return writeJson(`/src/content/media/${id}.json`, mapToContentSchema(item))
5
+ const imagePath = await saveImage(item.image)
6
+ return writeJson(
7
+ `/src/content/media/${id}.json`,
8
+ mapToContentSchema(item, imagePath),
9
+ )
6
10
  }
7
11
 
8
- const mapToContentSchema = (item: MediaItem) => {
12
+ const ensureRelativeImagePath = (path: string) => {
13
+ const trimmed = path.trim()
14
+ if (!trimmed) {
15
+ return ""
16
+ }
17
+ if (trimmed.startsWith("./")) {
18
+ return trimmed
19
+ }
20
+ return `./${trimmed}`
21
+ }
22
+
23
+ const saveImage = async (image: MediaItem["image"]) => {
24
+ const relativePath = ensureRelativeImagePath(image?.path ?? "")
25
+ if (!relativePath || !image?.file) {
26
+ return relativePath
27
+ }
28
+ await writeFile(
29
+ `/src/content/media/${relativePath.replace(/^\.\//, "")}`,
30
+ await image.file.arrayBuffer(),
31
+ image.file.type || "application/octet-stream",
32
+ )
33
+ return relativePath
34
+ }
35
+
36
+ const mapToContentSchema = (item: MediaItem, imagePath: string) => {
9
37
  return {
10
38
  ...item,
39
+ image: imagePath,
11
40
  authors: flatten(item.authors),
12
41
  categories: flatten(item.categories),
13
42
  }
@@ -1,4 +1,5 @@
1
1
  import { type RefinementCtx, z } from "astro/zod"
2
+ import config from "virtual:lightnet/config"
2
3
 
3
4
  const NON_EMPTY_STRING = "ln.admin.errors.non-empty-string"
4
5
  const INVALID_DATE = "ln.admin.errors.invalid-date"
@@ -6,6 +7,7 @@ const REQUIRED = "ln.admin.errors.required"
6
7
  const GTE_0 = "ln.admin.errors.gte-0"
7
8
  const INTEGER = "ln.admin.errors.integer"
8
9
  const UNIQUE_ELEMENTS = "ln.admin.errors.unique-elements"
10
+ const FILE_SIZE_EXCEEDED = "ln.admin.error.file-size-exceeded"
9
11
 
10
12
  const unique = <TArrayItem>(path: Extract<keyof TArrayItem, string>) => {
11
13
  return (values: TArrayItem[], ctx: RefinementCtx) => {
@@ -23,6 +25,19 @@ const unique = <TArrayItem>(path: Extract<keyof TArrayItem, string>) => {
23
25
  }
24
26
  }
25
27
 
28
+ const fileShape = z
29
+ .instanceof(File)
30
+ .optional()
31
+ .refine(
32
+ (file) =>
33
+ !file ||
34
+ !!(
35
+ file.size <
36
+ (config.experimental?.admin?.maxFileSize ?? 0) * 1024 * 1024
37
+ ),
38
+ { message: FILE_SIZE_EXCEEDED },
39
+ )
40
+
26
41
  export const mediaItemSchema = z.object({
27
42
  commonId: z.string().nonempty(NON_EMPTY_STRING),
28
43
  title: z.string().nonempty(NON_EMPTY_STRING),
@@ -47,6 +62,11 @@ export const mediaItemSchema = z.object({
47
62
  .superRefine(unique("collection")),
48
63
  dateCreated: z.string().date(INVALID_DATE),
49
64
  description: z.string().optional(),
65
+ image: z.object({
66
+ path: z.string().nonempty(NON_EMPTY_STRING),
67
+ previewSrc: z.string(),
68
+ file: fileShape,
69
+ }),
50
70
  })
51
71
 
52
72
  export type MediaItem = z.input<typeof mediaItemSchema>
@@ -228,6 +228,16 @@ export const configSchema = z.object({
228
228
  admin: z
229
229
  .object({
230
230
  enabled: z.boolean().default(false),
231
+ /**
232
+ * Currently we only support english as Admin UI language.
233
+ */
234
+ languageCode: z.literal("en").default("en"),
235
+ /**
236
+ * Max file size to upload in mega bytes.
237
+ *
238
+ * Default is 25 (this aligns with Cloudflare's max file size).
239
+ */
240
+ maxFileSize: z.number().default(25),
231
241
  })
232
242
  .optional(),
233
243
  })
@@ -72,12 +72,12 @@ export function lightnet(lightnetConfig: LightnetConfig): AstroIntegration {
72
72
  })
73
73
 
74
74
  injectRoute({
75
- pattern: "/[locale]/admin",
75
+ pattern: "/admin",
76
76
  entrypoint: "lightnet/admin/pages/AdminRoute.astro",
77
77
  prerender: true,
78
78
  })
79
79
  injectRoute({
80
- pattern: "/[locale]/admin/media/[mediaId]",
80
+ pattern: "/admin/media/[mediaId]",
81
81
  entrypoint: "lightnet/admin/pages/media/EditRoute.astro",
82
82
  prerender: true,
83
83
  })
@@ -114,7 +114,11 @@ export function lightnet(lightnetConfig: LightnetConfig): AstroIntegration {
114
114
  locales: resolveLocales(config),
115
115
  routing: {
116
116
  redirectToDefaultLocale: false,
117
- prefixDefaultLocale: true,
117
+ // We need to set this to false to allow for
118
+ // admin paths without locale. But actually
119
+ // the default locale will be prefixed for regular
120
+ // LightNet pages.
121
+ prefixDefaultLocale: false,
118
122
  fallbackType: "rewrite",
119
123
  },
120
124
  },
@@ -53,7 +53,7 @@ const { image, id, title, text, link, className, titleClass, textClass } =
53
53
  {
54
54
  link && (
55
55
  <a
56
- class="inline-flex items-center justify-center gap-2 rounded-2xl bg-primary px-6 py-3 text-sm font-bold uppercase text-gray-50 shadow-sm hover:bg-primary/85 hover:text-gray-100"
56
+ class="inline-flex items-center justify-center gap-2 rounded-2xl bg-primary px-6 py-3 text-sm font-bold text-gray-50 shadow-sm hover:bg-primary/85 hover:text-gray-100"
57
57
  href={link.href}
58
58
  >
59
59
  {link.text}
@@ -59,7 +59,8 @@ async function revertMediaItemEntry({ id, data: mediaItem }: MediaItemEntry) {
59
59
  ...collection,
60
60
  collection: collection.collection.id,
61
61
  }))
62
- const image = (await getEntry("internal-media-image-path", id))?.data.image
62
+ const image =
63
+ (await getEntry("internal-media-image-path", id))?.data.image ?? ""
63
64
  return {
64
65
  id,
65
66
  data: {
@@ -1,7 +1,7 @@
1
1
  import { createContext, useMemo } from "react"
2
2
 
3
3
  export type I18n = {
4
- t: (key: string) => string
4
+ t: (key: string, params?: Record<string, unknown>) => string
5
5
  currentLocale: string
6
6
  direction: "rtl" | "ltr"
7
7
  }
@@ -12,6 +12,17 @@ export type I18nConfig = Omit<I18n, "t"> & {
12
12
 
13
13
  export const I18nContext = createContext<I18n | undefined>(undefined)
14
14
 
15
+ const interpolate = (value: string, params?: Record<string, unknown>) => {
16
+ if (!params) {
17
+ return value
18
+ }
19
+ return Object.entries(params ?? {}).reduce(
20
+ (prev, [paramName, paramValue]) =>
21
+ prev.replaceAll(`{{${paramName}}}`, `${paramValue}`),
22
+ value,
23
+ )
24
+ }
25
+
15
26
  /**
16
27
  * Creates the runtime i18n helpers given a prepared configuration.
17
28
  * Wraps the raw translation dictionary with a lookup that throws on missing keys.
@@ -22,16 +33,16 @@ export const createI18n = ({
22
33
  direction,
23
34
  }: I18nConfig) => {
24
35
  return useMemo(() => {
25
- const t = (key: string) => {
36
+ const t = (key: string, params?: Record<string, unknown>) => {
26
37
  const value = translations[key]
27
38
  if (value) {
28
- return value
39
+ return interpolate(value, params)
29
40
  }
30
- if (key.match(/^(?:ln|x)\../i)) {
41
+ if (!key || key.match(/^(?:ln|x)\../i)) {
31
42
  console.error(`Missing translation for key ${key}`)
32
43
  return ""
33
44
  }
34
- return key
45
+ return interpolate(key, params)
35
46
  }
36
47
  return { t, currentLocale, direction }
37
48
  }, [])
@@ -14,17 +14,18 @@ interface Props {
14
14
  title?: string
15
15
  description?: string
16
16
  mainClass?: string
17
+ locale?: string
17
18
  }
18
19
 
19
- const { title, description, mainClass } = Astro.props
20
+ const { title, description, mainClass, locale } = Astro.props
20
21
  const configTitle = Astro.locals.i18n.t(config.title)
21
22
 
22
- const { currentLocale } = Astro.locals.i18n
23
- const language = resolveLanguage(currentLocale)
23
+ const currentLocale = locale ?? Astro.locals.i18n.currentLocale
24
+ const { direction } = resolveLanguage(currentLocale)
24
25
  ---
25
26
 
26
27
  <!doctype html>
27
- <html lang={currentLocale} dir={language.direction}>
28
+ <html lang={currentLocale} dir={direction}>
28
29
  <head>
29
30
  <meta charset="UTF-8" />
30
31
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -6,6 +6,11 @@ import MenuItem from "./MenuItem.astro"
6
6
 
7
7
  const { t, locales } = Astro.locals.i18n
8
8
 
9
+ const hasLocale =
10
+ Astro.currentLocale &&
11
+ (Astro.url.pathname.startsWith(`/${Astro.currentLocale}/`) ||
12
+ Astro.url.pathname === `/${Astro.currentLocale}`)
13
+
9
14
  const translations = locales
10
15
  .map((locale) => ({
11
16
  locale,
@@ -17,17 +22,18 @@ const translations = locales
17
22
 
18
23
  function currentPathWithLocale(locale: string) {
19
24
  const currentPath = Astro.url.pathname
20
- const currentPathWithoutLocale =
21
- Astro.currentLocale && currentPath.startsWith(`/${Astro.currentLocale}`)
22
- ? currentPath.slice(Astro.currentLocale.length + 1)
23
- : currentPath
25
+ const currentPathWithoutLocale = hasLocale
26
+ ? currentPath.slice(Astro.currentLocale.length + 1)
27
+ : currentPath
24
28
  return localizePath(locale, currentPathWithoutLocale)
25
29
  }
30
+
31
+ const disabled = !hasLocale && Astro.url.pathname !== "/"
26
32
  ---
27
33
 
28
34
  {
29
35
  translations.length > 1 && (
30
- <Menu icon="mdi--web" label="ln.header.select-language">
36
+ <Menu disabled={disabled} icon="mdi--web" label="ln.header.select-language">
31
37
  {translations.map(({ label, locale, active, href }) => (
32
38
  <MenuItem href={href} hreflang={locale} active={active}>
33
39
  {label}
@@ -4,25 +4,91 @@ import Icon from "../../components/Icon"
4
4
  interface Props {
5
5
  icon: string
6
6
  label: string
7
+ disabled?: boolean
7
8
  }
8
9
 
9
- const { icon, label } = Astro.props
10
+ const { icon, label, disabled } = Astro.props
10
11
  ---
11
12
 
12
- <div class="dy-dropdown dy-dropdown-end">
13
- <div
14
- role="button"
15
- tabindex="0"
13
+ <ln-menu class="relative flex h-full items-center">
14
+ <button
15
+ disabled={disabled}
16
+ aria-disabled={disabled}
16
17
  aria-label={Astro.locals.i18n.t(label)}
17
- class="flex cursor-pointer rounded-md p-3 text-gray-600 hover:text-primary"
18
+ class="flex rounded-md p-3 text-gray-600 hover:text-primary disabled:text-gray-300"
18
19
  >
19
20
  <Icon className={icon} ariaLabel="" />
20
- </div>
21
+ </button>
21
22
 
22
23
  <ul
23
- tabindex="0"
24
- class="dy-dropdown-content top-px me-3 mt-[3.25rem] w-48 overflow-hidden rounded-b-md bg-white py-3 shadow-lg sm:mt-16"
24
+ data-menu-panel
25
+ aria-hidden="true"
26
+ inert
27
+ class="pointer-events-none absolute right-3 top-full mt-px flex w-48 origin-top scale-y-90 flex-col overflow-hidden rounded-b-md bg-white py-3 opacity-0 shadow-lg transition-all duration-100 ease-out"
25
28
  >
26
29
  <slot />
27
30
  </ul>
28
- </div>
31
+ </ln-menu>
32
+ <script>
33
+ class Menu extends HTMLElement {
34
+ menuPanel = this.querySelector<HTMLElement>("[data-menu-panel]")!
35
+ isMenuOpened = false
36
+ handleOutsideClick = (event: MouseEvent) => {
37
+ if (!this.isMenuOpened) {
38
+ return
39
+ }
40
+ const target = event.target as Node | null
41
+ if (target && this.contains(target)) {
42
+ return
43
+ }
44
+ this.closeMenu()
45
+ }
46
+
47
+ toggleMenu() {
48
+ if (this.isMenuOpened) {
49
+ this.closeMenu()
50
+ } else {
51
+ this.openMenu()
52
+ }
53
+ }
54
+
55
+ openMenu() {
56
+ if (this.isMenuOpened) {
57
+ return
58
+ }
59
+ this.menuPanel.style.opacity = "1"
60
+ this.menuPanel.style.pointerEvents = "auto"
61
+ this.menuPanel.style.transform = "scaleY(1)"
62
+
63
+ this.menuPanel.setAttribute("aria-hidden", "false")
64
+ this.menuPanel.removeAttribute("inert")
65
+ document.addEventListener("click", this.handleOutsideClick)
66
+ this.isMenuOpened = true
67
+ }
68
+
69
+ closeMenu() {
70
+ if (!this.isMenuOpened) {
71
+ return
72
+ }
73
+ this.menuPanel.style.opacity = "0"
74
+ this.menuPanel.style.pointerEvents = "none"
75
+ this.menuPanel.style.transform = "scaleY(0.9)"
76
+
77
+ this.menuPanel.setAttribute("aria-hidden", "true")
78
+ this.menuPanel.setAttribute("inert", "")
79
+ document.removeEventListener("click", this.handleOutsideClick)
80
+ this.isMenuOpened = false
81
+ }
82
+
83
+ connectedCallback() {
84
+ this.querySelector("button")?.addEventListener("click", () =>
85
+ this.toggleMenu(),
86
+ )
87
+
88
+ window.addEventListener("beforeunload", () => {
89
+ this.closeMenu()
90
+ })
91
+ }
92
+ }
93
+ customElements.define("ln-menu", Menu)
94
+ </script>
@@ -14,7 +14,7 @@ const { mediaId } = Astro.props
14
14
  class="hidden cursor-pointer items-center gap-2 font-bold text-gray-700 underline"
15
15
  id="edit-btn"
16
16
  data-admin-enabled={config.experimental?.admin?.enabled}
17
- href={`/${Astro.currentLocale}/admin/media/${mediaId}`}
17
+ href={`/admin/media/${mediaId}`}
18
18
  ><Icon className="mdi--square-edit-outline" ariaLabel="" />
19
19
  {Astro.locals.i18n.t("ln.admin.edit")}</a
20
20
  >
@@ -14,7 +14,7 @@ const content = createContentMetadata(item.data.content[0])
14
14
  ---
15
15
 
16
16
  <a
17
- class="flex min-w-52 items-center justify-center gap-2 rounded-2xl bg-gray-800 px-6 py-3 font-bold uppercase text-gray-100 shadow-sm hover:bg-gray-950 hover:text-gray-300"
17
+ class="flex min-w-52 items-center justify-center gap-2 rounded-2xl bg-gray-800 px-6 py-3 font-bold text-gray-100 shadow-sm hover:bg-gray-950 hover:text-gray-300"
18
18
  href={content.url}
19
19
  target={content.target}
20
20
  hreflang={item.data.language}
@@ -1,5 +1,5 @@
1
1
  import Icon from "../../../components/Icon"
2
- import { useI18n } from "../../../i18n/react/useI18n"
2
+ import { useI18n } from "../../../i18n/react/use-i18n"
3
3
 
4
4
  export default function LoadingSkeleton() {
5
5
  const { direction } = useI18n()
@@ -1,6 +1,6 @@
1
1
  import CoverImageDecorator from "../../../components/CoverImageDecorator"
2
2
  import Icon from "../../../components/Icon"
3
- import { useI18n } from "../../../i18n/react/useI18n"
3
+ import { useI18n } from "../../../i18n/react/use-i18n"
4
4
  import { detailsPagePath } from "../../../utils/paths"
5
5
  import type { SearchItem } from "../api/search-response"
6
6
 
@@ -19,6 +19,12 @@ export function markdownToText(markdown?: string) {
19
19
  .replaceAll(/^#+ ?/gm, "")
20
20
  // lists
21
21
  .replaceAll(/^- /gm, "")
22
+ // escaped white space
23
+ .replaceAll(/&#x20;/g, " ")
24
+ // underlines
25
+ .replaceAll(/<\/?u>/g, "")
26
+ // code block
27
+ .replaceAll(/^```[a-zA-Z ]*\n/gm, "")
22
28
  // block quotes
23
29
  .replaceAll(/^>+ ?/gm, "")
24
30
  // bold and italics
@@ -1,20 +0,0 @@
1
- import { useI18n } from "../../../../i18n/react/useI18n"
2
-
3
- export default function Legend({
4
- label,
5
- size = "sm",
6
- className,
7
- }: {
8
- label: string
9
- className?: string
10
- size?: "sm" | "xs"
11
- }) {
12
- const { t } = useI18n()
13
- return (
14
- <legend
15
- className={`pb-2 font-bold uppercase text-gray-600 ${size === "sm" ? "text-sm" : "text-xs"} ${className}`}
16
- >
17
- {t(label)}
18
- </legend>
19
- )
20
- }
File without changes