lightnet 3.10.8 → 3.12.0
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 +18 -0
- package/__e2e__/admin.spec.ts +35 -1
- package/__e2e__/fixtures/basics/node_modules/.bin/astro +2 -2
- package/__e2e__/fixtures/basics/node_modules/.bin/tailwind +2 -2
- package/__e2e__/fixtures/basics/node_modules/.bin/tailwindcss +2 -2
- package/__e2e__/fixtures/basics/package.json +5 -5
- package/__tests__/pages/details-page/create-content-metadata.spec.ts +23 -3
- package/package.json +8 -8
- package/src/admin/components/form/DynamicArray.tsx +81 -29
- package/src/admin/components/form/Input.tsx +17 -3
- package/src/admin/components/form/LazyLoadedMarkdownEditor.tsx +12 -5
- package/src/admin/components/form/MarkdownEditor.tsx +12 -4
- package/src/admin/components/form/Select.tsx +25 -13
- package/src/admin/components/form/SubmitButton.tsx +2 -2
- package/src/admin/components/form/atoms/Button.tsx +27 -0
- package/src/admin/components/form/atoms/FileUpload.tsx +124 -49
- package/src/admin/components/form/atoms/Hint.tsx +2 -2
- package/src/admin/components/form/atoms/Label.tsx +17 -6
- package/src/admin/components/form/utils/get-border-class.ts +22 -0
- package/src/admin/i18n/translations/en.yml +19 -3
- package/src/admin/pages/AdminRoute.astro +1 -1
- package/src/admin/pages/media/EditForm.tsx +33 -15
- package/src/admin/pages/media/EditRoute.astro +2 -2
- package/src/admin/pages/media/fields/Authors.tsx +15 -5
- package/src/admin/pages/media/fields/Categories.tsx +17 -5
- package/src/admin/pages/media/fields/Collections.tsx +21 -11
- package/src/admin/pages/media/fields/Content.tsx +150 -0
- package/src/admin/pages/media/fields/Image.tsx +43 -10
- package/src/admin/pages/media/media-item-store.ts +16 -2
- package/src/admin/types/media-item.ts +9 -0
- package/src/components/SearchInput.astro +3 -3
- package/src/components/VideoPlayer.astro +74 -10
- package/src/i18n/react/prepare-i18n-config.ts +1 -1
- package/src/i18n/react/use-i18n.ts +1 -1
- package/src/i18n/translations/TRANSLATION-STATUS.md +4 -0
- package/src/i18n/translations/en.yml +3 -4
- package/src/i18n/translations/ur.yml +25 -0
- package/src/i18n/translations.ts +1 -0
- package/src/layouts/Page.astro +0 -2
- package/src/layouts/components/Menu.astro +1 -1
- package/src/pages/404Route.astro +3 -1
- package/src/pages/details-page/components/AudioPanel.astro +1 -12
- package/src/pages/details-page/components/AudioPlayer.astro +40 -8
- package/src/pages/details-page/utils/create-content-metadata.ts +2 -1
- package/src/pages/search-page/components/LoadingSkeleton.tsx +20 -13
- package/src/pages/search-page/components/SearchFilter.tsx +2 -2
- package/src/pages/search-page/components/SearchList.tsx +33 -29
- package/src/pages/search-page/components/Select.tsx +15 -13
- package/src/layouts/components/PreloadReact.tsx +0 -3
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { type Control, useWatch } from "react-hook-form"
|
|
2
|
+
|
|
3
|
+
import Icon from "../../../../components/Icon"
|
|
4
|
+
import { useI18n } from "../../../../i18n/react/use-i18n"
|
|
5
|
+
import { createContentMetadata } from "../../../../pages/details-page/utils/create-content-metadata"
|
|
6
|
+
import Button from "../../../components/form/atoms/Button"
|
|
7
|
+
import FileUpload from "../../../components/form/atoms/FileUpload"
|
|
8
|
+
import DynamicArray from "../../../components/form/DynamicArray"
|
|
9
|
+
import Input from "../../../components/form/Input"
|
|
10
|
+
import type { MediaItem } from "../../../types/media-item"
|
|
11
|
+
|
|
12
|
+
export default function Content({
|
|
13
|
+
control,
|
|
14
|
+
defaultValue,
|
|
15
|
+
}: {
|
|
16
|
+
control: Control<MediaItem>
|
|
17
|
+
defaultValue: MediaItem["content"]
|
|
18
|
+
}) {
|
|
19
|
+
const { t } = useI18n()
|
|
20
|
+
return (
|
|
21
|
+
<DynamicArray
|
|
22
|
+
control={control}
|
|
23
|
+
name="content"
|
|
24
|
+
required
|
|
25
|
+
label="ln.admin.content"
|
|
26
|
+
renderElement={(index) => (
|
|
27
|
+
<div className="flex w-full flex-col gap-6">
|
|
28
|
+
<URLInput
|
|
29
|
+
control={control}
|
|
30
|
+
defaultValue={defaultValue[index]?.url}
|
|
31
|
+
index={index}
|
|
32
|
+
/>
|
|
33
|
+
<LabelInput
|
|
34
|
+
control={control}
|
|
35
|
+
defaultValue={defaultValue[index]?.label}
|
|
36
|
+
index={index}
|
|
37
|
+
/>
|
|
38
|
+
</div>
|
|
39
|
+
)}
|
|
40
|
+
renderElementMeta={(index) => {
|
|
41
|
+
if (index !== 0) {
|
|
42
|
+
return <span></span>
|
|
43
|
+
}
|
|
44
|
+
return (
|
|
45
|
+
<span className="ms-1 flex items-center gap-1 rounded-lg bg-slate-200 px-2 py-1 text-xs font-bold uppercase text-slate-600">
|
|
46
|
+
<Icon className="text-sm text-sky-700 mdi--star" ariaLabel="" />
|
|
47
|
+
{t("ln.admin.primary-content")}
|
|
48
|
+
</span>
|
|
49
|
+
)
|
|
50
|
+
}}
|
|
51
|
+
renderAddButton={({ addElement, index }) => (
|
|
52
|
+
<div className="flex w-2/3 flex-col items-center gap-2 pb-2 pt-4">
|
|
53
|
+
<Button
|
|
54
|
+
variant="secondary"
|
|
55
|
+
onClick={() =>
|
|
56
|
+
addElement({ url: "" }, { focusName: `content.${index}.url` })
|
|
57
|
+
}
|
|
58
|
+
>
|
|
59
|
+
<Icon className="mdi--link-variant" ariaLabel="" />
|
|
60
|
+
{t("ln.admin.add-link")}
|
|
61
|
+
</Button>
|
|
62
|
+
<span className="text-sm font-bold uppercase text-slate-500">
|
|
63
|
+
{t("ln.admin.or")}
|
|
64
|
+
</span>
|
|
65
|
+
<FileUpload
|
|
66
|
+
title="ln.admin.files-upload-title"
|
|
67
|
+
description="ln.admin.files-upload-description"
|
|
68
|
+
icon="mdi--file-upload-outline"
|
|
69
|
+
multiple
|
|
70
|
+
onUpload={(...files: File[]) =>
|
|
71
|
+
files.forEach((file, i) => {
|
|
72
|
+
addElement(
|
|
73
|
+
{ url: `/files/${file.name}`, file },
|
|
74
|
+
{ focusName: `content.${index + i}.url` },
|
|
75
|
+
)
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
/>
|
|
79
|
+
</div>
|
|
80
|
+
)}
|
|
81
|
+
/>
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function URLInput({
|
|
86
|
+
control,
|
|
87
|
+
defaultValue,
|
|
88
|
+
index,
|
|
89
|
+
}: {
|
|
90
|
+
defaultValue?: string
|
|
91
|
+
index: number
|
|
92
|
+
control: Control<MediaItem>
|
|
93
|
+
}) {
|
|
94
|
+
const { url, file } = useWatch({ control, name: `content.${index}` })
|
|
95
|
+
const { t } = useI18n()
|
|
96
|
+
const isFile = url.startsWith("/files/")
|
|
97
|
+
|
|
98
|
+
const label = () => {
|
|
99
|
+
if (!isFile) {
|
|
100
|
+
return "ln.admin.link"
|
|
101
|
+
}
|
|
102
|
+
if (!file) {
|
|
103
|
+
return "ln.admin.file"
|
|
104
|
+
}
|
|
105
|
+
return t("ln.admin.file-upload", {
|
|
106
|
+
fileSize: (file.size / 1024 / 1024).toFixed(1),
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<Input
|
|
112
|
+
control={control}
|
|
113
|
+
label={label()}
|
|
114
|
+
disabled={isFile}
|
|
115
|
+
labelSize="small"
|
|
116
|
+
required
|
|
117
|
+
preserveHintSpace={false}
|
|
118
|
+
defaultValue={defaultValue}
|
|
119
|
+
name={`content.${index}.url`}
|
|
120
|
+
/>
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function LabelInput({
|
|
125
|
+
control,
|
|
126
|
+
defaultValue,
|
|
127
|
+
index,
|
|
128
|
+
}: {
|
|
129
|
+
control: Control<MediaItem>
|
|
130
|
+
defaultValue?: string
|
|
131
|
+
index: number
|
|
132
|
+
}) {
|
|
133
|
+
const url = useWatch({ control, name: `content.${index}.url` })
|
|
134
|
+
const { label: defaultLabel } = createContentMetadata({ url })
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<Input
|
|
138
|
+
name={`content.${index}.label`}
|
|
139
|
+
control={control}
|
|
140
|
+
label="ln.admin.label"
|
|
141
|
+
labelSize="small"
|
|
142
|
+
placeholder={defaultLabel}
|
|
143
|
+
preserveHintSpace={false}
|
|
144
|
+
defaultValue={defaultValue}
|
|
145
|
+
registerOptions={{
|
|
146
|
+
setValueAs: (value) => value?.trim() || undefined,
|
|
147
|
+
}}
|
|
148
|
+
/>
|
|
149
|
+
)
|
|
150
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useEffect, useRef, useState } from "react"
|
|
2
|
-
import { type Control } from "react-hook-form"
|
|
2
|
+
import { type Control, useController } from "react-hook-form"
|
|
3
3
|
|
|
4
4
|
import ErrorMessage from "../../../components/form/atoms/ErrorMessage"
|
|
5
5
|
import FileUpload from "../../../components/form/atoms/FileUpload"
|
|
@@ -11,6 +11,30 @@ import type { MediaItem } from "../../../types/media-item"
|
|
|
11
11
|
|
|
12
12
|
const acceptedFileTypes = ["image/jpeg", "image/png", "image/webp"] as const
|
|
13
13
|
|
|
14
|
+
const sanitizeImageUrl = (url?: string) => {
|
|
15
|
+
if (!url) {
|
|
16
|
+
return undefined
|
|
17
|
+
}
|
|
18
|
+
const trimmed = url.trim()
|
|
19
|
+
if (!trimmed) {
|
|
20
|
+
return undefined
|
|
21
|
+
}
|
|
22
|
+
if (
|
|
23
|
+
trimmed.startsWith("/") ||
|
|
24
|
+
trimmed.startsWith("./") ||
|
|
25
|
+
trimmed.startsWith("blob:")
|
|
26
|
+
) {
|
|
27
|
+
return trimmed
|
|
28
|
+
}
|
|
29
|
+
if (/^https?:\/\//i.test(trimmed)) {
|
|
30
|
+
return trimmed
|
|
31
|
+
}
|
|
32
|
+
if (/^data:image\/(png|jpe?g|webp);/i.test(trimmed)) {
|
|
33
|
+
return trimmed
|
|
34
|
+
}
|
|
35
|
+
return undefined
|
|
36
|
+
}
|
|
37
|
+
|
|
14
38
|
export default function Image({
|
|
15
39
|
control,
|
|
16
40
|
defaultValue,
|
|
@@ -20,6 +44,7 @@ export default function Image({
|
|
|
20
44
|
defaultValue: MediaItem["image"]
|
|
21
45
|
mediaId: string
|
|
22
46
|
}) {
|
|
47
|
+
const { field } = useController({ control, name: "image" })
|
|
23
48
|
const objectUrlRef = useRef<string | null>(null)
|
|
24
49
|
const [previewSrc, setPreviewSrc] = useState<string | undefined>(
|
|
25
50
|
defaultValue.previewSrc,
|
|
@@ -48,7 +73,15 @@ export default function Image({
|
|
|
48
73
|
}
|
|
49
74
|
const objectUrl = URL.createObjectURL(file)
|
|
50
75
|
objectUrlRef.current = objectUrl
|
|
76
|
+
|
|
77
|
+
const nameParts = file.name.split(".")
|
|
78
|
+
const extension = nameParts.pop()
|
|
51
79
|
setPreviewSrc(objectUrl)
|
|
80
|
+
field.onChange({
|
|
81
|
+
...field.value,
|
|
82
|
+
path: `./images/${mediaId}.${extension}`,
|
|
83
|
+
file,
|
|
84
|
+
})
|
|
52
85
|
}
|
|
53
86
|
|
|
54
87
|
return (
|
|
@@ -57,30 +90,30 @@ export default function Image({
|
|
|
57
90
|
<Label
|
|
58
91
|
label="ln.admin.image"
|
|
59
92
|
isDirty={isDirty}
|
|
93
|
+
required
|
|
60
94
|
isInvalid={!!errorMessage}
|
|
61
95
|
/>
|
|
62
96
|
</label>
|
|
63
97
|
<div
|
|
64
|
-
className={`flex w-full items-stretch gap-4 rounded-
|
|
98
|
+
className={`flex w-full items-stretch gap-4 rounded-xl rounded-ss-none border bg-slate-50 px-4 py-3 shadow-inner outline-none transition-colors focus-within:border-sky-700 focus-within:ring-1 focus-within:ring-sky-700 ${isDirty && !errorMessage ? "border-slate-700" : "border-slate-300"} ${errorMessage ? "border-rose-800" : ""} `}
|
|
65
99
|
>
|
|
66
|
-
<div className="flex h-32 w-32 shrink-0 items-center justify-center overflow-hidden rounded-
|
|
100
|
+
<div className="flex h-32 w-32 shrink-0 items-center justify-center overflow-hidden rounded-xl bg-slate-200 p-1">
|
|
67
101
|
<img
|
|
68
|
-
src={previewSrc}
|
|
102
|
+
src={sanitizeImageUrl(previewSrc)}
|
|
69
103
|
alt=""
|
|
70
104
|
className="h-full w-full object-contain"
|
|
71
105
|
/>
|
|
72
106
|
</div>
|
|
73
107
|
<FileUpload
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
108
|
+
title="ln.admin.image-upload-title"
|
|
109
|
+
description="ln.admin.image-upload-description"
|
|
110
|
+
onUpload={updateImage}
|
|
111
|
+
onBlur={field.onBlur}
|
|
78
112
|
acceptedFileTypes={acceptedFileTypes}
|
|
79
|
-
fileName={mediaId}
|
|
80
113
|
/>
|
|
81
114
|
</div>
|
|
82
115
|
<ErrorMessage message={errorMessage} />
|
|
83
|
-
<Hint preserveSpace={true}
|
|
116
|
+
<Hint preserveSpace={true} />
|
|
84
117
|
</div>
|
|
85
118
|
)
|
|
86
119
|
}
|
|
@@ -3,13 +3,14 @@ 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
|
+
await Promise.all(item.content.map(saveContentFile))
|
|
6
7
|
return writeJson(
|
|
7
8
|
`/src/content/media/${id}.json`,
|
|
8
9
|
mapToContentSchema(item, imagePath),
|
|
9
10
|
)
|
|
10
11
|
}
|
|
11
12
|
|
|
12
|
-
const
|
|
13
|
+
const ensureRelativePath = (path: string) => {
|
|
13
14
|
const trimmed = path.trim()
|
|
14
15
|
if (!trimmed) {
|
|
15
16
|
return ""
|
|
@@ -21,7 +22,7 @@ const ensureRelativeImagePath = (path: string) => {
|
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
const saveImage = async (image: MediaItem["image"]) => {
|
|
24
|
-
const relativePath =
|
|
25
|
+
const relativePath = ensureRelativePath(image?.path ?? "")
|
|
25
26
|
if (!relativePath || !image?.file) {
|
|
26
27
|
return relativePath
|
|
27
28
|
}
|
|
@@ -33,11 +34,24 @@ const saveImage = async (image: MediaItem["image"]) => {
|
|
|
33
34
|
return relativePath
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
const saveContentFile = async ({ url, file }: MediaItem["content"][number]) => {
|
|
38
|
+
if (!file) {
|
|
39
|
+
return
|
|
40
|
+
}
|
|
41
|
+
const path = `/public/${url.replace(/^\//, "")}`
|
|
42
|
+
return writeFile(
|
|
43
|
+
path,
|
|
44
|
+
await file.arrayBuffer(),
|
|
45
|
+
file.type || "application/octet-stream",
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
36
49
|
const mapToContentSchema = (item: MediaItem, imagePath: string) => {
|
|
37
50
|
return {
|
|
38
51
|
...item,
|
|
39
52
|
image: imagePath,
|
|
40
53
|
authors: flatten(item.authors),
|
|
54
|
+
content: item.content.map(({ file: _, ...content }) => content),
|
|
41
55
|
categories: flatten(item.categories),
|
|
42
56
|
}
|
|
43
57
|
}
|
|
@@ -8,6 +8,7 @@ const GTE_0 = "ln.admin.errors.gte-0"
|
|
|
8
8
|
const INTEGER = "ln.admin.errors.integer"
|
|
9
9
|
const UNIQUE_ELEMENTS = "ln.admin.errors.unique-elements"
|
|
10
10
|
const FILE_SIZE_EXCEEDED = "ln.admin.error.file-size-exceeded"
|
|
11
|
+
const NON_EMPTY_LIST = "ln.admin.errors.non-empty-list"
|
|
11
12
|
|
|
12
13
|
const unique = <TArrayItem>(path: Extract<keyof TArrayItem, string>) => {
|
|
13
14
|
return (values: TArrayItem[], ctx: RefinementCtx) => {
|
|
@@ -67,6 +68,14 @@ export const mediaItemSchema = z.object({
|
|
|
67
68
|
previewSrc: z.string(),
|
|
68
69
|
file: fileShape,
|
|
69
70
|
}),
|
|
71
|
+
content: z
|
|
72
|
+
.object({
|
|
73
|
+
url: z.string().nonempty(NON_EMPTY_STRING),
|
|
74
|
+
file: fileShape,
|
|
75
|
+
label: z.string().optional(),
|
|
76
|
+
})
|
|
77
|
+
.array()
|
|
78
|
+
.min(1, NON_EMPTY_LIST),
|
|
70
79
|
})
|
|
71
80
|
|
|
72
81
|
export type MediaItem = z.input<typeof mediaItemSchema>
|
|
@@ -12,11 +12,11 @@ const { t } = Astro.locals.i18n
|
|
|
12
12
|
action={`/${Astro.currentLocale}/media`}
|
|
13
13
|
method="get"
|
|
14
14
|
role="search"
|
|
15
|
-
class="group
|
|
15
|
+
class="group flex w-full rounded-2xl shadow-sm outline outline-2 outline-offset-2 outline-transparent transition-all ease-in-out group-focus-within:outline-gray-400"
|
|
16
16
|
class:list={[Astro.props.className]}
|
|
17
17
|
>
|
|
18
18
|
<input
|
|
19
|
-
class="
|
|
19
|
+
class="grow rounded-s-2xl bg-gray-100/95 px-4 py-3 text-gray-900 placeholder-gray-500 shadow-inner focus:outline-none"
|
|
20
20
|
enterkeyhint="search"
|
|
21
21
|
type="search"
|
|
22
22
|
name="search"
|
|
@@ -24,7 +24,7 @@ const { t } = Astro.locals.i18n
|
|
|
24
24
|
/>
|
|
25
25
|
<button
|
|
26
26
|
type="submit"
|
|
27
|
-
class="
|
|
27
|
+
class="flex items-center rounded-e-2xl border border-gray-100/95 bg-gray-800 px-4 py-3 text-gray-50 hover:bg-gray-950 hover:text-gray-300"
|
|
28
28
|
>
|
|
29
29
|
<Icon className="mdi--magnify" ariaLabel={t("ln.search.title")} />
|
|
30
30
|
</button>
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
---
|
|
2
2
|
import type { ImageMetadata } from "astro"
|
|
3
3
|
import { getImage } from "astro:assets"
|
|
4
|
+
import { Image } from "astro:assets"
|
|
5
|
+
|
|
6
|
+
import Icon from "./Icon"
|
|
4
7
|
|
|
5
8
|
interface Props {
|
|
6
9
|
/**
|
|
@@ -12,21 +15,43 @@ interface Props {
|
|
|
12
15
|
*/
|
|
13
16
|
title?: string
|
|
14
17
|
/**
|
|
15
|
-
* Poster image to use for the mp4 video player.
|
|
18
|
+
* Poster image to use for the mp4 video player or external fallback.
|
|
16
19
|
*/
|
|
17
20
|
image?: ImageMetadata
|
|
18
21
|
}
|
|
19
22
|
|
|
20
23
|
const { url, title, image: imageMetadata } = Astro.props
|
|
21
24
|
|
|
22
|
-
const { host, id, image } = await parseUrl(url)
|
|
25
|
+
const { host, id, image, href } = await parseUrl(url, imageMetadata)
|
|
26
|
+
const { t } = Astro.locals.i18n
|
|
23
27
|
|
|
24
|
-
async function parseUrl(
|
|
25
|
-
|
|
28
|
+
async function parseUrl(
|
|
29
|
+
urlToParse: string,
|
|
30
|
+
imageMetadata?: ImageMetadata,
|
|
31
|
+
): Promise<{
|
|
32
|
+
host: "youtube" | "vimeo" | "mp4" | "external"
|
|
26
33
|
id: string | null
|
|
27
34
|
image?: string
|
|
35
|
+
href?: string
|
|
28
36
|
}> {
|
|
29
|
-
const
|
|
37
|
+
const resolvedImage = async () => {
|
|
38
|
+
if (!imageMetadata) {
|
|
39
|
+
return undefined
|
|
40
|
+
}
|
|
41
|
+
return (await getImage({ src: imageMetadata, format: "webp" })).src
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let url: URL
|
|
45
|
+
try {
|
|
46
|
+
url = new URL(urlToParse)
|
|
47
|
+
} catch {
|
|
48
|
+
return {
|
|
49
|
+
host: "external",
|
|
50
|
+
id: null,
|
|
51
|
+
href: urlToParse,
|
|
52
|
+
image: await resolvedImage(),
|
|
53
|
+
}
|
|
54
|
+
}
|
|
30
55
|
// https://www.youtube.com/embed/ABC123abc
|
|
31
56
|
// https://www.youtube.com/watch?v=ABC123abc
|
|
32
57
|
if (url.hostname === "www.youtube.com") {
|
|
@@ -54,12 +79,15 @@ async function parseUrl(urlToParse: string): Promise<{
|
|
|
54
79
|
|
|
55
80
|
// https://domain.com/video.mp4
|
|
56
81
|
if (url.pathname.endsWith(".mp4")) {
|
|
57
|
-
const image =
|
|
58
|
-
imageMetadata &&
|
|
59
|
-
(await getImage({ src: imageMetadata, format: "webp" })).src
|
|
82
|
+
const image = await resolvedImage()
|
|
60
83
|
return { host: "mp4", id: url.toString(), image }
|
|
61
84
|
}
|
|
62
|
-
|
|
85
|
+
return {
|
|
86
|
+
host: "external",
|
|
87
|
+
id: null,
|
|
88
|
+
href: url.toString(),
|
|
89
|
+
image: await resolvedImage(),
|
|
90
|
+
}
|
|
63
91
|
}
|
|
64
92
|
---
|
|
65
93
|
|
|
@@ -86,6 +114,42 @@ async function parseUrl(urlToParse: string): Promise<{
|
|
|
86
114
|
<video class="h-full w-full" controls preload="auto" poster={image}>
|
|
87
115
|
<source src={id} type="video/mp4" />
|
|
88
116
|
</video>
|
|
89
|
-
) :
|
|
117
|
+
) : (
|
|
118
|
+
<a
|
|
119
|
+
class="group relative flex h-full w-full items-center justify-center focus:outline-none focus-visible:ring-2 focus-visible:ring-white/80 focus-visible:ring-offset-2 focus-visible:ring-offset-black"
|
|
120
|
+
href={href ?? url}
|
|
121
|
+
target="_blank"
|
|
122
|
+
rel="noreferrer noopener"
|
|
123
|
+
>
|
|
124
|
+
{imageMetadata ? (
|
|
125
|
+
<Image
|
|
126
|
+
src={imageMetadata}
|
|
127
|
+
alt={title ?? ""}
|
|
128
|
+
class="h-full w-full object-cover"
|
|
129
|
+
/>
|
|
130
|
+
) : (
|
|
131
|
+
<div class="flex h-full w-full items-center justify-center bg-gray-900 text-gray-200">
|
|
132
|
+
<span class="text-sm font-medium">{t("ln.external-link")}</span>
|
|
133
|
+
</div>
|
|
134
|
+
)}
|
|
135
|
+
<div class="absolute inset-0 z-10 bg-black/35 transition group-hover:bg-black/50" />
|
|
136
|
+
<div class="absolute inset-0 z-20 flex items-center justify-center">
|
|
137
|
+
<span class="flex h-14 w-14 items-center justify-center rounded-full bg-white/90 text-black shadow-lg transition group-hover:scale-105">
|
|
138
|
+
<svg
|
|
139
|
+
class="h-7 w-7"
|
|
140
|
+
viewBox="0 0 24 24"
|
|
141
|
+
fill="currentColor"
|
|
142
|
+
aria-hidden="true"
|
|
143
|
+
>
|
|
144
|
+
<path d="M8 5v14l11-7z" />
|
|
145
|
+
</svg>
|
|
146
|
+
</span>
|
|
147
|
+
</div>
|
|
148
|
+
<div class="absolute bottom-0 end-0 start-0 z-20 flex items-center gap-2 rounded bg-black/70 px-4 py-4 text-sm font-medium text-white backdrop-blur md:text-sm">
|
|
149
|
+
<Icon className="mdi--external-link" ariaLabel="" />
|
|
150
|
+
{t("ln.external-link")}
|
|
151
|
+
</div>
|
|
152
|
+
</a>
|
|
153
|
+
)
|
|
90
154
|
}
|
|
91
155
|
</div>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Prepares the configuration object passed from an Astro page to the
|
|
2
|
+
* Prepares the configuration object passed from an Astro page to the react i18n context.
|
|
3
3
|
* Resolves every requested translation key (supporting wildcard suffixes like `ln.dashboard.*`)
|
|
4
4
|
* so the React island receives only the strings it needs.
|
|
5
5
|
*
|
|
@@ -4,7 +4,7 @@ import { I18nContext } from "./i18n-context"
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Retrieves the current i18n helpers from context.
|
|
7
|
-
* Must be called inside a
|
|
7
|
+
* Must be called inside a react tree wrapped with `I18nContext.Provider`, otherwise throws.
|
|
8
8
|
*/
|
|
9
9
|
export const useI18n = () => {
|
|
10
10
|
const i18n = useContext(I18nContext)
|
|
@@ -60,12 +60,11 @@ ln.previous: Previous
|
|
|
60
60
|
# English: Next
|
|
61
61
|
ln.next: Next
|
|
62
62
|
|
|
63
|
-
#
|
|
64
|
-
# This is only "visible" to a screen-reader.
|
|
63
|
+
# Hint for an external link.
|
|
65
64
|
#
|
|
66
|
-
# English: External link
|
|
65
|
+
# English: External link - opens in a new tab
|
|
67
66
|
# Used on: https://sk8-ministries.pages.dev/en/media/ollie-with-integrity--en/
|
|
68
|
-
ln.external-link: External link
|
|
67
|
+
ln.external-link: External link - opens in a new tab
|
|
69
68
|
|
|
70
69
|
# Title for the search page.
|
|
71
70
|
#
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
ln.header.open-main-menu: مرکزی مینو کھولیں
|
|
2
|
+
ln.header.select-language: زبان منتخب کریں
|
|
3
|
+
ln.home.title: ہوم
|
|
4
|
+
ln.category: زمرہ
|
|
5
|
+
ln.categories: زمروں
|
|
6
|
+
ln.language: زبان
|
|
7
|
+
ln.languages: زبانیں
|
|
8
|
+
ln.type: قسم
|
|
9
|
+
ln.previous: پچھلا
|
|
10
|
+
ln.next: اگلا
|
|
11
|
+
ln.external-link: بیرونی لنک
|
|
12
|
+
ln.search.title: تلاش
|
|
13
|
+
ln.search.placeholder: میڈیا تلاش کریں
|
|
14
|
+
ln.search.all-languages: تمام زبانیں
|
|
15
|
+
ln.search.all-types: تمام اقسام
|
|
16
|
+
ln.search.all-categories: تمام زمرے
|
|
17
|
+
ln.search.no-results: کوئی نتیجہ نہیں ملا
|
|
18
|
+
ln.details.open: کھولیں
|
|
19
|
+
ln.details.share: شیئر کریں
|
|
20
|
+
ln.details.part-of-collection: مجموعے کا حصہ
|
|
21
|
+
ln.details.download: ڈاؤن لوڈ کریں
|
|
22
|
+
ln.share.url-copied-to-clipboard: لنک کاپی ہو گیا
|
|
23
|
+
ln.404.page-not-found: صفحہ نہیں ملا
|
|
24
|
+
ln.404.go-to-the-home-page: ہوم پیج پر جائیں
|
|
25
|
+
ln.footer.powered-by-lightnet: لائٹ نیٹ کے ذریعے چلایا گیا
|
package/src/i18n/translations.ts
CHANGED
|
@@ -18,6 +18,7 @@ const builtInTranslations = {
|
|
|
18
18
|
pt: () => import("./translations/pt.yml?raw"),
|
|
19
19
|
ru: () => import("./translations/ru.yml?raw"),
|
|
20
20
|
uk: () => import("./translations/uk.yml?raw"),
|
|
21
|
+
ur: () => import("./translations/ur.yml?raw"),
|
|
21
22
|
zh: () => import("./translations/zh.yml?raw"),
|
|
22
23
|
} as const
|
|
23
24
|
|
package/src/layouts/Page.astro
CHANGED
|
@@ -7,7 +7,6 @@ import { resolveLanguage } from "../i18n/resolve-language"
|
|
|
7
7
|
import Favicon from "./components/Favicon.astro"
|
|
8
8
|
import Footer from "./components/Footer.astro"
|
|
9
9
|
import Header from "./components/Header.astro"
|
|
10
|
-
import PreloadReact from "./components/PreloadReact"
|
|
11
10
|
import ViewTransition from "./components/ViewTransition.astro"
|
|
12
11
|
|
|
13
12
|
interface Props {
|
|
@@ -45,6 +44,5 @@ const { direction } = resolveLanguage(currentLocale)
|
|
|
45
44
|
<slot />
|
|
46
45
|
</main>
|
|
47
46
|
{CustomFooter ? <CustomFooter /> : <Footer />}
|
|
48
|
-
<PreloadReact client:idle />
|
|
49
47
|
</body>
|
|
50
48
|
</html>
|
|
@@ -24,7 +24,7 @@ const { icon, label, disabled } = Astro.props
|
|
|
24
24
|
data-menu-panel
|
|
25
25
|
aria-hidden="true"
|
|
26
26
|
inert
|
|
27
|
-
class="pointer-events-none absolute
|
|
27
|
+
class="pointer-events-none absolute end-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"
|
|
28
28
|
>
|
|
29
29
|
<slot />
|
|
30
30
|
</ul>
|
package/src/pages/404Route.astro
CHANGED
|
@@ -7,7 +7,9 @@ import Page from "../layouts/Page.astro"
|
|
|
7
7
|
<p class="pb-8 pt-20 text-center text-3xl opacity-80">
|
|
8
8
|
{Astro.locals.i18n.t("ln.404.page-not-found")}
|
|
9
9
|
</p>
|
|
10
|
-
<a
|
|
10
|
+
<a
|
|
11
|
+
href={`/${Astro.locals.i18n.defaultLocale}`}
|
|
12
|
+
class="rounded-2xl bg-gray-200 px-8 py-4 text-sm font-bold transition-colors ease-in-out hover:bg-gray-400"
|
|
11
13
|
>{Astro.locals.i18n.t("ln.404.go-to-the-home-page")}</a
|
|
12
14
|
>
|
|
13
15
|
</div>
|
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
---
|
|
2
|
-
import { AstroError } from "astro/errors"
|
|
3
|
-
|
|
4
2
|
import { getMediaItem } from "../../../content/get-media-items"
|
|
5
3
|
import { createContentMetadata } from "../utils/create-content-metadata"
|
|
6
4
|
import AudioPlayer from "./AudioPlayer.astro"
|
|
@@ -15,16 +13,7 @@ const { t } = Astro.locals.i18n
|
|
|
15
13
|
|
|
16
14
|
const item = await getMediaItem(mediaId)
|
|
17
15
|
|
|
18
|
-
const content = item.data.content
|
|
19
|
-
.map((c) => createContentMetadata(c))
|
|
20
|
-
.filter((c) => c.extension === "mp3")
|
|
21
|
-
|
|
22
|
-
if (!content.length) {
|
|
23
|
-
throw new AstroError(
|
|
24
|
-
`Missing mp3 content for ${mediaId}`,
|
|
25
|
-
`Add at least one mp3 file to the content array of /src/media/${mediaId}.json`,
|
|
26
|
-
)
|
|
27
|
-
}
|
|
16
|
+
const content = item.data.content.map((c) => createContentMetadata(c))
|
|
28
17
|
---
|
|
29
18
|
|
|
30
19
|
<ol
|
|
@@ -1,20 +1,52 @@
|
|
|
1
1
|
---
|
|
2
|
-
import
|
|
2
|
+
import Icon from "../../../components/Icon"
|
|
3
3
|
|
|
4
4
|
type Props = {
|
|
5
5
|
src: string
|
|
6
6
|
className?: string
|
|
7
7
|
}
|
|
8
8
|
const { src, className } = Astro.props
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
}
|
|
9
|
+
const { t } = Astro.locals.i18n
|
|
10
|
+
|
|
11
|
+
const normalizedSrc = src.trim()
|
|
12
|
+
const audioExtension = normalizedSrc.split("?")[0].split("#")[0].toLowerCase()
|
|
13
|
+
const isMp3 = audioExtension.endsWith(".mp3")
|
|
15
14
|
---
|
|
16
15
|
|
|
17
|
-
|
|
16
|
+
{
|
|
17
|
+
isMp3 ? (
|
|
18
|
+
<audio class="rounded-2xl" class:list={[className]} src={src} controls />
|
|
19
|
+
) : (
|
|
20
|
+
<a
|
|
21
|
+
class="group flex w-full items-center gap-4 rounded-2xl border border-gray-200 bg-white px-4 py-4 shadow-sm transition hover:border-gray-300 hover:bg-gray-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-gray-900/70"
|
|
22
|
+
class:list={[className]}
|
|
23
|
+
href={src}
|
|
24
|
+
target="_blank"
|
|
25
|
+
rel="noreferrer noopener"
|
|
26
|
+
aria-label={t("ln.external-link")}
|
|
27
|
+
>
|
|
28
|
+
<span class="flex h-12 w-12 items-center justify-center rounded-full border border-gray-700 text-gray-700 shadow-sm transition group-hover:scale-105">
|
|
29
|
+
<svg
|
|
30
|
+
class="h-6 w-6"
|
|
31
|
+
viewBox="0 0 24 24"
|
|
32
|
+
fill="currentColor"
|
|
33
|
+
aria-hidden="true"
|
|
34
|
+
>
|
|
35
|
+
<path d="M8 5v14l11-7z" />
|
|
36
|
+
</svg>
|
|
37
|
+
</span>
|
|
38
|
+
<div class="flex h-full grow flex-col gap-3">
|
|
39
|
+
<span class="flex items-center gap-2 font-semibold text-gray-900">
|
|
40
|
+
<Icon className="mdi--external-link" ariaLabel="" />
|
|
41
|
+
{t("ln.external-link")}
|
|
42
|
+
</span>
|
|
43
|
+
<div class="h-2 w-full rounded-full bg-gray-200">
|
|
44
|
+
<div class="h-full w-2 rounded-full bg-gray-400" />
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
</a>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
18
50
|
|
|
19
51
|
<style>
|
|
20
52
|
audio::-webkit-media-controls-enclosure {
|