Add Gitea Actions deploy workflow and server configuration
Some checks failed
Deploy to Server / deploy (push) Failing after 1m49s
Some checks failed
Deploy to Server / deploy (push) Failing after 1m49s
- Add basePath /admin to Next.js config for path-based routing - Add .gitea/workflows/deploy.yml for CI/CD via Gitea Actions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
29
.gitea/workflows/deploy.yml
Normal file
29
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
name: Deploy to Server
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Build API image
|
||||||
|
run: |
|
||||||
|
docker build -t rustycms-api:latest .
|
||||||
|
|
||||||
|
- name: Build Admin UI image
|
||||||
|
run: |
|
||||||
|
docker build \
|
||||||
|
--build-arg NEXT_PUBLIC_RUSTYCMS_API_URL=https://cms.pm86.de \
|
||||||
|
-t rustycms-admin:latest \
|
||||||
|
./admin-ui
|
||||||
|
|
||||||
|
- name: Deploy
|
||||||
|
run: |
|
||||||
|
docker compose -f /opt/rustycms/docker-compose.yml up -d
|
||||||
|
docker image prune -f
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
"slugHint": "Kleinbuchstaben (a-z), Ziffern (0-9), Bindestriche. Leerzeichen werden zu Bindestrichen.",
|
"slugHint": "Kleinbuchstaben (a-z), Ziffern (0-9), Bindestriche. Leerzeichen werden zu Bindestrichen.",
|
||||||
"savedSuccessfully": "Erfolgreich gespeichert.",
|
"savedSuccessfully": "Erfolgreich gespeichert.",
|
||||||
"errorSaving": "Fehler beim Speichern",
|
"errorSaving": "Fehler beim Speichern",
|
||||||
|
"validationErrors": "Bitte korrigieren Sie die Fehler im Formular (z. B. Pflichtfelder).",
|
||||||
"saving": "Speichern…",
|
"saving": "Speichern…",
|
||||||
"save": "Speichern",
|
"save": "Speichern",
|
||||||
"backToList": "Zurück zur Liste",
|
"backToList": "Zurück zur Liste",
|
||||||
@@ -175,7 +176,11 @@
|
|||||||
"filterByTag": "Tag:",
|
"filterByTag": "Tag:",
|
||||||
"tagAll": "Alle",
|
"tagAll": "Alle",
|
||||||
"noResults": "Kein Inhaltstyp entspricht Suche oder Filter.",
|
"noResults": "Kein Inhaltstyp entspricht Suche oder Filter.",
|
||||||
"noCollections": "Keine Sammlungen geladen. Pr\u00fcfe ob die RustyCMS-API unter {url} erreichbar ist."
|
"noCollections": "Keine Sammlungen geladen. Pr\u00fcfe ob die RustyCMS-API unter {url} erreichbar ist.",
|
||||||
|
"recentSectionTitle": "Letzte 3 bearbeitete Beitr\u00e4ge",
|
||||||
|
"recentSectionLink": "Alle Beitr\u00e4ge",
|
||||||
|
"recentSectionEmpty": "Noch keine Beitr\u00e4ge.",
|
||||||
|
"loading": "Laden…"
|
||||||
},
|
},
|
||||||
"TypesPage": {
|
"TypesPage": {
|
||||||
"title": "Typen",
|
"title": "Typen",
|
||||||
@@ -355,6 +360,8 @@
|
|||||||
"newEntry": "Neuer Eintrag",
|
"newEntry": "Neuer Eintrag",
|
||||||
"colActions": "Aktionen",
|
"colActions": "Aktionen",
|
||||||
"colStatus": "Status",
|
"colStatus": "Status",
|
||||||
|
"colCreated": "Erstellt",
|
||||||
|
"colLastEdited": "Zuletzt geändert",
|
||||||
"published": "Veröffentlicht",
|
"published": "Veröffentlicht",
|
||||||
"noEntries": "Keine Einträge.",
|
"noEntries": "Keine Einträge.",
|
||||||
"noEntriesCreate": "Noch keine Einträge. Erstellen Sie den ersten.",
|
"noEntriesCreate": "Noch keine Einträge. Erstellen Sie den ersten.",
|
||||||
@@ -382,6 +389,8 @@
|
|||||||
},
|
},
|
||||||
"ContentEditPage": {
|
"ContentEditPage": {
|
||||||
"title": "Eintrag bearbeiten",
|
"title": "Eintrag bearbeiten",
|
||||||
|
"created": "Erstellt",
|
||||||
|
"lastEdited": "Zuletzt geändert",
|
||||||
"apiLink": "API-Link (Daten-Vorschau):",
|
"apiLink": "API-Link (Daten-Vorschau):",
|
||||||
"referrersSection": "Referenziert von",
|
"referrersSection": "Referenziert von",
|
||||||
"noReferrers": "Kein anderer Eintrag verweist auf diesen.",
|
"noReferrers": "Kein anderer Eintrag verweist auf diesen.",
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
"slugHint": "Lowercase letters (a-z), digits (0-9), hyphens. Spaces become hyphens.",
|
"slugHint": "Lowercase letters (a-z), digits (0-9), hyphens. Spaces become hyphens.",
|
||||||
"savedSuccessfully": "Saved successfully.",
|
"savedSuccessfully": "Saved successfully.",
|
||||||
"errorSaving": "Error saving",
|
"errorSaving": "Error saving",
|
||||||
|
"validationErrors": "Please fix the errors in the form (e.g. required fields).",
|
||||||
"saving": "Saving…",
|
"saving": "Saving…",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"backToList": "Back to list",
|
"backToList": "Back to list",
|
||||||
@@ -175,7 +176,11 @@
|
|||||||
"filterByTag": "Tag:",
|
"filterByTag": "Tag:",
|
||||||
"tagAll": "All",
|
"tagAll": "All",
|
||||||
"noResults": "No content types match your search or filter.",
|
"noResults": "No content types match your search or filter.",
|
||||||
"noCollections": "No collections loaded. Check that the RustyCMS API is running at {url}."
|
"noCollections": "No collections loaded. Check that the RustyCMS API is running at {url}.",
|
||||||
|
"recentSectionTitle": "Last 3 edited posts",
|
||||||
|
"recentSectionLink": "All posts",
|
||||||
|
"recentSectionEmpty": "No posts yet.",
|
||||||
|
"loading": "Loading…"
|
||||||
},
|
},
|
||||||
"TypesPage": {
|
"TypesPage": {
|
||||||
"title": "Types",
|
"title": "Types",
|
||||||
@@ -355,6 +360,8 @@
|
|||||||
"newEntry": "New entry",
|
"newEntry": "New entry",
|
||||||
"colActions": "Actions",
|
"colActions": "Actions",
|
||||||
"colStatus": "Status",
|
"colStatus": "Status",
|
||||||
|
"colCreated": "Created",
|
||||||
|
"colLastEdited": "Last edited",
|
||||||
"published": "Published",
|
"published": "Published",
|
||||||
"noEntries": "No entries.",
|
"noEntries": "No entries.",
|
||||||
"noEntriesCreate": "No entries yet. Create the first one.",
|
"noEntriesCreate": "No entries yet. Create the first one.",
|
||||||
@@ -382,6 +389,8 @@
|
|||||||
},
|
},
|
||||||
"ContentEditPage": {
|
"ContentEditPage": {
|
||||||
"title": "Edit entry",
|
"title": "Edit entry",
|
||||||
|
"created": "Created",
|
||||||
|
"lastEdited": "Last edited",
|
||||||
"apiLink": "API link (data preview):",
|
"apiLink": "API link (data preview):",
|
||||||
"referrersSection": "Referenced by",
|
"referrersSection": "Referenced by",
|
||||||
"noReferrers": "No other entries reference this one.",
|
"noReferrers": "No other entries reference this one.",
|
||||||
|
|||||||
@@ -4,4 +4,5 @@ const withNextIntl = createNextIntlPlugin('./i18n/request.ts');
|
|||||||
|
|
||||||
export default withNextIntl({
|
export default withNextIntl({
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
|
basePath: '/admin',
|
||||||
});
|
});
|
||||||
|
|||||||
138
admin-ui/package-lock.json
generated
138
admin-ui/package-lock.json
generated
@@ -24,6 +24,7 @@
|
|||||||
"react-hook-form": "^7.71.1",
|
"react-hook-form": "^7.71.1",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-syntax-highlighter": "^16.1.1",
|
"react-syntax-highlighter": "^16.1.1",
|
||||||
|
"rehype-raw": "^7.0.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"tailwindcss-animate": "^1.0.7"
|
||||||
@@ -6316,6 +6317,18 @@
|
|||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/entities": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/es-abstract": {
|
"node_modules/es-abstract": {
|
||||||
"version": "1.24.1",
|
"version": "1.24.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz",
|
||||||
@@ -7415,6 +7428,26 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hast-util-from-parse5": {
|
||||||
|
"version": "8.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz",
|
||||||
|
"integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/hast": "^3.0.0",
|
||||||
|
"@types/unist": "^3.0.0",
|
||||||
|
"devlop": "^1.0.0",
|
||||||
|
"hastscript": "^9.0.0",
|
||||||
|
"property-information": "^7.0.0",
|
||||||
|
"vfile": "^6.0.0",
|
||||||
|
"vfile-location": "^5.0.0",
|
||||||
|
"web-namespaces": "^2.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/hast-util-parse-selector": {
|
"node_modules/hast-util-parse-selector": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz",
|
||||||
@@ -7428,6 +7461,31 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hast-util-raw": {
|
||||||
|
"version": "9.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz",
|
||||||
|
"integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/hast": "^3.0.0",
|
||||||
|
"@types/unist": "^3.0.0",
|
||||||
|
"@ungap/structured-clone": "^1.0.0",
|
||||||
|
"hast-util-from-parse5": "^8.0.0",
|
||||||
|
"hast-util-to-parse5": "^8.0.0",
|
||||||
|
"html-void-elements": "^3.0.0",
|
||||||
|
"mdast-util-to-hast": "^13.0.0",
|
||||||
|
"parse5": "^7.0.0",
|
||||||
|
"unist-util-position": "^5.0.0",
|
||||||
|
"unist-util-visit": "^5.0.0",
|
||||||
|
"vfile": "^6.0.0",
|
||||||
|
"web-namespaces": "^2.0.0",
|
||||||
|
"zwitch": "^2.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/hast-util-to-jsx-runtime": {
|
"node_modules/hast-util-to-jsx-runtime": {
|
||||||
"version": "2.3.6",
|
"version": "2.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
|
||||||
@@ -7455,6 +7513,25 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hast-util-to-parse5": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/hast": "^3.0.0",
|
||||||
|
"comma-separated-tokens": "^2.0.0",
|
||||||
|
"devlop": "^1.0.0",
|
||||||
|
"property-information": "^7.0.0",
|
||||||
|
"space-separated-tokens": "^2.0.0",
|
||||||
|
"web-namespaces": "^2.0.0",
|
||||||
|
"zwitch": "^2.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/hast-util-whitespace": {
|
"node_modules/hast-util-whitespace": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
|
||||||
@@ -7527,6 +7604,16 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/html-void-elements": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/icu-minify": {
|
"node_modules/icu-minify": {
|
||||||
"version": "4.8.3",
|
"version": "4.8.3",
|
||||||
"resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/icu-minify/-/icu-minify-4.8.3.tgz",
|
||||||
@@ -9718,6 +9805,18 @@
|
|||||||
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
|
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/parse5": {
|
||||||
|
"version": "7.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
|
||||||
|
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"entities": "^6.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-exists": {
|
"node_modules/path-exists": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
@@ -10201,6 +10300,21 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/rehype-raw": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/hast": "^3.0.0",
|
||||||
|
"hast-util-raw": "^9.0.0",
|
||||||
|
"vfile": "^6.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/remark-parse": {
|
"node_modules/remark-parse": {
|
||||||
"version": "11.0.0",
|
"version": "11.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
|
||||||
@@ -11424,6 +11538,20 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vfile-location": {
|
||||||
|
"version": "5.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz",
|
||||||
|
"integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/unist": "^3.0.0",
|
||||||
|
"vfile": "^6.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/unified"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vfile-message": {
|
"node_modules/vfile-message": {
|
||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz",
|
||||||
@@ -11438,6 +11566,16 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/web-namespaces": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"react-hook-form": "^7.71.1",
|
"react-hook-form": "^7.71.1",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-syntax-highlighter": "^16.1.1",
|
"react-syntax-highlighter": "^16.1.1",
|
||||||
|
"rehype-raw": "^7.0.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss-animate": "^1.0.7"
|
"tailwindcss-animate": "^1.0.7"
|
||||||
|
|||||||
@@ -204,9 +204,25 @@ export default function ContentEditPage() {
|
|||||||
)}
|
)}
|
||||||
</CollapsibleSection>
|
</CollapsibleSection>
|
||||||
|
|
||||||
<h1 className="mb-6 text-2xl font-semibold text-gray-900">
|
<h1 className="mb-2 text-2xl font-semibold text-gray-900">
|
||||||
{t("title")} — {slug}
|
{t("title")} — {slug}
|
||||||
</h1>
|
</h1>
|
||||||
|
{entry && (entry._created != null || entry._updated != null) && (
|
||||||
|
<p className="mb-6 text-sm text-gray-500">
|
||||||
|
{entry._created != null && (
|
||||||
|
<span>
|
||||||
|
{t("created")}: {new Date(String(entry._created)).toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{entry._created != null && entry._updated != null && " · "}
|
||||||
|
{entry._updated != null && (
|
||||||
|
<span>
|
||||||
|
{t("lastEdited")}: {new Date(String(entry._updated)).toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{entry && !entry._created && !entry._updated && <div className="mb-6" />}
|
||||||
|
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="max-w-2xl space-y-8">
|
<div className="max-w-2xl space-y-8">
|
||||||
|
|||||||
@@ -104,6 +104,20 @@ export default function ContentListPage() {
|
|||||||
};
|
};
|
||||||
const isSortSlug = sort === "_slug" || !sort;
|
const isSortSlug = sort === "_slug" || !sort;
|
||||||
const nextSlugOrder = isSortSlug && order === "asc" ? "desc" : "asc";
|
const nextSlugOrder = isSortSlug && order === "asc" ? "desc" : "asc";
|
||||||
|
const isSortCreated = sort === "_created";
|
||||||
|
const nextCreatedOrder = isSortCreated && order === "desc" ? "asc" : "desc";
|
||||||
|
const isSortUpdated = sort === "_updated";
|
||||||
|
const nextUpdatedOrder = isSortUpdated && order === "desc" ? "asc" : "desc";
|
||||||
|
|
||||||
|
function formatListDate(iso: string | undefined): string {
|
||||||
|
if (!iso) return "—";
|
||||||
|
try {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" });
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const tBread = useTranslations("Breadcrumbs");
|
const tBread = useTranslations("Breadcrumbs");
|
||||||
|
|
||||||
@@ -245,7 +259,7 @@ export default function ContentListPage() {
|
|||||||
<TableHead className="w-64 max-w-[16rem]">
|
<TableHead className="w-64 max-w-[16rem]">
|
||||||
<Link
|
<Link
|
||||||
href={`/content/${collection}?${sortQuery("_slug", nextSlugOrder)}`}
|
href={`/content/${collection}?${sortQuery("_slug", nextSlugOrder)}`}
|
||||||
className="inline-flex items-center gap-1 font-medium hover:underline"
|
className="inline-flex items-center gap-1 font-medium no-underline hover:text-accent-800"
|
||||||
title={t("sortBy", { field: "_slug" })}
|
title={t("sortBy", { field: "_slug" })}
|
||||||
>
|
>
|
||||||
_slug
|
_slug
|
||||||
@@ -259,6 +273,38 @@ export default function ContentListPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="w-24">{t("colStatus")}</TableHead>
|
<TableHead className="w-24">{t("colStatus")}</TableHead>
|
||||||
|
<TableHead className="w-36 whitespace-nowrap">
|
||||||
|
<Link
|
||||||
|
href={`/content/${collection}?${sortQuery("_created", nextCreatedOrder)}`}
|
||||||
|
className="inline-flex items-center gap-1 font-medium no-underline hover:text-accent-800"
|
||||||
|
title={t("sortBy", { field: t("colCreated") })}
|
||||||
|
>
|
||||||
|
{t("colCreated")}
|
||||||
|
{isSortCreated && (
|
||||||
|
<Icon
|
||||||
|
icon={order === "desc" ? "mdi:chevron-down" : "mdi:chevron-up"}
|
||||||
|
className="size-4"
|
||||||
|
aria-label={order === "desc" ? t("sortDesc") : t("sortAsc")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead className="w-36 whitespace-nowrap">
|
||||||
|
<Link
|
||||||
|
href={`/content/${collection}?${sortQuery("_updated", nextUpdatedOrder)}`}
|
||||||
|
className="inline-flex items-center gap-1 font-medium no-underline hover:text-accent-800"
|
||||||
|
title={t("sortBy", { field: t("colLastEdited") })}
|
||||||
|
>
|
||||||
|
{t("colLastEdited")}
|
||||||
|
{isSortUpdated && (
|
||||||
|
<Icon
|
||||||
|
icon={order === "desc" ? "mdi:chevron-down" : "mdi:chevron-up"}
|
||||||
|
className="size-4"
|
||||||
|
aria-label={order === "desc" ? t("sortDesc") : t("sortAsc")}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</TableHead>
|
||||||
<TableHead className="w-28 text-right">{t("colActions")}</TableHead>
|
<TableHead className="w-28 text-right">{t("colActions")}</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -273,7 +319,7 @@ export default function ContentListPage() {
|
|||||||
<TableCell className="w-64 max-w-[16rem] font-mono text-sm">
|
<TableCell className="w-64 max-w-[16rem] font-mono text-sm">
|
||||||
<Link
|
<Link
|
||||||
href={editHref}
|
href={editHref}
|
||||||
className="block truncate text-accent-700 hover:underline hover:text-accent-900"
|
className="block truncate text-accent-700 no-underline hover:text-accent-900"
|
||||||
title={slug}
|
title={slug}
|
||||||
>
|
>
|
||||||
{slug}
|
{slug}
|
||||||
@@ -290,6 +336,12 @@ export default function ContentListPage() {
|
|||||||
{isDraft ? t("draft") : t("published")}
|
{isDraft ? t("draft") : t("published")}
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell className="w-36 text-sm text-gray-600">
|
||||||
|
{formatListDate(entry._created as string | undefined)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="w-36 text-sm text-gray-600">
|
||||||
|
{formatListDate(entry._updated as string | undefined)}
|
||||||
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
<Button variant="outline" size="icon-sm" asChild className="min-h-[44px] w-11 sm:min-h-0">
|
<Button variant="outline" size="icon-sm" asChild className="min-h-[44px] w-11 sm:min-h-0">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { getTranslations } from "next-intl/server";
|
|||||||
import { Icon } from "@iconify/react";
|
import { Icon } from "@iconify/react";
|
||||||
import { fetchCollections, type CollectionMeta } from "@/lib/api";
|
import { fetchCollections, type CollectionMeta } from "@/lib/api";
|
||||||
import { DashboardCollectionList } from "@/components/DashboardCollectionList";
|
import { DashboardCollectionList } from "@/components/DashboardCollectionList";
|
||||||
|
import { DashboardRecentEdits } from "@/components/DashboardRecentEdits";
|
||||||
|
|
||||||
export default async function DashboardPage() {
|
export default async function DashboardPage() {
|
||||||
const t = await getTranslations("Dashboard");
|
const t = await getTranslations("Dashboard");
|
||||||
@@ -30,6 +31,7 @@ export default async function DashboardPage() {
|
|||||||
{t("newContentType")}
|
{t("newContentType")}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
<DashboardRecentEdits collections={collections} />
|
||||||
<DashboardCollectionList collections={collections} />
|
<DashboardCollectionList collections={collections} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -645,8 +645,17 @@ export function ContentForm({
|
|||||||
flush();
|
flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onInvalid = (errs: Record<string, unknown>) => {
|
||||||
|
const firstKey = Object.keys(errs).find((k) => k !== "root" && errs[k]);
|
||||||
|
if (firstKey) {
|
||||||
|
const el = document.querySelector(`[name="${firstKey}"], [data-field="${firstKey}"]`);
|
||||||
|
if (el instanceof HTMLElement) el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
}
|
||||||
|
toast.error(t("validationErrors"));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="max-w-2xl space-y-6">
|
<form onSubmit={handleSubmit(onSubmit, onInvalid)} className="max-w-2xl space-y-6">
|
||||||
{errors.root && (
|
{errors.root && (
|
||||||
<div
|
<div
|
||||||
className="rounded bg-red-50 p-3 text-sm text-red-700"
|
className="rounded bg-red-50 p-3 text-sm text-red-700"
|
||||||
@@ -656,20 +665,22 @@ export function ContentForm({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showSlugField && (
|
{showSlugField && (
|
||||||
<SlugField
|
<div data-field="_slug">
|
||||||
collection={collection}
|
<SlugField
|
||||||
currentSlug={slug}
|
collection={collection}
|
||||||
slugPrefix={!isEdit ? slugPrefixForCollection(collection) : undefined}
|
currentSlug={slug}
|
||||||
locale={locale}
|
slugPrefix={!isEdit ? slugPrefixForCollection(collection) : undefined}
|
||||||
register={register}
|
locale={locale}
|
||||||
setValue={setValue}
|
register={register}
|
||||||
setError={setError}
|
setValue={setValue}
|
||||||
clearErrors={clearErrors}
|
setError={setError}
|
||||||
error={errors._slug}
|
clearErrors={clearErrors}
|
||||||
slugValue={watch("_slug") as string | undefined}
|
error={errors._slug}
|
||||||
/>
|
slugValue={watch("_slug") as string | undefined}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div data-field="_status">
|
||||||
<Label className="mb-1 block font-medium">{t("status")}</Label>
|
<Label className="mb-1 block font-medium">{t("status")}</Label>
|
||||||
<Controller
|
<Controller
|
||||||
name="_status"
|
name="_status"
|
||||||
@@ -729,7 +740,7 @@ export function ContentForm({
|
|||||||
contentClassName="space-y-4"
|
contentClassName="space-y-4"
|
||||||
>
|
>
|
||||||
{item.entries.map(([name, def]) => (
|
{item.entries.map(([name, def]) => (
|
||||||
<div key={name} className={FIELD_BLOCK_CLASS}>
|
<div key={name} className={FIELD_BLOCK_CLASS} data-field={name}>
|
||||||
<Field
|
<Field
|
||||||
name={name}
|
name={name}
|
||||||
def={def}
|
def={def}
|
||||||
@@ -758,7 +769,7 @@ export function ContentForm({
|
|||||||
locale={locale}
|
locale={locale}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div key={name} className={FIELD_BLOCK_CLASS}>
|
<div key={name} className={FIELD_BLOCK_CLASS} data-field={name}>
|
||||||
<Field
|
<Field
|
||||||
name={name}
|
name={name}
|
||||||
def={def as FieldDefinition}
|
def={def as FieldDefinition}
|
||||||
|
|||||||
101
admin-ui/src/components/DashboardRecentEdits.tsx
Normal file
101
admin-ui/src/components/DashboardRecentEdits.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Icon } from "@iconify/react";
|
||||||
|
import { fetchContentList } from "@/lib/api";
|
||||||
|
import type { CollectionMeta } from "@/lib/api";
|
||||||
|
|
||||||
|
const RECENT_COLLECTION = "post";
|
||||||
|
const RECENT_LIMIT = 3;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
collections: CollectionMeta[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatListDate(iso: string | undefined): string {
|
||||||
|
if (!iso) return "";
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleString(undefined, {
|
||||||
|
dateStyle: "short",
|
||||||
|
timeStyle: "short",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardRecentEdits({ collections }: Props) {
|
||||||
|
const t = useTranslations("Dashboard");
|
||||||
|
const hasPost = collections.some((c) => c.name === RECENT_COLLECTION);
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: ["content", RECENT_COLLECTION, "_updated", "desc", RECENT_LIMIT],
|
||||||
|
queryFn: () =>
|
||||||
|
fetchContentList(RECENT_COLLECTION, {
|
||||||
|
_sort: "_updated",
|
||||||
|
_order: "desc",
|
||||||
|
_per_page: RECENT_LIMIT,
|
||||||
|
_status: "all",
|
||||||
|
}),
|
||||||
|
enabled: hasPost,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!hasPost) return null;
|
||||||
|
const items = (data?.items ?? []) as Record<string, unknown>[];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="mb-8 rounded-xl border border-gray-200 bg-white p-4 shadow-[0_1px_2px_rgba(0,0,0,0.04)]">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">
|
||||||
|
{t("recentSectionTitle")}
|
||||||
|
</h2>
|
||||||
|
<Link
|
||||||
|
href={`/content/${RECENT_COLLECTION}`}
|
||||||
|
className="text-sm font-medium text-accent-600 hover:text-accent-800 hover:underline"
|
||||||
|
>
|
||||||
|
{t("recentSectionLink")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{isLoading && <p className="text-sm text-gray-500">{t("loading")}</p>}
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-red-600">
|
||||||
|
{error instanceof Error ? error.message : String(error)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!isLoading && !error && items.length === 0 && (
|
||||||
|
<p className="text-sm text-gray-500">{t("recentSectionEmpty")}</p>
|
||||||
|
)}
|
||||||
|
{!isLoading && !error && items.length > 0 && (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{items.map((entry) => {
|
||||||
|
const slug = entry._slug as string | undefined;
|
||||||
|
if (!slug) return null;
|
||||||
|
const updated = entry._updated as string | undefined;
|
||||||
|
return (
|
||||||
|
<li key={slug}>
|
||||||
|
<Link
|
||||||
|
href={`/content/${RECENT_COLLECTION}/${encodeURIComponent(slug)}`}
|
||||||
|
className="flex flex-wrap items-baseline justify-between gap-2 rounded-lg border border-gray-100 bg-gray-50/50 px-3 py-2 text-sm no-underline! hover:bg-gray-100/80 hover:border-gray-200"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="font-mono text-gray-900 truncate max-w-[70%]"
|
||||||
|
title={slug}
|
||||||
|
>
|
||||||
|
{slug}
|
||||||
|
</span>
|
||||||
|
{updated && (
|
||||||
|
<span className="shrink-0 text-xs text-gray-500">
|
||||||
|
{formatListDate(updated)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useId, useRef } from "react";
|
import { useId, useRef, useMemo } from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
|
import rehypeRaw from "rehype-raw";
|
||||||
import { useTranslations } from "next-intl";
|
import { useTranslations } from "next-intl";
|
||||||
|
import { getBaseUrl, expandAssetUrlsInText } from "@/lib/api";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -57,6 +59,10 @@ export function MarkdownEditor({
|
|||||||
const id = useId();
|
const id = useId();
|
||||||
const previewId = `${id}-preview`;
|
const previewId = `${id}-preview`;
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const previewMarkdown = useMemo(
|
||||||
|
() => expandAssetUrlsInText(value ?? "", getBaseUrl()),
|
||||||
|
[value]
|
||||||
|
);
|
||||||
|
|
||||||
const apply = (result: { newText: string; newStart: number; newEnd: number }) => {
|
const apply = (result: { newText: string; newStart: number; newEnd: number }) => {
|
||||||
onChange(result.newText);
|
onChange(result.newText);
|
||||||
@@ -162,7 +168,7 @@ export function MarkdownEditor({
|
|||||||
className="min-h-[120px] rounded border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-800 [&_ul]:list-inside [&_ul]:list-disc [&_ol]:list-inside [&_ol]:list-decimal [&_pre]:overflow-x-auto [&_pre]:rounded [&_pre]:bg-gray-200 [&_pre]:p-2 [&_code]:rounded [&_code]:bg-gray-200 [&_code]:px-1 [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_h1]:text-lg [&_h1]:font-bold [&_h2]:text-base [&_h2]:font-bold [&_h3]:text-sm [&_h3]:font-bold [&_a]:text-accent-700 [&_a]:underline [&_a:hover]:text-accent-800"
|
className="min-h-[120px] rounded border border-gray-200 bg-gray-50 px-3 py-2 text-sm text-gray-800 [&_ul]:list-inside [&_ul]:list-disc [&_ol]:list-inside [&_ol]:list-decimal [&_pre]:overflow-x-auto [&_pre]:rounded [&_pre]:bg-gray-200 [&_pre]:p-2 [&_code]:rounded [&_code]:bg-gray-200 [&_code]:px-1 [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_h1]:text-lg [&_h1]:font-bold [&_h2]:text-base [&_h2]:font-bold [&_h3]:text-sm [&_h3]:font-bold [&_a]:text-accent-700 [&_a]:underline [&_a:hover]:text-accent-800"
|
||||||
>
|
>
|
||||||
{(value ?? "").trim() ? (
|
{(value ?? "").trim() ? (
|
||||||
<ReactMarkdown>{value}</ReactMarkdown>
|
<ReactMarkdown rehypePlugins={[rehypeRaw]}>{previewMarkdown}</ReactMarkdown>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-gray-400">{t("emptyPreview")}</span>
|
<span className="text-gray-400">{t("emptyPreview")}</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -6,6 +6,21 @@
|
|||||||
export const getBaseUrl = () =>
|
export const getBaseUrl = () =>
|
||||||
process.env.NEXT_PUBLIC_RUSTYCMS_API_URL || "http://127.0.0.1:3000";
|
process.env.NEXT_PUBLIC_RUSTYCMS_API_URL || "http://127.0.0.1:3000";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expand relative /api/assets/ paths in text (e.g. markdown) to full URLs for preview/display.
|
||||||
|
* Idempotent: already-expanded base URL is preserved.
|
||||||
|
*/
|
||||||
|
export function expandAssetUrlsInText(text: string, baseUrl: string): string {
|
||||||
|
const base = baseUrl.replace(/\/+$/, "");
|
||||||
|
const fullPrefix = `${base}/api/assets/`;
|
||||||
|
if (!text.includes("/api/assets/")) return text;
|
||||||
|
const placeholder = "\x00__ASSET_BASE__\x00";
|
||||||
|
return text
|
||||||
|
.replaceAll(fullPrefix, placeholder)
|
||||||
|
.replaceAll("/api/assets/", fullPrefix)
|
||||||
|
.replaceAll(placeholder, fullPrefix);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Recursively collapse absolute asset URLs to relative paths so the admin
|
* Recursively collapse absolute asset URLs to relative paths so the admin
|
||||||
* keeps and displays /api/assets/... and the backend stores relative.
|
* keeps and displays /api/assets/... and the backend stores relative.
|
||||||
@@ -14,10 +29,11 @@ export function collapseAssetUrlsForAdmin(
|
|||||||
value: unknown,
|
value: unknown,
|
||||||
baseUrl: string
|
baseUrl: string
|
||||||
): unknown {
|
): unknown {
|
||||||
const prefix = `${baseUrl.replace(/\/+$/, "")}/api/assets/`;
|
const base = baseUrl.replace(/\/+$/, "");
|
||||||
|
const prefix = `${base}/api/assets/`;
|
||||||
if (typeof value === "string") {
|
if (typeof value === "string") {
|
||||||
if (value.startsWith(prefix)) return `/api/assets/${value.slice(prefix.length)}`;
|
if (!value.includes(prefix)) return value;
|
||||||
return value;
|
return value.replaceAll(prefix, "/api/assets/");
|
||||||
}
|
}
|
||||||
if (Array.isArray(value)) return value.map((v) => collapseAssetUrlsForAdmin(v, baseUrl));
|
if (Array.isArray(value)) return value.map((v) => collapseAssetUrlsForAdmin(v, baseUrl));
|
||||||
if (value != null && typeof value === "object") {
|
if (value != null && typeof value === "object") {
|
||||||
|
|||||||
@@ -570,9 +570,7 @@ pub async fn list_entries(
|
|||||||
Some(&*registry),
|
Some(&*registry),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
if resolve.is_some() {
|
expand_asset_urls(item, &state.base_url);
|
||||||
expand_asset_urls(item, &state.base_url);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let response_value = serde_json::to_value(&result).unwrap();
|
let response_value = serde_json::to_value(&result).unwrap();
|
||||||
@@ -676,9 +674,7 @@ pub async fn get_entry(
|
|||||||
Some(&*registry),
|
Some(&*registry),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
if resolve.is_some() {
|
expand_asset_urls(&mut formatted, &state.base_url);
|
||||||
expand_asset_urls(&mut formatted, &state.base_url);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only cache published entries so unauthenticated requests never see cached drafts.
|
// Only cache published entries so unauthenticated requests never see cached drafts.
|
||||||
if !entry_is_draft(&formatted) {
|
if !entry_is_draft(&formatted) {
|
||||||
@@ -844,7 +840,7 @@ pub async fn create_entry(
|
|||||||
.await
|
.await
|
||||||
.map_err(ApiError::from)?
|
.map_err(ApiError::from)?
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let formatted = format_references(
|
let mut formatted = format_references(
|
||||||
entry,
|
entry,
|
||||||
&schema,
|
&schema,
|
||||||
store.as_ref(),
|
store.as_ref(),
|
||||||
@@ -853,6 +849,7 @@ pub async fn create_entry(
|
|||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
expand_asset_urls(&mut formatted, &state.base_url);
|
||||||
|
|
||||||
webhooks::fire(
|
webhooks::fire(
|
||||||
state.http_client.clone(),
|
state.http_client.clone(),
|
||||||
@@ -1003,7 +1000,7 @@ pub async fn update_entry(
|
|||||||
.await
|
.await
|
||||||
.map_err(ApiError::from)?
|
.map_err(ApiError::from)?
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let formatted = format_references(
|
let mut formatted = format_references(
|
||||||
entry,
|
entry,
|
||||||
&schema,
|
&schema,
|
||||||
store.as_ref(),
|
store.as_ref(),
|
||||||
@@ -1012,6 +1009,7 @@ pub async fn update_entry(
|
|||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
expand_asset_urls(&mut formatted, &state.base_url);
|
||||||
|
|
||||||
webhooks::fire(
|
webhooks::fire(
|
||||||
state.http_client.clone(),
|
state.http_client.clone(),
|
||||||
|
|||||||
@@ -9,11 +9,19 @@ use crate::schema::SchemaRegistry;
|
|||||||
use crate::store::ContentStore;
|
use crate::store::ContentStore;
|
||||||
|
|
||||||
/// Recursively expand relative `/api/assets/` paths to absolute URLs.
|
/// Recursively expand relative `/api/assets/` paths to absolute URLs.
|
||||||
/// Idempotent: strings already starting with a scheme (http/https) are skipped.
|
/// Whole-string values and occurrences inside strings (e.g. markdown) are expanded.
|
||||||
|
/// Idempotent: already-expanded base URL is preserved via placeholder during replace.
|
||||||
pub fn expand_asset_urls(value: &mut Value, base_url: &str) {
|
pub fn expand_asset_urls(value: &mut Value, base_url: &str) {
|
||||||
|
let base = base_url.trim_end_matches('/');
|
||||||
|
let full_prefix = format!("{}/api/assets/", base);
|
||||||
match value {
|
match value {
|
||||||
Value::String(s) if s.starts_with("/api/assets/") => {
|
Value::String(s) if s.contains("/api/assets/") => {
|
||||||
*s = format!("{}{}", base_url.trim_end_matches('/'), s);
|
// Avoid double-expand: temporarily replace already-expanded URLs
|
||||||
|
const PLACEHOLDER: &str = "\x00__ASSET_BASE__\x00";
|
||||||
|
*s = s
|
||||||
|
.replace(&full_prefix, PLACEHOLDER)
|
||||||
|
.replace("/api/assets/", &full_prefix)
|
||||||
|
.replace(PLACEHOLDER, &full_prefix);
|
||||||
}
|
}
|
||||||
Value::Array(arr) => arr.iter_mut().for_each(|v| expand_asset_urls(v, base_url)),
|
Value::Array(arr) => arr.iter_mut().for_each(|v| expand_asset_urls(v, base_url)),
|
||||||
Value::Object(map) => map.values_mut().for_each(|v| expand_asset_urls(v, base_url)),
|
Value::Object(map) => map.values_mut().for_each(|v| expand_asset_urls(v, base_url)),
|
||||||
@@ -23,11 +31,12 @@ pub fn expand_asset_urls(value: &mut Value, base_url: &str) {
|
|||||||
|
|
||||||
/// Reverse of expand_asset_urls: strip the base_url prefix from absolute asset URLs
|
/// Reverse of expand_asset_urls: strip the base_url prefix from absolute asset URLs
|
||||||
/// before persisting to disk, so stored paths are always relative.
|
/// before persisting to disk, so stored paths are always relative.
|
||||||
|
/// Whole-string values and occurrences inside strings (e.g. markdown) are collapsed.
|
||||||
pub fn collapse_asset_urls(value: &mut Value, base_url: &str) {
|
pub fn collapse_asset_urls(value: &mut Value, base_url: &str) {
|
||||||
let prefix = format!("{}/api/assets/", base_url.trim_end_matches('/'));
|
let prefix = format!("{}/api/assets/", base_url.trim_end_matches('/'));
|
||||||
match value {
|
match value {
|
||||||
Value::String(s) if s.starts_with(&prefix) => {
|
Value::String(s) if s.contains(&prefix) => {
|
||||||
*s = format!("/api/assets/{}", &s[prefix.len()..]);
|
*s = s.replace(&prefix, "/api/assets/");
|
||||||
}
|
}
|
||||||
Value::Array(arr) => arr.iter_mut().for_each(|v| collapse_asset_urls(v, base_url)),
|
Value::Array(arr) => arr.iter_mut().for_each(|v| collapse_asset_urls(v, base_url)),
|
||||||
Value::Object(map) => map.values_mut().for_each(|v| collapse_asset_urls(v, base_url)),
|
Value::Object(map) => map.values_mut().for_each(|v| collapse_asset_urls(v, base_url)),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use std::path::{Path, PathBuf};
|
|||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use tokio::fs;
|
use tokio::fs;
|
||||||
use tokio::io::AsyncWriteExt;
|
use tokio::io::AsyncWriteExt;
|
||||||
@@ -34,6 +35,32 @@ impl FileStore {
|
|||||||
.join(format!("{}.json5", slug))
|
.join(format!("{}.json5", slug))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Attach _created and _updated (ISO8601) from file metadata when available.
|
||||||
|
async fn attach_file_timestamps(&self, path: &Path, value: &mut Value) {
|
||||||
|
let meta = match fs::metadata(path).await {
|
||||||
|
Ok(m) => m,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
let modified = match meta.modified() {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
let modified_dt: DateTime<Utc> = modified.into();
|
||||||
|
let updated = modified_dt.to_rfc3339();
|
||||||
|
let created = meta
|
||||||
|
.created()
|
||||||
|
.ok()
|
||||||
|
.map(|t| {
|
||||||
|
let dt: DateTime<Utc> = t.into();
|
||||||
|
dt.to_rfc3339()
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| updated.clone());
|
||||||
|
if let Some(obj) = value.as_object_mut() {
|
||||||
|
obj.insert("_created".to_string(), Value::String(created));
|
||||||
|
obj.insert("_updated".to_string(), Value::String(updated));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Path for optional external content file: same dir as entry, stem.content.md.
|
/// Path for optional external content file: same dir as entry, stem.content.md.
|
||||||
/// Used so markdown (and other long text) can live in a .md file instead of JSON.
|
/// Used so markdown (and other long text) can live in a .md file instead of JSON.
|
||||||
fn content_file_path(&self, entry_path: &Path) -> PathBuf {
|
fn content_file_path(&self, entry_path: &Path) -> PathBuf {
|
||||||
@@ -178,6 +205,7 @@ impl FileStore {
|
|||||||
match self.read_file(&path).await {
|
match self.read_file(&path).await {
|
||||||
Ok(mut value) => {
|
Ok(mut value) => {
|
||||||
let _ = self.resolve_content_file(&path, &mut value).await;
|
let _ = self.resolve_content_file(&path, &mut value).await;
|
||||||
|
self.attach_file_timestamps(&path, &mut value).await;
|
||||||
if let Some(obj) = value.as_object_mut() {
|
if let Some(obj) = value.as_object_mut() {
|
||||||
obj.insert("_slug".to_string(), Value::String(slug.clone()));
|
obj.insert("_slug".to_string(), Value::String(slug.clone()));
|
||||||
}
|
}
|
||||||
@@ -201,6 +229,7 @@ impl FileStore {
|
|||||||
|
|
||||||
let mut value = self.read_file(&path).await?;
|
let mut value = self.read_file(&path).await?;
|
||||||
self.resolve_content_file(&path, &mut value).await?;
|
self.resolve_content_file(&path, &mut value).await?;
|
||||||
|
self.attach_file_timestamps(&path, &mut value).await;
|
||||||
if let Some(obj) = value.as_object_mut() {
|
if let Some(obj) = value.as_object_mut() {
|
||||||
obj.insert("_slug".to_string(), Value::String(slug.to_string()));
|
obj.insert("_slug".to_string(), Value::String(slug.to_string()));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user