@ampless/admin 0.2.0-alpha.7 → 1.0.0-alpha.26
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/README.ja.md +73 -0
- package/README.md +3 -0
- package/dist/api/index.d.ts +1 -1
- package/dist/chunk-2ITWLRYF.js +38 -0
- package/dist/chunk-2U3POKAZ.js +198 -0
- package/dist/{chunk-VXEVLHGL.js → chunk-6LQGVDCW.js} +2 -2
- package/dist/chunk-6NPYUTV6.js +250 -0
- package/dist/chunk-6SB7YICQ.js +48 -0
- package/dist/chunk-6W3JIOOR.js +37 -0
- package/dist/chunk-CTGFMK2J.js +335 -0
- package/dist/chunk-G4CF5ZWV.js +1319 -0
- package/dist/chunk-KQOE5CT6.js +21 -0
- package/dist/chunk-MWSCSCCU.js +67 -0
- package/dist/chunk-Q66BLMNJ.js +33 -0
- package/dist/chunk-TZ5F24BG.js +149 -0
- package/dist/chunk-VL6MMF2P.js +21 -0
- package/dist/chunk-VSS5FWSR.js +334 -0
- package/dist/{chunk-L5NHN3MY.js → chunk-WL4IBW2D.js} +145 -43
- package/dist/chunk-YFWHKIVH.js +1187 -0
- package/dist/components/admin-dashboard.d.ts +10 -0
- package/dist/components/admin-dashboard.js +9 -0
- package/dist/components/edit-post-view.d.ts +9 -0
- package/dist/components/edit-post-view.js +12 -0
- package/dist/components/index.d.ts +14 -42
- package/dist/components/index.js +22 -33
- package/dist/components/login-view.d.ts +5 -0
- package/dist/components/login-view.js +9 -0
- package/dist/components/mcp-tokens-view.d.ts +16 -0
- package/dist/components/mcp-tokens-view.js +9 -0
- package/dist/components/media-view.d.ts +5 -0
- package/dist/components/media-view.js +12 -0
- package/dist/components/new-post-view.d.ts +5 -0
- package/dist/components/new-post-view.js +12 -0
- package/dist/components/posts-list-view.d.ts +5 -0
- package/dist/components/posts-list-view.js +9 -0
- package/dist/components/users-list-view.d.ts +7 -0
- package/dist/components/users-list-view.js +9 -0
- package/dist/{i18n-Bc4SYgWx.d.ts → i18n-BhMBRfio.d.ts} +215 -1
- package/dist/index.d.ts +18 -18
- package/dist/index.js +17 -38
- package/dist/lib/theme-actions.d.ts +3 -3
- package/dist/lib/theme-actions.js +1 -1
- package/dist/metafile-esm.json +1 -1
- package/dist/pages/index.d.ts +35 -16
- package/dist/pages/index.js +90 -257
- package/package.json +26 -14
- package/dist/chunk-GXPSAOES.js +0 -2823
- package/dist/login-view-BKrSZLJu.d.ts +0 -24
package/README.ja.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
> English: [README.md](./README.md)
|
|
2
|
+
>
|
|
3
|
+
|
|
4
|
+
# @ampless/admin
|
|
5
|
+
|
|
6
|
+
[ampless](https://github.com/heavymoons/ampless) 向け管理 UI ライブラリ。投稿エディター(Tiptap + Markdown + HTML)、メディアマネージャー(S3 + 画像処理)、サイト / テーマ設定、ロケール対応 UI 文字列、そしてすべてを結びつける Next.js ページファクトリーを提供します。
|
|
7
|
+
|
|
8
|
+
> **プレリリース / アルファ版。** v1.0 まではマイナーバージョンでも破壊的変更が入る可能性があります。
|
|
9
|
+
|
|
10
|
+
## なぜライブラリなのか?
|
|
11
|
+
|
|
12
|
+
以前は管理 UI がテンプレートプロジェクト内に存在していました。バグ修正や機能追加のたびに、テンプレートと各サイト間でファイルをコピーし続ける必要がありました。`@ampless/admin` として切り出すことで、プロジェクトは `npm update @ampless/admin` を実行するだけで改善を取り込めます — 他の依存パッケージと同じアップグレードフローです。
|
|
13
|
+
|
|
14
|
+
## インストール
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npm install @ampless/admin@alpha @ampless/runtime@alpha ampless@alpha
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
ピア依存: `next`、`react`、`react-dom`、`aws-amplify`、`@aws-amplify/adapter-nextjs`。
|
|
21
|
+
|
|
22
|
+
## 接続方法
|
|
23
|
+
|
|
24
|
+
Next.js プロジェクトに `lib/admin.ts` を作成します:
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import outputs from '../amplify_outputs.json'
|
|
28
|
+
import cmsConfig from '../cms.config'
|
|
29
|
+
import { createAdmin } from '@ampless/admin'
|
|
30
|
+
import { ampless } from './ampless'
|
|
31
|
+
|
|
32
|
+
export const admin = createAdmin({ outputs, cmsConfig, ampless })
|
|
33
|
+
export const t = admin.t
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
各管理ルートを薄いシェルとして公開します:
|
|
37
|
+
|
|
38
|
+
```tsx
|
|
39
|
+
// app/(admin)/admin/posts/page.tsx
|
|
40
|
+
import { admin } from '@/lib/admin'
|
|
41
|
+
import { createPostsListPage } from '@ampless/admin/pages'
|
|
42
|
+
export default createPostsListPage(admin)
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
// app/api/media/[...path]/route.ts
|
|
47
|
+
import { admin } from '@/lib/admin'
|
|
48
|
+
import { createMediaProxyRoute } from '@ampless/admin/api'
|
|
49
|
+
export const { GET } = createMediaProxyRoute(admin)
|
|
50
|
+
export const runtime = 'nodejs'
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## サブパス
|
|
54
|
+
|
|
55
|
+
| サブパス | 内容 |
|
|
56
|
+
| ----------------------------- | --------------------------------------------------- |
|
|
57
|
+
| `@ampless/admin` | `createAdmin` ファクトリー + `Admin` インターフェース |
|
|
58
|
+
| `@ampless/admin/pages` | ページファクトリー — 管理ルートごとに 1 つ |
|
|
59
|
+
| `@ampless/admin/api` | API ルートファクトリー(`createMediaProxyRoute` など)|
|
|
60
|
+
| `@ampless/admin/components` | 高度な接続用フォーム / エディターコンポーネント |
|
|
61
|
+
|
|
62
|
+
## ロケール
|
|
63
|
+
|
|
64
|
+
`createAdmin({ locale: 'ja' })` で管理 UI 文字列を日本語に切り替えます。
|
|
65
|
+
オブジェクトリテラルを渡すと個別の文字列をオーバーライドできます:
|
|
66
|
+
|
|
67
|
+
```ts
|
|
68
|
+
createAdmin({
|
|
69
|
+
outputs,
|
|
70
|
+
cmsConfig,
|
|
71
|
+
locale: { sidebar: { brand: 'MySite Admin' } },
|
|
72
|
+
})
|
|
73
|
+
```
|
package/README.md
CHANGED
package/dist/api/index.d.ts
CHANGED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// src/lib/media.ts
|
|
2
|
+
var state = { outputs: null, cmsConfig: null };
|
|
3
|
+
function setAdminMediaContext(outputs, cmsConfig) {
|
|
4
|
+
state.outputs = outputs;
|
|
5
|
+
state.cmsConfig = cmsConfig;
|
|
6
|
+
}
|
|
7
|
+
function publicMediaUrl(input) {
|
|
8
|
+
if (/^https?:\/\//.test(input)) return input;
|
|
9
|
+
let path = input.replace(/^\/+/, "");
|
|
10
|
+
if (path.startsWith("public/")) path = path.slice("public/".length);
|
|
11
|
+
const { outputs, cmsConfig } = state;
|
|
12
|
+
const delivery = cmsConfig?.media?.delivery ?? "nextjs";
|
|
13
|
+
if (delivery !== "s3-direct") return `/api/media/${path}`;
|
|
14
|
+
const storage = outputs?.storage;
|
|
15
|
+
if (!storage) return `/api/media/${path}`;
|
|
16
|
+
return `https://${storage.bucket_name}.s3.${storage.aws_region}.amazonaws.com/public/${path}`;
|
|
17
|
+
}
|
|
18
|
+
function createMedia(outputs, cmsConfig) {
|
|
19
|
+
const storage = outputs.storage;
|
|
20
|
+
function s3DirectUrl(path) {
|
|
21
|
+
if (!storage) return `/api/media/${path}`;
|
|
22
|
+
return `https://${storage.bucket_name}.s3.${storage.aws_region}.amazonaws.com/public/${path}`;
|
|
23
|
+
}
|
|
24
|
+
function urlFor(input) {
|
|
25
|
+
if (/^https?:\/\//.test(input)) return input;
|
|
26
|
+
let path = input.replace(/^\/+/, "");
|
|
27
|
+
if (path.startsWith("public/")) path = path.slice("public/".length);
|
|
28
|
+
const delivery = cmsConfig.media?.delivery ?? "nextjs";
|
|
29
|
+
return delivery === "s3-direct" ? s3DirectUrl(path) : `/api/media/${path}`;
|
|
30
|
+
}
|
|
31
|
+
return { publicMediaUrl: urlFor };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export {
|
|
35
|
+
setAdminMediaContext,
|
|
36
|
+
publicMediaUrl,
|
|
37
|
+
createMedia
|
|
38
|
+
};
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import {
|
|
3
|
+
useT
|
|
4
|
+
} from "./chunk-Q66BLMNJ.js";
|
|
5
|
+
|
|
6
|
+
// src/components/login-view.tsx
|
|
7
|
+
import { useState } from "react";
|
|
8
|
+
import { useRouter } from "next/navigation";
|
|
9
|
+
import {
|
|
10
|
+
signIn,
|
|
11
|
+
signUp,
|
|
12
|
+
confirmSignUp,
|
|
13
|
+
resetPassword,
|
|
14
|
+
confirmResetPassword
|
|
15
|
+
} from "aws-amplify/auth";
|
|
16
|
+
import {
|
|
17
|
+
Button,
|
|
18
|
+
Input,
|
|
19
|
+
Label,
|
|
20
|
+
Card,
|
|
21
|
+
CardContent,
|
|
22
|
+
CardHeader,
|
|
23
|
+
CardTitle,
|
|
24
|
+
CardDescription
|
|
25
|
+
} from "@ampless/runtime/ui";
|
|
26
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
27
|
+
function LoginPage() {
|
|
28
|
+
const router = useRouter();
|
|
29
|
+
const t = useT();
|
|
30
|
+
const [mode, setMode] = useState("signIn");
|
|
31
|
+
const [email, setEmail] = useState("");
|
|
32
|
+
const [password, setPassword] = useState("");
|
|
33
|
+
const [code, setCode] = useState("");
|
|
34
|
+
const [error, setError] = useState(null);
|
|
35
|
+
const [info, setInfo] = useState(null);
|
|
36
|
+
const [loading, setLoading] = useState(false);
|
|
37
|
+
function go(next) {
|
|
38
|
+
setMode(next);
|
|
39
|
+
setError(null);
|
|
40
|
+
setInfo(null);
|
|
41
|
+
setCode("");
|
|
42
|
+
if (next === "signIn" || next === "signUp" || next === "forgot") setPassword("");
|
|
43
|
+
}
|
|
44
|
+
async function handleSubmit(e) {
|
|
45
|
+
e.preventDefault();
|
|
46
|
+
setError(null);
|
|
47
|
+
setInfo(null);
|
|
48
|
+
setLoading(true);
|
|
49
|
+
try {
|
|
50
|
+
if (mode === "signIn") {
|
|
51
|
+
const result = await signIn({ username: email, password });
|
|
52
|
+
if (result.isSignedIn) {
|
|
53
|
+
router.push("/admin");
|
|
54
|
+
router.refresh();
|
|
55
|
+
} else {
|
|
56
|
+
setError(t("auth.additionalStep", { step: result.nextStep.signInStep }));
|
|
57
|
+
}
|
|
58
|
+
} else if (mode === "signUp") {
|
|
59
|
+
await signUp({
|
|
60
|
+
username: email,
|
|
61
|
+
password,
|
|
62
|
+
options: { userAttributes: { email } }
|
|
63
|
+
});
|
|
64
|
+
go("confirm");
|
|
65
|
+
} else if (mode === "confirm") {
|
|
66
|
+
await confirmSignUp({ username: email, confirmationCode: code });
|
|
67
|
+
const result = await signIn({ username: email, password });
|
|
68
|
+
if (result.isSignedIn) {
|
|
69
|
+
router.push("/admin");
|
|
70
|
+
router.refresh();
|
|
71
|
+
}
|
|
72
|
+
} else if (mode === "forgot") {
|
|
73
|
+
await resetPassword({ username: email });
|
|
74
|
+
setMode("reset");
|
|
75
|
+
setInfo(t("auth.forgot.codeSent"));
|
|
76
|
+
} else if (mode === "reset") {
|
|
77
|
+
await confirmResetPassword({
|
|
78
|
+
username: email,
|
|
79
|
+
confirmationCode: code,
|
|
80
|
+
newPassword: password
|
|
81
|
+
});
|
|
82
|
+
const result = await signIn({ username: email, password });
|
|
83
|
+
if (result.isSignedIn) {
|
|
84
|
+
router.push("/admin");
|
|
85
|
+
router.refresh();
|
|
86
|
+
} else {
|
|
87
|
+
setMode("signIn");
|
|
88
|
+
setInfo(t("auth.reset.passwordUpdated"));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
} catch (err) {
|
|
92
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
93
|
+
} finally {
|
|
94
|
+
setLoading(false);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const showEmail = mode !== "confirm" && mode !== "reset";
|
|
98
|
+
const showPassword = mode === "signIn" || mode === "signUp" || mode === "reset";
|
|
99
|
+
const showCode = mode === "confirm" || mode === "reset";
|
|
100
|
+
return /* @__PURE__ */ jsx("main", { className: "flex min-h-screen items-center justify-center bg-muted/30 p-4", children: /* @__PURE__ */ jsxs(Card, { className: "w-full max-w-md", children: [
|
|
101
|
+
/* @__PURE__ */ jsxs(CardHeader, { children: [
|
|
102
|
+
/* @__PURE__ */ jsx(CardTitle, { children: t(`auth.${mode}.title`) }),
|
|
103
|
+
/* @__PURE__ */ jsx(CardDescription, { children: t(`auth.${mode}.description`) })
|
|
104
|
+
] }),
|
|
105
|
+
/* @__PURE__ */ jsx(CardContent, { children: /* @__PURE__ */ jsxs("form", { onSubmit: handleSubmit, className: "space-y-4", children: [
|
|
106
|
+
showEmail && /* @__PURE__ */ jsxs("div", { className: "space-y-2", children: [
|
|
107
|
+
/* @__PURE__ */ jsx(Label, { htmlFor: "email", children: t("auth.common.email") }),
|
|
108
|
+
/* @__PURE__ */ jsx(
|
|
109
|
+
Input,
|
|
110
|
+
{
|
|
111
|
+
id: "email",
|
|
112
|
+
type: "email",
|
|
113
|
+
required: true,
|
|
114
|
+
value: email,
|
|
115
|
+
onChange: (e) => setEmail(e.target.value),
|
|
116
|
+
autoComplete: "email"
|
|
117
|
+
}
|
|
118
|
+
)
|
|
119
|
+
] }),
|
|
120
|
+
showCode && /* @__PURE__ */ jsxs("div", { className: "space-y-2", children: [
|
|
121
|
+
/* @__PURE__ */ jsx(Label, { htmlFor: "code", children: t("auth.common.code") }),
|
|
122
|
+
/* @__PURE__ */ jsx(
|
|
123
|
+
Input,
|
|
124
|
+
{
|
|
125
|
+
id: "code",
|
|
126
|
+
required: true,
|
|
127
|
+
value: code,
|
|
128
|
+
onChange: (e) => setCode(e.target.value),
|
|
129
|
+
autoComplete: "one-time-code"
|
|
130
|
+
}
|
|
131
|
+
)
|
|
132
|
+
] }),
|
|
133
|
+
showPassword && /* @__PURE__ */ jsxs("div", { className: "space-y-2", children: [
|
|
134
|
+
/* @__PURE__ */ jsx(Label, { htmlFor: "password", children: mode === "reset" ? t("auth.common.newPassword") : t("auth.common.password") }),
|
|
135
|
+
/* @__PURE__ */ jsx(
|
|
136
|
+
Input,
|
|
137
|
+
{
|
|
138
|
+
id: "password",
|
|
139
|
+
type: "password",
|
|
140
|
+
required: true,
|
|
141
|
+
minLength: 8,
|
|
142
|
+
value: password,
|
|
143
|
+
onChange: (e) => setPassword(e.target.value),
|
|
144
|
+
autoComplete: mode === "signIn" ? "current-password" : "new-password"
|
|
145
|
+
}
|
|
146
|
+
),
|
|
147
|
+
(mode === "signUp" || mode === "reset") && /* @__PURE__ */ jsx("p", { className: "text-xs text-muted-foreground", children: t("auth.common.passwordHint") })
|
|
148
|
+
] }),
|
|
149
|
+
info && /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: info }),
|
|
150
|
+
error && /* @__PURE__ */ jsx("p", { className: "text-sm text-destructive", children: error }),
|
|
151
|
+
/* @__PURE__ */ jsx(Button, { type: "submit", className: "w-full", disabled: loading, children: loading ? t("auth.common.working") : t(`auth.${mode}.submit`) }),
|
|
152
|
+
/* @__PURE__ */ jsxs("div", { className: "space-y-1 text-center text-sm", children: [
|
|
153
|
+
mode === "signIn" && /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
154
|
+
/* @__PURE__ */ jsx("p", { children: /* @__PURE__ */ jsx(
|
|
155
|
+
"button",
|
|
156
|
+
{
|
|
157
|
+
type: "button",
|
|
158
|
+
className: "text-primary hover:underline",
|
|
159
|
+
onClick: () => go("forgot"),
|
|
160
|
+
children: t("auth.signIn.forgotPassword")
|
|
161
|
+
}
|
|
162
|
+
) }),
|
|
163
|
+
/* @__PURE__ */ jsx("p", { children: /* @__PURE__ */ jsx(
|
|
164
|
+
"button",
|
|
165
|
+
{
|
|
166
|
+
type: "button",
|
|
167
|
+
className: "text-primary hover:underline",
|
|
168
|
+
onClick: () => go("signUp"),
|
|
169
|
+
children: t("auth.signIn.createAccount")
|
|
170
|
+
}
|
|
171
|
+
) })
|
|
172
|
+
] }),
|
|
173
|
+
(mode === "signUp" || mode === "forgot" || mode === "reset") && /* @__PURE__ */ jsx("p", { children: /* @__PURE__ */ jsx(
|
|
174
|
+
"button",
|
|
175
|
+
{
|
|
176
|
+
type: "button",
|
|
177
|
+
className: "text-primary hover:underline",
|
|
178
|
+
onClick: () => go("signIn"),
|
|
179
|
+
children: t("auth.signUp.backToSignIn")
|
|
180
|
+
}
|
|
181
|
+
) }),
|
|
182
|
+
mode === "reset" && /* @__PURE__ */ jsx("p", { children: /* @__PURE__ */ jsx(
|
|
183
|
+
"button",
|
|
184
|
+
{
|
|
185
|
+
type: "button",
|
|
186
|
+
className: "text-primary hover:underline",
|
|
187
|
+
onClick: () => go("forgot"),
|
|
188
|
+
children: t("auth.reset.resendCode")
|
|
189
|
+
}
|
|
190
|
+
) })
|
|
191
|
+
] })
|
|
192
|
+
] }) })
|
|
193
|
+
] }) });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export {
|
|
197
|
+
LoginPage
|
|
198
|
+
};
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
'use server';
|
|
2
2
|
// src/lib/theme-actions.ts
|
|
3
3
|
import { updateTag } from "next/cache";
|
|
4
|
-
async function invalidateSiteSettingsCache(
|
|
5
|
-
updateTag(
|
|
4
|
+
async function invalidateSiteSettingsCache() {
|
|
5
|
+
updateTag("site-settings");
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
export {
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import {
|
|
3
|
+
ImageUploadDialog,
|
|
4
|
+
getMediaProcessingDefaults
|
|
5
|
+
} from "./chunk-CTGFMK2J.js";
|
|
6
|
+
import {
|
|
7
|
+
publicMediaUrl
|
|
8
|
+
} from "./chunk-2ITWLRYF.js";
|
|
9
|
+
import {
|
|
10
|
+
useT
|
|
11
|
+
} from "./chunk-Q66BLMNJ.js";
|
|
12
|
+
|
|
13
|
+
// src/components/media-uploader.tsx
|
|
14
|
+
import { useState, useEffect, useCallback, useRef } from "react";
|
|
15
|
+
import { uploadData, list, remove, isCancelError } from "aws-amplify/storage";
|
|
16
|
+
import { processImage } from "ampless/media";
|
|
17
|
+
import { Button, Input } from "@ampless/runtime/ui";
|
|
18
|
+
import { Trash2, Copy, Check, FileText, Code2 } from "lucide-react";
|
|
19
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
20
|
+
var IMAGE_EXT_RE = /\.(jpe?g|png|gif|webp|avif|svg|bmp|tiff?)$/i;
|
|
21
|
+
var STYLESHEET_EXT_RE = /\.css$/i;
|
|
22
|
+
var SCRIPT_EXT_RE = /\.m?js$/i;
|
|
23
|
+
function getExtension(path) {
|
|
24
|
+
const dot = path.lastIndexOf(".");
|
|
25
|
+
return dot >= 0 ? path.slice(dot + 1).toUpperCase() : "FILE";
|
|
26
|
+
}
|
|
27
|
+
function snippetFor(url, path) {
|
|
28
|
+
if (IMAGE_EXT_RE.test(path)) {
|
|
29
|
+
return `<img src="${url}" alt="" />`;
|
|
30
|
+
}
|
|
31
|
+
if (STYLESHEET_EXT_RE.test(path)) {
|
|
32
|
+
return `<link rel="stylesheet" href="${url}" />`;
|
|
33
|
+
}
|
|
34
|
+
if (SCRIPT_EXT_RE.test(path)) {
|
|
35
|
+
return `<script src="${url}"></script>`;
|
|
36
|
+
}
|
|
37
|
+
return url;
|
|
38
|
+
}
|
|
39
|
+
function sanitizeName(name) {
|
|
40
|
+
return name.replace(/[ -]/g, "").replace(/[\\/:*?"<>|]/g, "_").replace(/\s+/g, "_").replace(/^\.+/, "_").slice(0, 200) || "upload";
|
|
41
|
+
}
|
|
42
|
+
function MediaUploader() {
|
|
43
|
+
const t = useT();
|
|
44
|
+
const [items, setItems] = useState([]);
|
|
45
|
+
const [queue, setQueue] = useState([]);
|
|
46
|
+
const [uploading, setUploading] = useState(false);
|
|
47
|
+
const [error, setError] = useState(null);
|
|
48
|
+
const [copiedPath, setCopiedPath] = useState(null);
|
|
49
|
+
const uploadTaskRef = useRef(null);
|
|
50
|
+
const cancelTokenRef = useRef({ cancelled: false });
|
|
51
|
+
const refresh = useCallback(async () => {
|
|
52
|
+
try {
|
|
53
|
+
const result = await list({ path: "public/media/" });
|
|
54
|
+
setItems(
|
|
55
|
+
result.items.map((item) => ({
|
|
56
|
+
path: item.path,
|
|
57
|
+
url: publicMediaUrl(item.path)
|
|
58
|
+
}))
|
|
59
|
+
);
|
|
60
|
+
} catch (err) {
|
|
61
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
62
|
+
}
|
|
63
|
+
}, []);
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
refresh();
|
|
66
|
+
}, [refresh]);
|
|
67
|
+
function handleFiles(e) {
|
|
68
|
+
const files = Array.from(e.target.files ?? []);
|
|
69
|
+
e.target.value = "";
|
|
70
|
+
if (files.length === 0) return;
|
|
71
|
+
setError(null);
|
|
72
|
+
setQueue((prev) => [...prev, ...files]);
|
|
73
|
+
}
|
|
74
|
+
async function handleDialogConfirm(file, options) {
|
|
75
|
+
const token = { cancelled: false };
|
|
76
|
+
cancelTokenRef.current = token;
|
|
77
|
+
setUploading(true);
|
|
78
|
+
setError(null);
|
|
79
|
+
let advance = true;
|
|
80
|
+
try {
|
|
81
|
+
const processed = await processImage(file, options);
|
|
82
|
+
if (token.cancelled) {
|
|
83
|
+
advance = false;
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const safeName = sanitizeName(processed.suggestedName);
|
|
87
|
+
const now = /* @__PURE__ */ new Date();
|
|
88
|
+
const yyyy = now.getFullYear();
|
|
89
|
+
const mm = String(now.getMonth() + 1).padStart(2, "0");
|
|
90
|
+
const path = `public/media/${yyyy}/${mm}/${Date.now()}-${safeName}`;
|
|
91
|
+
const task = uploadData({
|
|
92
|
+
path,
|
|
93
|
+
data: processed.blob,
|
|
94
|
+
options: { contentType: processed.mime }
|
|
95
|
+
});
|
|
96
|
+
uploadTaskRef.current = task;
|
|
97
|
+
await task.result;
|
|
98
|
+
await refresh();
|
|
99
|
+
} catch (err) {
|
|
100
|
+
if (isCancelError(err) || token.cancelled) {
|
|
101
|
+
advance = false;
|
|
102
|
+
} else {
|
|
103
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
104
|
+
}
|
|
105
|
+
} finally {
|
|
106
|
+
uploadTaskRef.current = null;
|
|
107
|
+
setUploading(false);
|
|
108
|
+
if (advance) {
|
|
109
|
+
setQueue((prev) => prev.slice(1));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function handleDialogSkip() {
|
|
114
|
+
if (uploading) return;
|
|
115
|
+
setQueue((prev) => prev.slice(1));
|
|
116
|
+
}
|
|
117
|
+
function handleDialogCancel() {
|
|
118
|
+
cancelTokenRef.current.cancelled = true;
|
|
119
|
+
uploadTaskRef.current?.cancel();
|
|
120
|
+
setQueue([]);
|
|
121
|
+
}
|
|
122
|
+
async function handleDelete(path) {
|
|
123
|
+
if (!confirm(t("media.deleteConfirm"))) return;
|
|
124
|
+
try {
|
|
125
|
+
await remove({ path });
|
|
126
|
+
await refresh();
|
|
127
|
+
} catch (err) {
|
|
128
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
async function handleCopy(item, mode) {
|
|
132
|
+
const text = mode === "url" ? item.url : snippetFor(item.url, item.path);
|
|
133
|
+
await navigator.clipboard.writeText(text);
|
|
134
|
+
const key = `${item.path}:${mode}`;
|
|
135
|
+
setCopiedPath(key);
|
|
136
|
+
setTimeout(() => setCopiedPath((p) => p === key ? null : p), 1500);
|
|
137
|
+
}
|
|
138
|
+
const currentFile = queue[0] ?? null;
|
|
139
|
+
return /* @__PURE__ */ jsxs("div", { className: "space-y-6", children: [
|
|
140
|
+
/* @__PURE__ */ jsxs("div", { className: "rounded-md border p-4", children: [
|
|
141
|
+
/* @__PURE__ */ jsx(
|
|
142
|
+
Input,
|
|
143
|
+
{
|
|
144
|
+
type: "file",
|
|
145
|
+
multiple: true,
|
|
146
|
+
onChange: handleFiles,
|
|
147
|
+
disabled: uploading
|
|
148
|
+
}
|
|
149
|
+
),
|
|
150
|
+
uploading && /* @__PURE__ */ jsx("p", { className: "mt-2 text-sm text-muted-foreground", children: t("media.uploading") }),
|
|
151
|
+
!uploading && queue.length > 0 && /* @__PURE__ */ jsx("p", { className: "mt-2 text-sm text-muted-foreground", children: t("media.queued", { count: queue.length }) })
|
|
152
|
+
] }),
|
|
153
|
+
error && /* @__PURE__ */ jsx("p", { className: "text-sm text-destructive", children: error }),
|
|
154
|
+
/* @__PURE__ */ jsx(
|
|
155
|
+
ImageUploadDialog,
|
|
156
|
+
{
|
|
157
|
+
file: currentFile,
|
|
158
|
+
remaining: queue.length,
|
|
159
|
+
busy: uploading,
|
|
160
|
+
defaults: getMediaProcessingDefaults(),
|
|
161
|
+
onConfirm: handleDialogConfirm,
|
|
162
|
+
onSkip: handleDialogSkip,
|
|
163
|
+
onCancel: handleDialogCancel
|
|
164
|
+
}
|
|
165
|
+
),
|
|
166
|
+
/* @__PURE__ */ jsx("div", { className: "grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4", children: items.map((item) => {
|
|
167
|
+
const isImage = IMAGE_EXT_RE.test(item.path);
|
|
168
|
+
const isStylesheet = STYLESHEET_EXT_RE.test(item.path);
|
|
169
|
+
const isScript = SCRIPT_EXT_RE.test(item.path);
|
|
170
|
+
const filename = item.path.split("/").pop() ?? "";
|
|
171
|
+
const ext = getExtension(item.path);
|
|
172
|
+
const tagSnippet = snippetFor(item.url, item.path);
|
|
173
|
+
const tagDiffersFromUrl = tagSnippet !== item.url;
|
|
174
|
+
const urlCopied = copiedPath === `${item.path}:url`;
|
|
175
|
+
const tagCopied = copiedPath === `${item.path}:tag`;
|
|
176
|
+
return /* @__PURE__ */ jsxs(
|
|
177
|
+
"div",
|
|
178
|
+
{
|
|
179
|
+
className: "group relative overflow-hidden rounded-md border bg-[var(--card)]",
|
|
180
|
+
children: [
|
|
181
|
+
isImage ? (
|
|
182
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
183
|
+
/* @__PURE__ */ jsx(
|
|
184
|
+
"img",
|
|
185
|
+
{
|
|
186
|
+
src: item.url,
|
|
187
|
+
alt: item.path,
|
|
188
|
+
className: "aspect-square w-full object-cover"
|
|
189
|
+
}
|
|
190
|
+
)
|
|
191
|
+
) : /* @__PURE__ */ jsxs("div", { className: "flex aspect-square w-full flex-col items-center justify-center gap-2 bg-muted text-muted-foreground", children: [
|
|
192
|
+
isStylesheet || isScript ? /* @__PURE__ */ jsx(Code2, { className: "h-8 w-8" }) : /* @__PURE__ */ jsx(FileText, { className: "h-8 w-8" }),
|
|
193
|
+
/* @__PURE__ */ jsxs("span", { className: "font-mono text-xs font-semibold", children: [
|
|
194
|
+
".",
|
|
195
|
+
ext.toLowerCase()
|
|
196
|
+
] })
|
|
197
|
+
] }),
|
|
198
|
+
/* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between border-t px-2 py-1 text-xs", children: [
|
|
199
|
+
/* @__PURE__ */ jsx("span", { className: "truncate", title: filename, children: filename }),
|
|
200
|
+
/* @__PURE__ */ jsxs("div", { className: "flex shrink-0 items-center gap-0.5", children: [
|
|
201
|
+
/* @__PURE__ */ jsx(
|
|
202
|
+
Button,
|
|
203
|
+
{
|
|
204
|
+
type: "button",
|
|
205
|
+
variant: "ghost",
|
|
206
|
+
size: "icon",
|
|
207
|
+
className: "h-6 w-6",
|
|
208
|
+
onClick: () => handleCopy(item, "url"),
|
|
209
|
+
title: t("media.copyUrl"),
|
|
210
|
+
children: urlCopied ? /* @__PURE__ */ jsx(Check, { className: "h-3 w-3" }) : /* @__PURE__ */ jsx(Copy, { className: "h-3 w-3" })
|
|
211
|
+
}
|
|
212
|
+
),
|
|
213
|
+
tagDiffersFromUrl && /* @__PURE__ */ jsx(
|
|
214
|
+
Button,
|
|
215
|
+
{
|
|
216
|
+
type: "button",
|
|
217
|
+
variant: "ghost",
|
|
218
|
+
size: "icon",
|
|
219
|
+
className: "h-6 w-6",
|
|
220
|
+
onClick: () => handleCopy(item, "tag"),
|
|
221
|
+
title: t("media.copyTag"),
|
|
222
|
+
children: tagCopied ? /* @__PURE__ */ jsx(Check, { className: "h-3 w-3" }) : /* @__PURE__ */ jsx(Code2, { className: "h-3 w-3" })
|
|
223
|
+
}
|
|
224
|
+
),
|
|
225
|
+
/* @__PURE__ */ jsx(
|
|
226
|
+
Button,
|
|
227
|
+
{
|
|
228
|
+
type: "button",
|
|
229
|
+
variant: "ghost",
|
|
230
|
+
size: "icon",
|
|
231
|
+
className: "h-6 w-6",
|
|
232
|
+
onClick: () => handleDelete(item.path),
|
|
233
|
+
title: t("media.delete"),
|
|
234
|
+
children: /* @__PURE__ */ jsx(Trash2, { className: "h-3 w-3" })
|
|
235
|
+
}
|
|
236
|
+
)
|
|
237
|
+
] })
|
|
238
|
+
] })
|
|
239
|
+
]
|
|
240
|
+
},
|
|
241
|
+
item.path
|
|
242
|
+
);
|
|
243
|
+
}) }),
|
|
244
|
+
items.length === 0 && /* @__PURE__ */ jsx("p", { className: "text-center text-sm text-muted-foreground", children: t("media.empty") })
|
|
245
|
+
] });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export {
|
|
249
|
+
MediaUploader
|
|
250
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import {
|
|
3
|
+
useT
|
|
4
|
+
} from "./chunk-Q66BLMNJ.js";
|
|
5
|
+
|
|
6
|
+
// src/components/admin-dashboard.tsx
|
|
7
|
+
import { useEffect, useState } from "react";
|
|
8
|
+
import Link from "next/link";
|
|
9
|
+
import { listPosts } from "ampless";
|
|
10
|
+
import { Button, Card, CardContent, CardHeader, CardTitle } from "@ampless/runtime/ui";
|
|
11
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
12
|
+
function AdminDashboard() {
|
|
13
|
+
const t = useT();
|
|
14
|
+
const [posts, setPosts] = useState([]);
|
|
15
|
+
const [loading, setLoading] = useState(true);
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
listPosts({ status: "all" }).then(setPosts).finally(() => setLoading(false));
|
|
18
|
+
}, []);
|
|
19
|
+
const published = posts.filter((p) => p.status === "published").length;
|
|
20
|
+
const drafts = posts.filter((p) => p.status === "draft").length;
|
|
21
|
+
return /* @__PURE__ */ jsxs("div", { className: "mx-auto max-w-7xl p-4 md:p-8", children: [
|
|
22
|
+
/* @__PURE__ */ jsxs("div", { className: "mb-6 flex flex-wrap items-center justify-between gap-3 md:mb-8", children: [
|
|
23
|
+
/* @__PURE__ */ jsx("h1", { className: "text-2xl font-bold md:text-3xl", children: t("dashboard.title") }),
|
|
24
|
+
/* @__PURE__ */ jsx(Button, { asChild: true, children: /* @__PURE__ */ jsx(Link, { href: "/admin/posts/new", children: t("dashboard.newPost") }) })
|
|
25
|
+
] }),
|
|
26
|
+
/* @__PURE__ */ jsxs("div", { className: "grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3", children: [
|
|
27
|
+
/* @__PURE__ */ jsxs(Card, { children: [
|
|
28
|
+
/* @__PURE__ */ jsx(CardHeader, { children: /* @__PURE__ */ jsx(CardTitle, { children: t("dashboard.totalPosts") }) }),
|
|
29
|
+
/* @__PURE__ */ jsxs(CardContent, { children: [
|
|
30
|
+
/* @__PURE__ */ jsx("p", { className: "text-3xl font-bold", children: loading ? "\u2014" : posts.length }),
|
|
31
|
+
/* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground", children: t("dashboard.totalLabel") })
|
|
32
|
+
] })
|
|
33
|
+
] }),
|
|
34
|
+
/* @__PURE__ */ jsxs(Card, { children: [
|
|
35
|
+
/* @__PURE__ */ jsx(CardHeader, { children: /* @__PURE__ */ jsx(CardTitle, { children: t("dashboard.published") }) }),
|
|
36
|
+
/* @__PURE__ */ jsx(CardContent, { children: /* @__PURE__ */ jsx("p", { className: "text-3xl font-bold", children: loading ? "\u2014" : published }) })
|
|
37
|
+
] }),
|
|
38
|
+
/* @__PURE__ */ jsxs(Card, { children: [
|
|
39
|
+
/* @__PURE__ */ jsx(CardHeader, { children: /* @__PURE__ */ jsx(CardTitle, { children: t("dashboard.drafts") }) }),
|
|
40
|
+
/* @__PURE__ */ jsx(CardContent, { children: /* @__PURE__ */ jsx("p", { className: "text-3xl font-bold", children: loading ? "\u2014" : drafts }) })
|
|
41
|
+
] })
|
|
42
|
+
] })
|
|
43
|
+
] });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export {
|
|
47
|
+
AdminDashboard
|
|
48
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import {
|
|
3
|
+
PostForm
|
|
4
|
+
} from "./chunk-G4CF5ZWV.js";
|
|
5
|
+
import {
|
|
6
|
+
useT
|
|
7
|
+
} from "./chunk-Q66BLMNJ.js";
|
|
8
|
+
|
|
9
|
+
// src/components/edit-post-view.tsx
|
|
10
|
+
import { useEffect, useState, use } from "react";
|
|
11
|
+
import { notFound } from "next/navigation";
|
|
12
|
+
import { getPostById } from "ampless";
|
|
13
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
14
|
+
function EditPostPage({ params }) {
|
|
15
|
+
const t = useT();
|
|
16
|
+
const { postId } = use(params);
|
|
17
|
+
const [post, setPost] = useState(null);
|
|
18
|
+
const [loading, setLoading] = useState(true);
|
|
19
|
+
const [missing, setMissing] = useState(false);
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
getPostById(postId).then((p) => {
|
|
22
|
+
if (!p) setMissing(true);
|
|
23
|
+
else setPost(p);
|
|
24
|
+
}).finally(() => setLoading(false));
|
|
25
|
+
}, [postId]);
|
|
26
|
+
if (loading)
|
|
27
|
+
return /* @__PURE__ */ jsx("div", { className: "mx-auto max-w-7xl p-4 md:p-8", children: t("common.loading") });
|
|
28
|
+
if (missing) notFound();
|
|
29
|
+
return /* @__PURE__ */ jsxs("div", { className: "mx-auto max-w-7xl p-4 md:p-8", children: [
|
|
30
|
+
/* @__PURE__ */ jsx("h1", { className: "mb-6 text-2xl font-bold md:mb-8 md:text-3xl", children: t("posts.form.editTitle") }),
|
|
31
|
+
post && /* @__PURE__ */ jsx(PostForm, { post })
|
|
32
|
+
] });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export {
|
|
36
|
+
EditPostPage
|
|
37
|
+
};
|