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.
- package/CHANGELOG.md +23 -0
- package/__e2e__/admin.spec.ts +19 -19
- package/__e2e__/fixtures/basics/node_modules/.bin/astro +2 -2
- package/__e2e__/fixtures/basics/package.json +2 -2
- package/__tests__/utils/markdown.spec.ts +16 -0
- package/package.json +7 -7
- package/src/admin/components/form/DynamicArray.tsx +10 -7
- package/src/admin/components/form/Input.tsx +31 -16
- package/src/admin/components/form/LazyLoadedMarkdownEditor.tsx +34 -10
- package/src/admin/components/form/MarkdownEditor.tsx +10 -8
- package/src/admin/components/form/Select.tsx +21 -6
- package/src/admin/components/form/SubmitButton.tsx +12 -7
- package/src/admin/components/form/atoms/ErrorMessage.tsx +1 -1
- package/src/admin/components/form/atoms/FileUpload.tsx +115 -0
- package/src/admin/components/form/atoms/Hint.tsx +12 -3
- package/src/admin/components/form/atoms/Label.tsx +15 -12
- package/src/admin/components/form/hooks/use-field-dirty.tsx +12 -0
- package/src/admin/components/form/hooks/use-field-error.tsx +23 -2
- package/src/admin/i18n/admin-i18n.ts +21 -0
- package/src/admin/i18n/translations/en.yml +15 -9
- package/src/admin/pages/AdminRoute.astro +0 -2
- package/src/admin/pages/media/EditForm.tsx +16 -13
- package/src/admin/pages/media/EditRoute.astro +31 -15
- package/src/admin/pages/media/fields/Authors.tsx +4 -28
- package/src/admin/pages/media/fields/Categories.tsx +5 -38
- package/src/admin/pages/media/fields/Collections.tsx +19 -78
- package/src/admin/pages/media/fields/Image.tsx +86 -0
- package/src/admin/pages/media/file-system.ts +6 -2
- package/src/admin/pages/media/media-item-store.ts +32 -3
- package/src/admin/types/media-item.ts +20 -0
- package/src/astro-integration/config.ts +10 -0
- package/src/astro-integration/integration.ts +7 -3
- package/src/components/HighlightSection.astro +1 -1
- package/src/content/get-media-items.ts +2 -1
- package/src/i18n/react/i18n-context.ts +16 -5
- package/src/layouts/Page.astro +5 -4
- package/src/layouts/components/LanguagePicker.astro +11 -5
- package/src/layouts/components/Menu.astro +76 -10
- package/src/pages/details-page/components/main-details/EditButton.astro +1 -1
- package/src/pages/details-page/components/main-details/OpenButton.astro +1 -1
- package/src/pages/search-page/components/LoadingSkeleton.tsx +1 -1
- package/src/pages/search-page/components/SearchListItem.tsx +1 -1
- package/src/utils/markdown.ts +6 -0
- package/src/admin/components/form/atoms/Legend.tsx +0 -20
- /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
|
|
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
|
-
<
|
|
26
|
-
|
|
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
|
-
<
|
|
33
|
+
<Input
|
|
34
|
+
type="number"
|
|
32
35
|
control={control}
|
|
33
|
-
|
|
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 = (
|
|
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
|
-
|
|
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
|
|
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: "/
|
|
75
|
+
pattern: "/admin",
|
|
76
76
|
entrypoint: "lightnet/admin/pages/AdminRoute.astro",
|
|
77
77
|
prerender: true,
|
|
78
78
|
})
|
|
79
79
|
injectRoute({
|
|
80
|
-
pattern: "/
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
}, [])
|
package/src/layouts/Page.astro
CHANGED
|
@@ -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
|
|
23
|
-
const
|
|
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={
|
|
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
|
-
|
|
22
|
-
|
|
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
|
-
<
|
|
13
|
-
<
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
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
|
-
</
|
|
21
|
+
</button>
|
|
21
22
|
|
|
22
23
|
<ul
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
</
|
|
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={
|
|
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
|
|
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,6 +1,6 @@
|
|
|
1
1
|
import CoverImageDecorator from "../../../components/CoverImageDecorator"
|
|
2
2
|
import Icon from "../../../components/Icon"
|
|
3
|
-
import { useI18n } from "../../../i18n/react/
|
|
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
|
|
package/src/utils/markdown.ts
CHANGED
|
@@ -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(/ /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
|