@stacksjs/defaults 0.70.207 → 0.70.208
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/TODO.md +21 -0
- package/ai/AGENTS.md +79 -0
- package/ai/README.md +45 -0
- package/ai/claude/launch.json +12 -0
- package/ai/skills/stacks-actions/SKILL.md +128 -0
- package/ai/skills/stacks-ai/SKILL.md +276 -0
- package/ai/skills/stacks-alias/SKILL.md +94 -0
- package/ai/skills/stacks-analytics/SKILL.md +91 -0
- package/ai/skills/stacks-api/SKILL.md +738 -0
- package/ai/skills/stacks-arrays/SKILL.md +277 -0
- package/ai/skills/stacks-auth/SKILL.md +422 -0
- package/ai/skills/stacks-auto-imports/SKILL.md +138 -0
- package/ai/skills/stacks-brandkit/SKILL.md +811 -0
- package/ai/skills/stacks-browse/SKILL.md +127 -0
- package/ai/skills/stacks-browse/scripts/browse.ts +492 -0
- package/ai/skills/stacks-browser/SKILL.md +145 -0
- package/ai/skills/stacks-buddy/SKILL.md +676 -0
- package/ai/skills/stacks-build/SKILL.md +163 -0
- package/ai/skills/stacks-cache/SKILL.md +125 -0
- package/ai/skills/stacks-calendar/SKILL.md +113 -0
- package/ai/skills/stacks-chat/SKILL.md +371 -0
- package/ai/skills/stacks-cli/SKILL.md +112 -0
- package/ai/skills/stacks-cloud/SKILL.md +192 -0
- package/ai/skills/stacks-cms/SKILL.md +217 -0
- package/ai/skills/stacks-collections/SKILL.md +82 -0
- package/ai/skills/stacks-commerce/SKILL.md +114 -0
- package/ai/skills/stacks-composables/SKILL.md +161 -0
- package/ai/skills/stacks-config/SKILL.md +144 -0
- package/ai/skills/stacks-configuration/SKILL.md +154 -0
- package/ai/skills/stacks-cron/SKILL.md +123 -0
- package/ai/skills/stacks-crosswind/SKILL.md +190 -0
- package/ai/skills/stacks-dashboard/SKILL.md +173 -0
- package/ai/skills/stacks-database/SKILL.md +286 -0
- package/ai/skills/stacks-datetime/SKILL.md +269 -0
- package/ai/skills/stacks-dependencies/SKILL.md +111 -0
- package/ai/skills/stacks-deploy/SKILL.md +102 -0
- package/ai/skills/stacks-design-brutalist/SKILL.md +110 -0
- package/ai/skills/stacks-design-minimalist/SKILL.md +115 -0
- package/ai/skills/stacks-design-output/SKILL.md +55 -0
- package/ai/skills/stacks-design-soft/SKILL.md +156 -0
- package/ai/skills/stacks-design-taste/SKILL.md +1012 -0
- package/ai/skills/stacks-design-taste-codex/SKILL.md +294 -0
- package/ai/skills/stacks-desktop/SKILL.md +78 -0
- package/ai/skills/stacks-development/SKILL.md +298 -0
- package/ai/skills/stacks-dns/SKILL.md +149 -0
- package/ai/skills/stacks-docs/SKILL.md +109 -0
- package/ai/skills/stacks-email/SKILL.md +236 -0
- package/ai/skills/stacks-enums/SKILL.md +100 -0
- package/ai/skills/stacks-env/SKILL.md +181 -0
- package/ai/skills/stacks-error-handling/SKILL.md +137 -0
- package/ai/skills/stacks-events/SKILL.md +228 -0
- package/ai/skills/stacks-faker/SKILL.md +158 -0
- package/ai/skills/stacks-git/SKILL.md +158 -0
- package/ai/skills/stacks-guard/SKILL.md +98 -0
- package/ai/skills/stacks-health/SKILL.md +75 -0
- package/ai/skills/stacks-http/SKILL.md +316 -0
- package/ai/skills/stacks-i18n/SKILL.md +198 -0
- package/ai/skills/stacks-image-to-code/SKILL.md +629 -0
- package/ai/skills/stacks-imagegen-mobile/SKILL.md +1474 -0
- package/ai/skills/stacks-imagegen-web/SKILL.md +998 -0
- package/ai/skills/stacks-investigate/SKILL.md +119 -0
- package/ai/skills/stacks-jobs/SKILL.md +136 -0
- package/ai/skills/stacks-lint/SKILL.md +184 -0
- package/ai/skills/stacks-listeners/SKILL.md +99 -0
- package/ai/skills/stacks-logging/SKILL.md +78 -0
- package/ai/skills/stacks-mail/SKILL.md +120 -0
- package/ai/skills/stacks-middleware/SKILL.md +249 -0
- package/ai/skills/stacks-migrations/SKILL.md +105 -0
- package/ai/skills/stacks-models/SKILL.md +267 -0
- package/ai/skills/stacks-new-feature/SKILL.md +206 -0
- package/ai/skills/stacks-notifications/SKILL.md +210 -0
- package/ai/skills/stacks-objects/SKILL.md +205 -0
- package/ai/skills/stacks-office-hours/SKILL.md +134 -0
- package/ai/skills/stacks-orm/SKILL.md +342 -0
- package/ai/skills/stacks-path/SKILL.md +118 -0
- package/ai/skills/stacks-payments/SKILL.md +373 -0
- package/ai/skills/stacks-plan-review/SKILL.md +152 -0
- package/ai/skills/stacks-plugins/SKILL.md +149 -0
- package/ai/skills/stacks-push/SKILL.md +376 -0
- package/ai/skills/stacks-query-builder/SKILL.md +128 -0
- package/ai/skills/stacks-queue/SKILL.md +559 -0
- package/ai/skills/stacks-realtime/SKILL.md +442 -0
- package/ai/skills/stacks-redesign/SKILL.md +182 -0
- package/ai/skills/stacks-registry/SKILL.md +58 -0
- package/ai/skills/stacks-repl/SKILL.md +80 -0
- package/ai/skills/stacks-retro/SKILL.md +122 -0
- package/ai/skills/stacks-review/SKILL.md +135 -0
- package/ai/skills/stacks-router/SKILL.md +231 -0
- package/ai/skills/stacks-routes/SKILL.md +105 -0
- package/ai/skills/stacks-scaffolding/SKILL.md +162 -0
- package/ai/skills/stacks-scheduler/SKILL.md +363 -0
- package/ai/skills/stacks-search-engine/SKILL.md +112 -0
- package/ai/skills/stacks-security/SKILL.md +129 -0
- package/ai/skills/stacks-security-audit/SKILL.md +128 -0
- package/ai/skills/stacks-server/SKILL.md +458 -0
- package/ai/skills/stacks-shell/SKILL.md +62 -0
- package/ai/skills/stacks-slug/SKILL.md +87 -0
- package/ai/skills/stacks-sms/SKILL.md +385 -0
- package/ai/skills/stacks-socials/SKILL.md +303 -0
- package/ai/skills/stacks-storage/SKILL.md +576 -0
- package/ai/skills/stacks-strings/SKILL.md +416 -0
- package/ai/skills/stacks-stx/SKILL.md +176 -0
- package/ai/skills/stacks-technical-diagrams/LICENSE +22 -0
- package/ai/skills/stacks-technical-diagrams/SKILL.md +290 -0
- package/ai/skills/stacks-technical-diagrams/agents/openai.yaml +4 -0
- package/ai/skills/stacks-technical-diagrams/assets/template.html +1252 -0
- package/ai/skills/stacks-technical-diagrams/bin/technical-diagrams.mjs +289 -0
- package/ai/skills/stacks-technical-diagrams/bunfig.toml +1 -0
- package/ai/skills/stacks-technical-diagrams/examples/agent-run.lifecycle.json +64 -0
- package/ai/skills/stacks-technical-diagrams/examples/agent-tool-call.workflow.json +88 -0
- package/ai/skills/stacks-technical-diagrams/examples/cache-miss-request.sequence.json +75 -0
- package/ai/skills/stacks-technical-diagrams/examples/product-analytics.dataflow.json +70 -0
- package/ai/skills/stacks-technical-diagrams/examples/web-app.architecture.json +41 -0
- package/ai/skills/stacks-technical-diagrams/renderers/architecture/grid.mjs +62 -0
- package/ai/skills/stacks-technical-diagrams/renderers/architecture/render-architecture.mjs +371 -0
- package/ai/skills/stacks-technical-diagrams/renderers/dataflow/README.md +82 -0
- package/ai/skills/stacks-technical-diagrams/renderers/dataflow/render-dataflow.mjs +295 -0
- package/ai/skills/stacks-technical-diagrams/renderers/lifecycle/README.md +91 -0
- package/ai/skills/stacks-technical-diagrams/renderers/lifecycle/render-lifecycle.mjs +384 -0
- package/ai/skills/stacks-technical-diagrams/renderers/sequence/README.md +84 -0
- package/ai/skills/stacks-technical-diagrams/renderers/sequence/render-sequence.mjs +257 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/cli.mjs +42 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/generated-validators.mjs +13 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/geometry.mjs +230 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/layout-report.mjs +40 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/utils.mjs +88 -0
- package/ai/skills/stacks-technical-diagrams/renderers/shared/validator.mjs +40 -0
- package/ai/skills/stacks-technical-diagrams/renderers/workflow/README.md +103 -0
- package/ai/skills/stacks-technical-diagrams/renderers/workflow/render-workflow.mjs +481 -0
- package/ai/skills/stacks-technical-diagrams/schemas/README.md +68 -0
- package/ai/skills/stacks-technical-diagrams/schemas/architecture.schema.json +121 -0
- package/ai/skills/stacks-technical-diagrams/schemas/common.schema.json +43 -0
- package/ai/skills/stacks-technical-diagrams/schemas/dataflow.schema.json +221 -0
- package/ai/skills/stacks-technical-diagrams/schemas/lifecycle.schema.json +241 -0
- package/ai/skills/stacks-technical-diagrams/schemas/sequence.schema.json +186 -0
- package/ai/skills/stacks-technical-diagrams/schemas/workflow.schema.json +332 -0
- package/ai/skills/stacks-technical-diagrams/scripts/check-render-output.mjs +297 -0
- package/ai/skills/stacks-technical-diagrams/scripts/render-examples.mjs +28 -0
- package/ai/skills/stacks-testing/SKILL.md +129 -0
- package/ai/skills/stacks-tunnel/SKILL.md +125 -0
- package/ai/skills/stacks-types/SKILL.md +178 -0
- package/ai/skills/stacks-ui/SKILL.md +246 -0
- package/ai/skills/stacks-utils/SKILL.md +444 -0
- package/ai/skills/stacks-validation/SKILL.md +371 -0
- package/ai/skills/stacks-whois/SKILL.md +315 -0
- package/assets/world-map-data.ts +121 -0
- package/bootstrap.ts +114 -0
- package/docs/api-examples.md +46 -0
- package/docs/index.md +24 -0
- package/docs/markdown-examples.md +87 -0
- package/functions/analytics.ts +97 -0
- package/functions/api.ts +143 -0
- package/functions/auth.ts +208 -0
- package/functions/billing/payments.ts +149 -0
- package/functions/blog.ts +120 -0
- package/functions/cms/categorizables.ts +107 -0
- package/functions/cms/pages.ts +108 -0
- package/functions/cms/posts.ts +100 -0
- package/functions/cms/taggables.ts +102 -0
- package/functions/commerce/coupons.ts +128 -0
- package/functions/commerce/customers.ts +128 -0
- package/functions/commerce/gift-cards.ts +119 -0
- package/functions/commerce/orders.ts +112 -0
- package/functions/commerce/payments.ts +119 -0
- package/functions/commerce/products/categories.ts +123 -0
- package/functions/commerce/products/manufacturers.ts +123 -0
- package/functions/commerce/products/products.ts +123 -0
- package/functions/commerce/products/reviews.ts +123 -0
- package/functions/commerce/products/units.ts +123 -0
- package/functions/commerce/products/variants.ts +123 -0
- package/functions/commerce/shippings/delivery-routes.ts +123 -0
- package/functions/commerce/shippings/digital-deliveries.ts +119 -0
- package/functions/commerce/shippings/drivers.ts +123 -0
- package/functions/commerce/shippings/license-keys.ts +123 -0
- package/functions/commerce/shippings/shipping-methods.ts +123 -0
- package/functions/commerce/shippings/shipping-rates.ts +119 -0
- package/functions/commerce/shippings/shipping-zones.ts +119 -0
- package/functions/commerce/taxes.ts +119 -0
- package/functions/commerce/waitlist/products.ts +123 -0
- package/functions/commerce/waitlist/restaurants.ts +123 -0
- package/functions/craft.ts +107 -0
- package/functions/dashboard.ts +153 -0
- package/functions/inbox.ts +95 -0
- package/functions/jobs.ts +86 -0
- package/functions/monitoring/errors.ts +295 -0
- package/functions/queue.ts +111 -0
- package/functions/realtime/websockets.ts +96 -0
- package/functions/realtime.ts +52 -0
- package/functions/releases.ts +49 -0
- package/functions/search.ts +126 -0
- package/functions/toasts.ts +95 -0
- package/functions/uploads.ts +216 -0
- package/ide/.gitignore +4 -0
- package/ide/cursor/rules/auto-imports.mdc +574 -0
- package/ide/cursor/rules/code-style.mdc +12 -0
- package/ide/cursor/rules/documentation.mdc +9 -0
- package/ide/cursor/rules/error-handling-validation.mdc +11 -0
- package/ide/cursor/rules/key-conventions.mdc +11 -0
- package/ide/cursor/rules/project-structure.mdc +169 -0
- package/ide/cursor/rules/readme.mdc +318 -0
- package/ide/cursor/rules/syntax-formatting.mdc +9 -0
- package/ide/cursor/rules/testing.mdc +10 -0
- package/ide/cursor/rules/typescript.mdc +9 -0
- package/ide/cursor/rules/ui-styling.mdc +0 -0
- package/ide/dictionary.txt +397 -0
- package/ide/jetbrains/.fleet/settings.json +4 -0
- package/ide/jetbrains/.idea/.gitignore +5 -0
- package/ide/jetbrains/.idea/.name +1 -0
- package/ide/jetbrains/.idea/inspectionProfiles/Project_Default.xml +6 -0
- package/ide/jetbrains/.idea/jsLinters/eslint.xml +7 -0
- package/ide/jetbrains/.idea/modules.xml +8 -0
- package/ide/jetbrains/.idea/stacks.iml +22 -0
- package/ide/jetbrains/.idea/vcs.xml +6 -0
- package/ide/jetbrains/.idea/webResources.xml +14 -0
- package/ide/jetbrains/IntelliJ IDEA Global Settings +0 -0
- package/ide/jetbrains/installed.txt +4 -0
- package/ide/jetbrains/options/github-copilot.xml +5 -0
- package/ide/jetbrains/options/nodejs.xml +7 -0
- package/ide/jetbrains/options/templates.xml +124 -0
- package/ide/jetbrains/templates/JavaScript.xml +6 -0
- package/ide/jetbrains/templates/Stacks.xml +8 -0
- package/ide/vscode/.vscode/README.md +23 -0
- package/ide/vscode/.vscode/extensions.json +5 -0
- package/ide/vscode/.vscode/settings.json +176 -0
- package/ide/vscode/.vscode/stacks.code-snippets +181 -0
- package/ide/vscode/LICENSE.md +21 -0
- package/ide/vscode/README.md +36 -0
- package/ide/vscode/logo.png +0 -0
- package/ide/vscode/package.json +79 -0
- package/ide/zed/.zed/settings.json +139 -0
- package/lang/README.md +28 -0
- package/lang/de.yml +14 -0
- package/lang/en.yml +15 -0
- package/package.json +19 -3
- package/project/bootstrap +161 -0
- package/project/buddy +175 -0
- package/project/storage/framework/tsconfig.app.json +50 -0
- package/project/storage/framework/tsconfig.base.json +58 -0
- package/routes/core.ts +78 -0
- package/routes/dashboard-api.ts +233 -0
- package/routes/dashboard.ts +739 -0
- package/routes/email.ts +129 -0
- package/stores/[name].stx +16 -0
- package/stores/git.ts +71 -0
- package/stores/payment.ts +474 -0
- package/stores/queue.ts +43 -0
- package/stores/user.ts +29 -0
- package/stx-components-plugin.ts +25 -0
- package/styles/design-tokens.css +597 -0
- package/types/dashboard.ts +70 -0
- package/types/defaults.ts +438 -0
- package/vcs/github/CODE_OF_CONDUCT.md +83 -0
- package/vcs/github/CONTRIBUTING.md +187 -0
- package/vcs/github/EXPLANATIONS.md +41 -0
- package/vcs/github/FUNDING.yml +1 -0
- package/vcs/github/ISSUE_TEMPLATE/bug_report.yml +49 -0
- package/vcs/github/ISSUE_TEMPLATE/config.yml +10 -0
- package/vcs/github/ISSUE_TEMPLATE/feature_request.yml +44 -0
- package/vcs/github/SECURITY.md +70 -0
- package/vcs/github/renovate.json +5 -0
- package/vcs/github/stale.yml +14 -0
- package/vcs/github/workflows/README.md +16 -0
- package/vcs/github/workflows/ci.yml +114 -0
- package/vcs/github/workflows/export-size.yml +25 -0
- package/vcs/github/workflows/labeler.yml +13 -0
- package/vcs/github/workflows/release.yml +219 -0
- package/views/[...all].stx +8 -0
- package/views/about.md +3 -0
- package/views/auth/login.stx +1 -0
- package/views/auth/signup.stx +85 -0
- package/views/dashboard/.discovered-models.json +542 -0
- package/views/dashboard/AUDIT.md +904 -0
- package/views/dashboard/[...all].stx +89 -0
- package/views/dashboard/access-tokens/index.stx +177 -0
- package/views/dashboard/actions/index.stx +129 -0
- package/views/dashboard/analytics/blog/index.stx +356 -0
- package/views/dashboard/analytics/browsers/index.stx +263 -0
- package/views/dashboard/analytics/commerce/sales/index.stx +329 -0
- package/views/dashboard/analytics/commerce/web/index.stx +379 -0
- package/views/dashboard/analytics/countries/index.stx +289 -0
- package/views/dashboard/analytics/devices/index.stx +305 -0
- package/views/dashboard/analytics/events/index.stx +536 -0
- package/views/dashboard/analytics/index.stx +88 -0
- package/views/dashboard/analytics/marketing/index.stx +315 -0
- package/views/dashboard/analytics/pages/index.stx +271 -0
- package/views/dashboard/analytics/referrers/index.stx +319 -0
- package/views/dashboard/analytics/web/index.stx +356 -0
- package/views/dashboard/buddy/index.stx +198 -0
- package/views/dashboard/ci/index.stx +462 -0
- package/views/dashboard/cloud/index.stx +452 -0
- package/views/dashboard/commands/index.stx +295 -0
- package/views/dashboard/commerce/categories/index.stx +406 -0
- package/views/dashboard/commerce/coupons/index.stx +485 -0
- package/views/dashboard/commerce/customers/index.stx +410 -0
- package/views/dashboard/commerce/dashboard/index.stx +287 -0
- package/views/dashboard/commerce/delivery/delivery-routes.stx +305 -0
- package/views/dashboard/commerce/delivery/digital-delivery.stx +332 -0
- package/views/dashboard/commerce/delivery/drivers.stx +397 -0
- package/views/dashboard/commerce/delivery/index.stx +18 -0
- package/views/dashboard/commerce/delivery/license-keys.stx +380 -0
- package/views/dashboard/commerce/delivery/shipping-methods.stx +346 -0
- package/views/dashboard/commerce/delivery/shipping-rates.stx +338 -0
- package/views/dashboard/commerce/delivery/shipping-zones.stx +302 -0
- package/views/dashboard/commerce/delivery/tabs.ts +25 -0
- package/views/dashboard/commerce/gift-cards/index.stx +561 -0
- package/views/dashboard/commerce/manufacturers/index.stx +308 -0
- package/views/dashboard/commerce/orders/index.stx +247 -0
- package/views/dashboard/commerce/payments/index.stx +535 -0
- package/views/dashboard/commerce/pos/index.stx +653 -0
- package/views/dashboard/commerce/printers/devices/index.stx +273 -0
- package/views/dashboard/commerce/printers/receipts/index.stx +308 -0
- package/views/dashboard/commerce/products/detail.stx +563 -0
- package/views/dashboard/commerce/products/index.stx +370 -0
- package/views/dashboard/commerce/reviews/index.stx +656 -0
- package/views/dashboard/commerce/reviews/index.ts +0 -0
- package/views/dashboard/commerce/taxes/index.stx +377 -0
- package/views/dashboard/commerce/units/index.stx +317 -0
- package/views/dashboard/commerce/variants/index.stx +319 -0
- package/views/dashboard/commerce/waitlist/products.stx +942 -0
- package/views/dashboard/commerce/waitlist/restaurant.stx +1024 -0
- package/views/dashboard/components/index.stx +330 -0
- package/views/dashboard/composables/index.ts +19 -0
- package/views/dashboard/composables/useChart.ts +77 -0
- package/views/dashboard/composables/useNavigation.ts +99 -0
- package/views/dashboard/composables/useRole.ts +58 -0
- package/views/dashboard/composables/useTheme.ts +74 -0
- package/views/dashboard/content/authors/index.stx +171 -0
- package/views/dashboard/content/blog/index.stx +493 -0
- package/views/dashboard/content/categories/index.stx +169 -0
- package/views/dashboard/content/comments/index.stx +189 -0
- package/views/dashboard/content/dashboard.stx +383 -0
- package/views/dashboard/content/files/index.stx +706 -0
- package/views/dashboard/content/pages/index.stx +174 -0
- package/views/dashboard/content/posts/index.stx +269 -0
- package/views/dashboard/content/seo/index.stx +240 -0
- package/views/dashboard/content/tags/index.stx +167 -0
- package/views/dashboard/data/activity/index.stx +261 -0
- package/views/dashboard/data/dashboard/index.stx +116 -0
- package/views/dashboard/data/subscribers.stx +270 -0
- package/views/dashboard/data/teams.stx +275 -0
- package/views/dashboard/data/users.stx +279 -0
- package/views/dashboard/dependencies/index.stx +178 -0
- package/views/dashboard/deployments/[id].stx +234 -0
- package/views/dashboard/deployments/index.stx +9 -0
- package/views/dashboard/dns/index.stx +163 -0
- package/views/dashboard/environment/index.stx +283 -0
- package/views/dashboard/errors/index.stx +274 -0
- package/views/dashboard/forgot-password.stx +57 -0
- package/views/dashboard/functions/index.stx +159 -0
- package/views/dashboard/health/index.stx +248 -0
- package/views/dashboard/inbox/activity.stx +350 -0
- package/views/dashboard/inbox/index.stx +452 -0
- package/views/dashboard/inbox/settings.stx +290 -0
- package/views/dashboard/index.stx +252 -0
- package/views/dashboard/insights/index.stx +529 -0
- package/views/dashboard/jobs/[id].stx +430 -0
- package/views/dashboard/jobs/history.stx +222 -0
- package/views/dashboard/jobs/index.stx +214 -0
- package/views/dashboard/kanban/[id].stx +899 -0
- package/views/dashboard/kanban/index.stx +260 -0
- package/views/dashboard/layouts/default.stx +565 -0
- package/views/dashboard/layouts/guest.stx +10 -0
- package/views/dashboard/login.stx +48 -0
- package/views/dashboard/logs/index.stx +540 -0
- package/views/dashboard/mailboxes/index.stx +149 -0
- package/views/dashboard/management/permissions/index.stx +490 -0
- package/views/dashboard/marketing/campaigns/index.stx +604 -0
- package/views/dashboard/marketing/lists/index.stx +514 -0
- package/views/dashboard/marketing/reviews/index.stx +475 -0
- package/views/dashboard/marketing/social-posts/index.stx +560 -0
- package/views/dashboard/models/[model].stx +195 -0
- package/views/dashboard/models/index.stx +159 -0
- package/views/dashboard/monitoring/errors/index.stx +415 -0
- package/views/dashboard/notifications/dashboard.stx +383 -0
- package/views/dashboard/notifications/email.stx +336 -0
- package/views/dashboard/notifications/history.stx +373 -0
- package/views/dashboard/notifications/sms.stx +332 -0
- package/views/dashboard/packages/index.stx +195 -0
- package/views/dashboard/queries/[id].stx +381 -0
- package/views/dashboard/queries/history.stx +227 -0
- package/views/dashboard/queries/index.stx +335 -0
- package/views/dashboard/queries/slow.stx +318 -0
- package/views/dashboard/queue/index.stx +427 -0
- package/views/dashboard/realtime/index.stx +429 -0
- package/views/dashboard/register.stx +46 -0
- package/views/dashboard/releases/index.stx +389 -0
- package/views/dashboard/requests/index.stx +462 -0
- package/views/dashboard/serverless/index.stx +475 -0
- package/views/dashboard/servers/[id].stx +299 -0
- package/views/dashboard/servers/index.stx +483 -0
- package/views/dashboard/settings/billing.stx +50 -0
- package/views/dashboard/settings/index.stx +256 -0
- package/views/dashboard/settings/mail.stx +252 -0
- package/views/dashboard/stores/auth.ts +123 -0
- package/views/dashboard/stores/ci.ts +342 -0
- package/views/dashboard/stores/index.ts +129 -0
- package/views/dashboard/stores/kanban.ts +723 -0
- package/views/dashboard/stores/rbac.ts +350 -0
- package/views/dashboard/stx.config.ts +34 -0
- package/views/dashboard/teams/[id].stx +170 -0
- package/views/forum/category.stx +92 -0
- package/views/forum/index.stx +209 -0
- package/views/forum/new-topic.stx +113 -0
- package/views/forum/profile.stx +1 -0
- package/views/forum/topic.stx +143 -0
- package/views/hello-world/[name].stx +47 -0
- package/views/index.stx +21 -0
- package/views/system-tray/index.stx +147 -0
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
@extends('layouts/default')
|
|
2
|
+
|
|
3
|
+
@section('content')
|
|
4
|
+
<div class="p-6 min-h-screen text-neutral-950 dark:text-neutral-100 bg-neutral-50 dark:bg-neutral-950">
|
|
5
|
+
<div class="flex flex-col gap-6 mx-auto max-w-6xl">
|
|
6
|
+
<header class="flex flex-col gap-4 md:flex-row md:items-end md:justify-between pb-5 border-b border-neutral-200 dark:border-neutral-800">
|
|
7
|
+
<div>
|
|
8
|
+
<p class="font-semibold text-blue-600 text-xs tracking-wide uppercase dark:text-blue-400">CMS</p>
|
|
9
|
+
<h1 class="mt-1 font-semibold text-2xl tracking-tight">Authors</h1>
|
|
10
|
+
<p class="mt-1 text-neutral-500 text-sm dark:text-neutral-400">Manage the authors available to posts and pages.</p>
|
|
11
|
+
</div>
|
|
12
|
+
<button id="new-author-button" type="button" class="inline-flex gap-2 items-center px-3 py-2 font-semibold text-sm text-white bg-blue-600 hover:bg-blue-500 rounded-md shadow-sm">
|
|
13
|
+
<span class="h-4 w-4 i-hugeicons-plus-sign"></span>
|
|
14
|
+
New Author
|
|
15
|
+
</button>
|
|
16
|
+
</header>
|
|
17
|
+
|
|
18
|
+
<section class="grid gap-4 md:grid-cols-3">
|
|
19
|
+
<div class="p-4 bg-white dark:bg-neutral-900 border border-neutral-200 rounded-lg dark:border-neutral-800 shadow-sm">
|
|
20
|
+
<p class="text-neutral-500 text-sm dark:text-neutral-400">Total Authors</p>
|
|
21
|
+
<p id="author-total" class="mt-2 font-semibold text-3xl">0</p>
|
|
22
|
+
</div>
|
|
23
|
+
<div class="p-4 bg-white dark:bg-neutral-900 border border-neutral-200 rounded-lg dark:border-neutral-800 shadow-sm">
|
|
24
|
+
<p class="text-neutral-500 text-sm dark:text-neutral-400">Assigned Posts</p>
|
|
25
|
+
<p id="author-posts" class="mt-2 font-semibold text-3xl text-green-600 dark:text-green-400">0</p>
|
|
26
|
+
</div>
|
|
27
|
+
<div class="p-4 bg-white dark:bg-neutral-900 border border-neutral-200 rounded-lg dark:border-neutral-800 shadow-sm">
|
|
28
|
+
<p class="text-neutral-500 text-sm dark:text-neutral-400">Newest</p>
|
|
29
|
+
<p id="author-newest" class="mt-2 font-semibold text-2xl truncate">-</p>
|
|
30
|
+
</div>
|
|
31
|
+
</section>
|
|
32
|
+
|
|
33
|
+
<section class="bg-white dark:bg-neutral-900 border border-neutral-200 rounded-lg dark:border-neutral-800 shadow-sm">
|
|
34
|
+
<div class="p-4 border-b border-neutral-200 dark:border-neutral-800">
|
|
35
|
+
<input id="author-search" type="search" placeholder="Search authors..." class="px-3 py-2 max-w-md w-full text-sm bg-white dark:bg-neutral-950 border border-neutral-300 outline-none rounded-md dark:border-neutral-700 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20" />
|
|
36
|
+
</div>
|
|
37
|
+
<div class="overflow-x-auto">
|
|
38
|
+
<table class="min-w-full text-sm divide-neutral-200 divide-y dark:divide-neutral-800">
|
|
39
|
+
<thead class="font-semibold text-left text-neutral-500 text-xs uppercase dark:text-neutral-400 bg-neutral-50 dark:bg-neutral-900">
|
|
40
|
+
<tr>
|
|
41
|
+
<th class="px-4 py-3">Name</th>
|
|
42
|
+
<th class="px-4 py-3">Email</th>
|
|
43
|
+
<th class="px-4 py-3">Posts</th>
|
|
44
|
+
<th class="px-4 py-3">Created</th>
|
|
45
|
+
<th class="px-4 py-3 text-right">Actions</th>
|
|
46
|
+
</tr>
|
|
47
|
+
</thead>
|
|
48
|
+
<tbody id="authors-table" class="divide-neutral-200 divide-y dark:divide-neutral-800"></tbody>
|
|
49
|
+
</table>
|
|
50
|
+
</div>
|
|
51
|
+
<div id="authors-empty" class="hidden p-10 text-center text-neutral-500 text-sm dark:text-neutral-400">No authors found.</div>
|
|
52
|
+
</section>
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
<dialog id="author-dialog" class="p-0 max-w-lg w-full text-neutral-950 dark:text-neutral-100 bg-white backdrop:bg-black/40 dark:bg-neutral-900 border border-neutral-200 rounded-lg dark:border-neutral-800 shadow-xl">
|
|
57
|
+
<form id="author-form" method="dialog">
|
|
58
|
+
<div class="px-5 py-4 border-b border-neutral-200 dark:border-neutral-800">
|
|
59
|
+
<h2 class="font-semibold text-lg">New Author</h2>
|
|
60
|
+
</div>
|
|
61
|
+
<div class="grid gap-4 p-5">
|
|
62
|
+
<label class="grid gap-1 font-medium text-sm">Name <input id="author-name" required class="px-3 py-2 text-sm bg-white dark:bg-neutral-950 border border-neutral-300 rounded-md dark:border-neutral-700" /></label>
|
|
63
|
+
<label class="grid gap-1 font-medium text-sm">Email <input id="author-email" type="email" required class="px-3 py-2 text-sm bg-white dark:bg-neutral-950 border border-neutral-300 rounded-md dark:border-neutral-700" /></label>
|
|
64
|
+
</div>
|
|
65
|
+
<div class="flex gap-2 justify-end px-5 py-4 border-neutral-200 border-t dark:border-neutral-800">
|
|
66
|
+
<button id="author-cancel" type="button" class="px-3 py-2 font-medium text-sm border border-neutral-300 rounded-md dark:border-neutral-700">Cancel</button>
|
|
67
|
+
<button type="submit" class="px-3 py-2 font-semibold text-sm text-white bg-blue-600 hover:bg-blue-500 rounded-md">Create Author</button>
|
|
68
|
+
</div>
|
|
69
|
+
</form>
|
|
70
|
+
</dialog>
|
|
71
|
+
|
|
72
|
+
<script>
|
|
73
|
+
;(function () {
|
|
74
|
+
var api = '/api/dashboard/authors'
|
|
75
|
+
var authors = []
|
|
76
|
+
var assignedPostCount = 0
|
|
77
|
+
var table = document.getElementById('authors-table')
|
|
78
|
+
var empty = document.getElementById('authors-empty')
|
|
79
|
+
var dialog = document.getElementById('author-dialog')
|
|
80
|
+
var search = document.getElementById('author-search')
|
|
81
|
+
|
|
82
|
+
function $(id) { return document.getElementById(id) }
|
|
83
|
+
// CSRF is default-on for POST/PATCH/DELETE via a stateless double-submit
|
|
84
|
+
// cookie: the server seeds a non-HttpOnly X-CSRF-Token cookie on safe
|
|
85
|
+
// responses and the client echoes it back in the header. The list request on
|
|
86
|
+
// page load seeds it, so the token is present by the time a save can happen.
|
|
87
|
+
// See defaults/app/Middleware/Csrf.ts.
|
|
88
|
+
function writeHeaders() {
|
|
89
|
+
var headers = { 'content-type': 'application/json' }
|
|
90
|
+
var match = document.cookie.match(/(?:^|;\s*)X-CSRF-Token=([^;]*)/)
|
|
91
|
+
if (match) headers['X-CSRF-Token'] = decodeURIComponent(match[1])
|
|
92
|
+
return headers
|
|
93
|
+
}
|
|
94
|
+
function escapeHtml(value) {
|
|
95
|
+
return String(value || '').replace(/[&<>"']/g, function (ch) {
|
|
96
|
+
return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ch]
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
function formatDate(value) {
|
|
100
|
+
if (!value) return '-'
|
|
101
|
+
return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).format(new Date(value))
|
|
102
|
+
}
|
|
103
|
+
function render() {
|
|
104
|
+
var q = search.value.toLowerCase()
|
|
105
|
+
var rows = authors.filter(function (author) {
|
|
106
|
+
return !q || [author.name, author.email].join(' ').toLowerCase().indexOf(q) !== -1
|
|
107
|
+
})
|
|
108
|
+
$('author-total').textContent = String(authors.length)
|
|
109
|
+
$('author-posts').textContent = String(assignedPostCount)
|
|
110
|
+
$('author-newest').textContent = authors[0] ? authors[0].name : '-'
|
|
111
|
+
empty.classList.toggle('hidden', rows.length > 0)
|
|
112
|
+
table.innerHTML = rows.map(function (author) {
|
|
113
|
+
return '<tr class="hover:bg-neutral-50 dark:hover:bg-neutral-950/70">' +
|
|
114
|
+
'<td class="px-4 py-3 font-medium">' + escapeHtml(author.name) + '</td>' +
|
|
115
|
+
'<td class="px-4 py-3 text-neutral-600 dark:text-neutral-300">' + escapeHtml(author.email) + '</td>' +
|
|
116
|
+
'<td class="px-4 py-3 text-neutral-600 dark:text-neutral-300">' + Number(author.postCount || 0) + '</td>' +
|
|
117
|
+
'<td class="px-4 py-3 text-neutral-600 dark:text-neutral-300">' + formatDate(author.created_at) + '</td>' +
|
|
118
|
+
'<td class="px-4 py-3 text-right"><button type="button" data-id="' + author.id + '" class="p-2 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/40 rounded"><span class="block h-4 w-4 i-hugeicons-waste"></span></button></td>' +
|
|
119
|
+
'</tr>'
|
|
120
|
+
}).join('')
|
|
121
|
+
}
|
|
122
|
+
async function load() {
|
|
123
|
+
var res = await fetch(api)
|
|
124
|
+
if (!res.ok) throw new Error('Unable to load CMS authors (' + res.status + ').')
|
|
125
|
+
var json = await res.json()
|
|
126
|
+
authors = Array.isArray(json.authors) ? json.authors : []
|
|
127
|
+
assignedPostCount = Number(json.assignedPostCount || 0)
|
|
128
|
+
render()
|
|
129
|
+
}
|
|
130
|
+
document.getElementById('new-author-button').addEventListener('click', function () {
|
|
131
|
+
$('author-name').value = ''
|
|
132
|
+
$('author-email').value = ''
|
|
133
|
+
dialog.showModal()
|
|
134
|
+
})
|
|
135
|
+
$('author-cancel').addEventListener('click', function () { dialog.close() })
|
|
136
|
+
document.getElementById('author-form').addEventListener('submit', async function (event) {
|
|
137
|
+
event.preventDefault()
|
|
138
|
+
var res = await fetch(api, {
|
|
139
|
+
method: 'POST',
|
|
140
|
+
headers: writeHeaders(),
|
|
141
|
+
body: JSON.stringify({ name: $('author-name').value, email: $('author-email').value }),
|
|
142
|
+
})
|
|
143
|
+
if (!res.ok) {
|
|
144
|
+
var err = await res.json().catch(function () { return {} })
|
|
145
|
+
alert(err.message || err.error || 'Could not create author')
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
dialog.close()
|
|
149
|
+
await load()
|
|
150
|
+
})
|
|
151
|
+
table.addEventListener('click', async function (event) {
|
|
152
|
+
var button = event.target.closest('button[data-id]')
|
|
153
|
+
if (!button || !confirm('Delete this author? Their posts are kept but left without an author.')) return
|
|
154
|
+
var res = await fetch(api + '/' + button.dataset.id, { method: 'DELETE', headers: writeHeaders() })
|
|
155
|
+
if (!res.ok) {
|
|
156
|
+
var err = await res.json().catch(function () { return {} })
|
|
157
|
+
alert(err.message || err.error || 'Could not delete author')
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
await load()
|
|
161
|
+
})
|
|
162
|
+
search.addEventListener('input', render)
|
|
163
|
+
|
|
164
|
+
load().catch(function (error) {
|
|
165
|
+
empty.classList.remove('hidden')
|
|
166
|
+
empty.textContent = error.message || 'Unable to load CMS authors.'
|
|
167
|
+
})
|
|
168
|
+
})()
|
|
169
|
+
</script>
|
|
170
|
+
|
|
171
|
+
@endsection
|
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
@extends('layouts/default')
|
|
2
|
+
|
|
3
|
+
@section('content')
|
|
4
|
+
@title('Dashboard - Blog')
|
|
5
|
+
<div class="p-6 min-h-screen text-neutral-950 dark:text-neutral-100 bg-neutral-50 dark:bg-neutral-950">
|
|
6
|
+
<div class="flex flex-col gap-6 mx-auto max-w-7xl">
|
|
7
|
+
|
|
8
|
+
<header class="flex flex-col gap-4 md:flex-row md:items-end md:justify-between pb-5 border-b border-neutral-200 dark:border-neutral-800">
|
|
9
|
+
<div>
|
|
10
|
+
<h1 class="font-semibold text-2xl tracking-tight">Blog</h1>
|
|
11
|
+
<p class="mt-1 text-neutral-500 text-sm dark:text-neutral-400">
|
|
12
|
+
Posts are markdown files in <code class="px-1 py-0.5 text-xs bg-neutral-200 dark:bg-neutral-800 rounded">content/blog</code>. Saving writes the file. Commit and push to publish.
|
|
13
|
+
</p>
|
|
14
|
+
</div>
|
|
15
|
+
<div class="flex flex-wrap gap-2">
|
|
16
|
+
<a href="/blog" target="_blank" rel="noopener" class="inline-flex gap-2 items-center px-3 py-2 font-medium text-neutral-800 text-sm dark:text-neutral-100 hover:bg-white dark:hover:bg-neutral-900 border border-neutral-300 rounded-md dark:border-neutral-700">
|
|
17
|
+
<span class="h-4 w-4 i-hugeicons-view"></span>
|
|
18
|
+
View blog
|
|
19
|
+
</a>
|
|
20
|
+
<button type="button" @click="openEditor(null)" class="inline-flex gap-2 items-center px-3 py-2 font-semibold text-sm text-white bg-blue-600 hover:bg-blue-500 rounded-md shadow-sm">
|
|
21
|
+
<span class="h-4 w-4 i-hugeicons-plus-sign"></span>
|
|
22
|
+
New post
|
|
23
|
+
</button>
|
|
24
|
+
</div>
|
|
25
|
+
</header>
|
|
26
|
+
|
|
27
|
+
<section class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
28
|
+
<div class="p-4 bg-white dark:bg-neutral-900 border border-neutral-200 rounded-md dark:border-neutral-800">
|
|
29
|
+
<p class="text-neutral-500 text-sm dark:text-neutral-400">Total</p>
|
|
30
|
+
<p class="mt-2 font-semibold text-3xl">{{ posts().length }}</p>
|
|
31
|
+
</div>
|
|
32
|
+
<div class="p-4 bg-white dark:bg-neutral-900 border border-neutral-200 rounded-md dark:border-neutral-800">
|
|
33
|
+
<p class="text-neutral-500 text-sm dark:text-neutral-400">Published</p>
|
|
34
|
+
<p class="mt-2 font-semibold text-3xl text-green-600 dark:text-green-400">{{ publishedCount() }}</p>
|
|
35
|
+
</div>
|
|
36
|
+
<div class="p-4 bg-white dark:bg-neutral-900 border border-neutral-200 rounded-md dark:border-neutral-800">
|
|
37
|
+
<p class="text-neutral-500 text-sm dark:text-neutral-400">Drafts</p>
|
|
38
|
+
<p class="mt-2 font-semibold text-3xl text-amber-600 dark:text-amber-400">{{ draftCount() }}</p>
|
|
39
|
+
</div>
|
|
40
|
+
<div class="p-4 bg-white dark:bg-neutral-900 border border-neutral-200 rounded-md dark:border-neutral-800">
|
|
41
|
+
<p class="text-neutral-500 text-sm dark:text-neutral-400">Featured</p>
|
|
42
|
+
<p class="mt-2 font-semibold text-3xl">{{ featuredCount() }}</p>
|
|
43
|
+
</div>
|
|
44
|
+
</section>
|
|
45
|
+
|
|
46
|
+
<section class="bg-white dark:bg-neutral-900 border border-neutral-200 rounded-md dark:border-neutral-800">
|
|
47
|
+
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between p-4 border-b border-neutral-200 dark:border-neutral-800">
|
|
48
|
+
<div class="relative flex-1 max-w-md">
|
|
49
|
+
<span class="absolute left-3 top-1/2 h-4 w-4 text-neutral-500 pointer-events-none -translate-y-1/2 i-hugeicons-search-01"></span>
|
|
50
|
+
<label for="blog-search" class="sr-only">Search posts</label>
|
|
51
|
+
<input
|
|
52
|
+
id="blog-search"
|
|
53
|
+
type="search"
|
|
54
|
+
x-model="searchQuery"
|
|
55
|
+
placeholder="Search posts"
|
|
56
|
+
class="placeholder-neutral-500 pl-9 pr-3 py-2 w-full text-neutral-900 text-sm dark:text-neutral-100 bg-white dark:bg-neutral-950 border border-neutral-300 outline-none rounded-md dark:border-neutral-700 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30"
|
|
57
|
+
/>
|
|
58
|
+
</div>
|
|
59
|
+
<div>
|
|
60
|
+
<label for="blog-status" class="sr-only">Filter by status</label>
|
|
61
|
+
<select
|
|
62
|
+
id="blog-status"
|
|
63
|
+
x-model="statusFilter"
|
|
64
|
+
class="px-3 py-2 text-neutral-900 text-sm dark:text-neutral-100 bg-white dark:bg-neutral-950 border border-neutral-300 rounded-md dark:border-neutral-700"
|
|
65
|
+
>
|
|
66
|
+
<option value="all">All statuses</option>
|
|
67
|
+
<option value="published">Published</option>
|
|
68
|
+
<option value="draft">Draft</option>
|
|
69
|
+
</select>
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<!-- Error: the list request failed. Offers a retry rather than a dead end. -->
|
|
74
|
+
<div x-show="loadError()" class="flex flex-col gap-3 items-center px-6 py-12 text-center">
|
|
75
|
+
<span class="h-8 w-8 text-red-500 i-hugeicons-alert-02"></span>
|
|
76
|
+
<p class="font-medium">Couldn't load posts</p>
|
|
77
|
+
<p class="max-w-md text-neutral-500 text-sm dark:text-neutral-400">{{ loadError() }}</p>
|
|
78
|
+
<button type="button" @click="load()" class="mt-1 px-3 py-2 font-medium text-neutral-800 text-sm dark:text-neutral-100 border border-neutral-300 rounded-md dark:border-neutral-700">
|
|
79
|
+
Try again
|
|
80
|
+
</button>
|
|
81
|
+
</div>
|
|
82
|
+
|
|
83
|
+
<!-- Loading: skeleton rows matching the table's shape. -->
|
|
84
|
+
<div x-show="isLoading()" class="p-4 space-y-3">
|
|
85
|
+
@foreach(skeletonRows as row)
|
|
86
|
+
<div class="flex gap-4 items-center animate-pulse">
|
|
87
|
+
<div class="flex-1 space-y-2">
|
|
88
|
+
<div class="h-3.5 w-1/3 bg-neutral-200 dark:bg-neutral-800 rounded"></div>
|
|
89
|
+
<div class="h-3 w-2/3 bg-neutral-200 dark:bg-neutral-800 rounded"></div>
|
|
90
|
+
</div>
|
|
91
|
+
<div class="h-5 w-16 bg-neutral-200 dark:bg-neutral-800 rounded-full"></div>
|
|
92
|
+
<div class="h-3 w-20 bg-neutral-200 dark:bg-neutral-800 rounded"></div>
|
|
93
|
+
</div>
|
|
94
|
+
@endforeach
|
|
95
|
+
</div>
|
|
96
|
+
|
|
97
|
+
<div x-show="!isLoading() && !loadError() && filteredPosts().length > 0" class="overflow-x-auto">
|
|
98
|
+
<table class="min-w-full text-sm divide-neutral-200 divide-y dark:divide-neutral-800">
|
|
99
|
+
<thead class="font-semibold text-left text-neutral-600 text-xs uppercase dark:text-neutral-300 bg-neutral-50 dark:bg-neutral-950/60">
|
|
100
|
+
<tr>
|
|
101
|
+
<th scope="col" class="px-4 py-3">Post</th>
|
|
102
|
+
<th scope="col" class="px-4 py-3">Status</th>
|
|
103
|
+
<th scope="col" class="px-4 py-3">Date</th>
|
|
104
|
+
<th scope="col" class="px-4 py-3">Length</th>
|
|
105
|
+
<th scope="col" class="px-4 py-3"><span class="sr-only">Actions</span></th>
|
|
106
|
+
</tr>
|
|
107
|
+
</thead>
|
|
108
|
+
<tbody class="divide-neutral-200 divide-y dark:divide-neutral-800">
|
|
109
|
+
<template :for="post in filteredPosts">
|
|
110
|
+
<tr class="hover:bg-neutral-50 dark:hover:bg-neutral-950/60">
|
|
111
|
+
<td class="px-4 py-3">
|
|
112
|
+
<div class="flex gap-2 items-center">
|
|
113
|
+
<span class="font-medium">{{ post.title }}</span>
|
|
114
|
+
<span x-show="post.featured" class="inline-flex px-1.5 py-0.5 font-medium text-[11px] text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900/40 rounded">Featured</span>
|
|
115
|
+
</div>
|
|
116
|
+
<div class="mt-1 max-w-xl text-neutral-500 text-xs truncate dark:text-neutral-400">{{ post.description || post.slug }}</div>
|
|
117
|
+
</td>
|
|
118
|
+
<td class="px-4 py-3">
|
|
119
|
+
<span :class="statusClass(post.draft)" class="inline-flex px-2 py-0.5 font-medium text-xs rounded-full">
|
|
120
|
+
{{ post.draft ? 'Draft' : 'Published' }}
|
|
121
|
+
</span>
|
|
122
|
+
</td>
|
|
123
|
+
<td class="px-4 py-3 text-neutral-600 dark:text-neutral-300">{{ formatDate(post.date) }}</td>
|
|
124
|
+
<td class="px-4 py-3 text-neutral-600 dark:text-neutral-300">{{ post.readingTime }} min read</td>
|
|
125
|
+
<td class="px-4 py-3">
|
|
126
|
+
<div class="flex gap-1 justify-end">
|
|
127
|
+
<a
|
|
128
|
+
x-show="!post.draft"
|
|
129
|
+
:href="'/blog/' + post.slug"
|
|
130
|
+
target="_blank"
|
|
131
|
+
rel="noopener"
|
|
132
|
+
:title="'View ' + post.title"
|
|
133
|
+
class="p-2 text-neutral-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded"
|
|
134
|
+
>
|
|
135
|
+
<span class="block h-4 w-4 i-hugeicons-view"></span>
|
|
136
|
+
</a>
|
|
137
|
+
<button type="button" @click="openEditor(post)" :title="'Edit ' + post.title" class="p-2 text-neutral-600 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded">
|
|
138
|
+
<span class="block h-4 w-4 i-hugeicons-edit-01"></span>
|
|
139
|
+
</button>
|
|
140
|
+
<button type="button" @click="askDelete(post)" :title="'Delete ' + post.title" class="p-2 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-950/40 rounded">
|
|
141
|
+
<span class="block h-4 w-4 i-hugeicons-waste"></span>
|
|
142
|
+
</button>
|
|
143
|
+
</div>
|
|
144
|
+
</td>
|
|
145
|
+
</tr>
|
|
146
|
+
</template>
|
|
147
|
+
</tbody>
|
|
148
|
+
</table>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<!-- Empty: distinguishes "no posts yet" from "nothing matched the filter". -->
|
|
152
|
+
<div x-show="!isLoading() && !loadError() && filteredPosts().length === 0" class="flex flex-col gap-3 items-center px-6 py-16 text-center">
|
|
153
|
+
<span class="h-8 w-8 text-neutral-400 i-hugeicons-quill-write-02"></span>
|
|
154
|
+
<p class="font-medium">{{ posts().length === 0 ? 'No posts yet' : 'No posts match your filters' }}</p>
|
|
155
|
+
<p class="max-w-sm text-neutral-500 text-sm dark:text-neutral-400">
|
|
156
|
+
{{ posts().length === 0 ? 'Your first post becomes a markdown file in content/blog.' : 'Try a different search term or status.' }}
|
|
157
|
+
</p>
|
|
158
|
+
<button x-show="posts().length === 0" type="button" @click="openEditor(null)" class="mt-1 px-3 py-2 font-semibold text-sm text-white bg-blue-600 hover:bg-blue-500 rounded-md">
|
|
159
|
+
Write your first post
|
|
160
|
+
</button>
|
|
161
|
+
<button x-show="posts().length > 0" type="button" @click="clearFilters()" class="mt-1 px-3 py-2 font-medium text-neutral-800 text-sm dark:text-neutral-100 border border-neutral-300 rounded-md dark:border-neutral-700">
|
|
162
|
+
Clear filters
|
|
163
|
+
</button>
|
|
164
|
+
</div>
|
|
165
|
+
</section>
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
<!-- Editor -->
|
|
169
|
+
<div x-show="isEditorOpen()" class="flex fixed inset-0 overflow-y-auto z-40 items-start justify-center p-4 sm:p-6">
|
|
170
|
+
<div class="fixed inset-0 bg-neutral-950/60" @click="closeEditor()"></div>
|
|
171
|
+
|
|
172
|
+
<div role="dialog" aria-modal="true" aria-labelledby="editor-title" class="relative z-10 my-8 max-w-3xl w-full bg-white dark:bg-neutral-900 border border-neutral-200 rounded-lg dark:border-neutral-800 shadow-xl">
|
|
173
|
+
<div class="flex items-center justify-between px-5 py-4 border-b border-neutral-200 dark:border-neutral-800">
|
|
174
|
+
<h2 id="editor-title" class="font-semibold text-lg">{{ editingSlug() ? 'Edit post' : 'New post' }}</h2>
|
|
175
|
+
<button type="button" @click="closeEditor()" aria-label="Close editor" class="p-2 text-neutral-500 hover:bg-neutral-100 dark:hover:bg-neutral-800 rounded">
|
|
176
|
+
<span class="block h-4 w-4 i-hugeicons-cancel-01"></span>
|
|
177
|
+
</button>
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
<div class="overflow-y-auto p-5 space-y-4 max-h-[70vh]">
|
|
181
|
+
<div x-show="formError()" class="flex gap-2 items-start px-3 py-2 text-red-800 text-sm dark:text-red-200 bg-red-50 dark:bg-red-950/40 border border-red-200 rounded-md dark:border-red-900">
|
|
182
|
+
<span class="shrink-0 mt-0.5 h-4 w-4 i-hugeicons-alert-02"></span>
|
|
183
|
+
<span>{{ formError() }}</span>
|
|
184
|
+
</div>
|
|
185
|
+
|
|
186
|
+
<div class="grid gap-4 sm:grid-cols-2">
|
|
187
|
+
<div class="flex flex-col gap-2 sm:col-span-2">
|
|
188
|
+
<label for="post-title" class="font-medium text-sm">Title</label>
|
|
189
|
+
<input id="post-title" type="text" x-model="formTitle" class="px-3 py-2 text-neutral-900 text-sm dark:text-neutral-100 bg-white dark:bg-neutral-950 border border-neutral-300 outline-none rounded-md dark:border-neutral-700 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30" />
|
|
190
|
+
</div>
|
|
191
|
+
|
|
192
|
+
<div class="flex flex-col gap-2 sm:col-span-2">
|
|
193
|
+
<label for="post-slug" class="font-medium text-sm">Slug</label>
|
|
194
|
+
<input id="post-slug" type="text" x-model="formSlug" :placeholder="suggestedSlug()" class="placeholder-neutral-500 px-3 py-2 font-mono text-neutral-900 text-sm dark:text-neutral-100 bg-white dark:bg-neutral-950 border border-neutral-300 outline-none rounded-md dark:border-neutral-700 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30" />
|
|
195
|
+
<p class="text-neutral-500 text-xs dark:text-neutral-400">The file name and public URL: /blog/{{ formSlug() || suggestedSlug() || 'your-post' }}</p>
|
|
196
|
+
</div>
|
|
197
|
+
|
|
198
|
+
<div class="flex flex-col gap-2 sm:col-span-2">
|
|
199
|
+
<label for="post-description" class="font-medium text-sm">Description</label>
|
|
200
|
+
<input id="post-description" type="text" x-model="formDescription" class="px-3 py-2 text-neutral-900 text-sm dark:text-neutral-100 bg-white dark:bg-neutral-950 border border-neutral-300 outline-none rounded-md dark:border-neutral-700 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30" />
|
|
201
|
+
<p class="text-neutral-500 text-xs dark:text-neutral-400">Used as the excerpt, the meta description, and the RSS summary.</p>
|
|
202
|
+
</div>
|
|
203
|
+
|
|
204
|
+
<div class="flex flex-col gap-2">
|
|
205
|
+
<label for="post-date" class="font-medium text-sm">Date</label>
|
|
206
|
+
<input id="post-date" type="date" x-model="formDate" class="px-3 py-2 text-neutral-900 text-sm dark:text-neutral-100 bg-white dark:bg-neutral-950 border border-neutral-300 outline-none rounded-md dark:border-neutral-700 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30" />
|
|
207
|
+
</div>
|
|
208
|
+
|
|
209
|
+
<div class="flex flex-col gap-2">
|
|
210
|
+
<label for="post-author" class="font-medium text-sm">Author</label>
|
|
211
|
+
<input id="post-author" type="text" x-model="formAuthor" class="px-3 py-2 text-neutral-900 text-sm dark:text-neutral-100 bg-white dark:bg-neutral-950 border border-neutral-300 outline-none rounded-md dark:border-neutral-700 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30" />
|
|
212
|
+
</div>
|
|
213
|
+
|
|
214
|
+
<div class="flex flex-col gap-2 sm:col-span-2">
|
|
215
|
+
<label for="post-poster" class="font-medium text-sm">Poster image</label>
|
|
216
|
+
<input id="post-poster" type="text" x-model="formPoster" placeholder="/assets/images/example.svg" class="placeholder-neutral-500 px-3 py-2 text-neutral-900 text-sm dark:text-neutral-100 bg-white dark:bg-neutral-950 border border-neutral-300 outline-none rounded-md dark:border-neutral-700 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30" />
|
|
217
|
+
</div>
|
|
218
|
+
|
|
219
|
+
<div class="flex flex-col gap-2 sm:col-span-2">
|
|
220
|
+
<label for="post-body" class="font-medium text-sm">Content</label>
|
|
221
|
+
<textarea id="post-body" x-model="formBody" rows="14" class="px-3 py-2 font-mono text-neutral-900 text-sm dark:text-neutral-100 bg-white dark:bg-neutral-950 border border-neutral-300 outline-none rounded-md dark:border-neutral-700 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/30 resize-y"></textarea>
|
|
222
|
+
<p class="text-neutral-500 text-xs dark:text-neutral-400">Markdown. {{ bodyWordCount() }} words, about {{ bodyReadingTime() }} min read.</p>
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
|
|
226
|
+
<div class="flex flex-col gap-3 sm:flex-row sm:gap-6 pt-2 border-neutral-200 border-t dark:border-neutral-800">
|
|
227
|
+
<label class="flex gap-2 items-center text-sm">
|
|
228
|
+
<input type="checkbox" x-model="formDraft" class="h-4 w-4 text-blue-600 border-neutral-300 rounded dark:border-neutral-600" />
|
|
229
|
+
<span>Draft, keep off the public blog</span>
|
|
230
|
+
</label>
|
|
231
|
+
<label class="flex gap-2 items-center text-sm">
|
|
232
|
+
<input type="checkbox" x-model="formFeatured" class="h-4 w-4 text-blue-600 border-neutral-300 rounded dark:border-neutral-600" />
|
|
233
|
+
<span>Feature at the top of the listing</span>
|
|
234
|
+
</label>
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
237
|
+
|
|
238
|
+
<div class="flex gap-2 justify-end px-5 py-4 border-neutral-200 border-t dark:border-neutral-800">
|
|
239
|
+
<button type="button" @click="closeEditor()" class="px-3 py-2 font-medium text-neutral-800 text-sm dark:text-neutral-100 border border-neutral-300 rounded-md dark:border-neutral-700">Cancel</button>
|
|
240
|
+
<button type="button" @click="submit()" :disabled="isSaving()" class="inline-flex gap-2 items-center px-3 py-2 font-semibold text-sm text-white bg-blue-600 hover:bg-blue-500 rounded-md disabled:opacity-60">
|
|
241
|
+
<span x-show="isSaving()" class="h-4 w-4 animate-spin i-hugeicons-loading-03"></span>
|
|
242
|
+
{{ isSaving() ? 'Saving' : 'Save post' }}
|
|
243
|
+
</button>
|
|
244
|
+
</div>
|
|
245
|
+
</div>
|
|
246
|
+
</div>
|
|
247
|
+
|
|
248
|
+
<!-- Delete confirmation -->
|
|
249
|
+
<div x-show="postToDelete()" class="flex fixed inset-0 z-50 items-center justify-center p-4">
|
|
250
|
+
<div class="fixed inset-0 bg-neutral-950/60" @click="cancelDelete()"></div>
|
|
251
|
+
|
|
252
|
+
<div role="dialog" aria-modal="true" aria-labelledby="delete-title" class="relative z-10 p-5 max-w-md w-full bg-white dark:bg-neutral-900 border border-neutral-200 rounded-lg dark:border-neutral-800 shadow-xl">
|
|
253
|
+
<h2 id="delete-title" class="font-semibold text-lg">Delete this post?</h2>
|
|
254
|
+
<p class="mt-2 text-neutral-600 text-sm dark:text-neutral-300">
|
|
255
|
+
This deletes the file <code class="px-1 py-0.5 text-xs bg-neutral-100 dark:bg-neutral-800 rounded">content/blog/{{ deleteTargetSlug() }}.md</code>. It stays in git history until you commit the deletion.
|
|
256
|
+
</p>
|
|
257
|
+
<div class="flex gap-2 justify-end mt-5">
|
|
258
|
+
<button type="button" @click="cancelDelete()" class="px-3 py-2 font-medium text-neutral-800 text-sm dark:text-neutral-100 border border-neutral-300 rounded-md dark:border-neutral-700">Cancel</button>
|
|
259
|
+
<button type="button" @click="confirmDelete()" class="px-3 py-2 font-semibold text-sm text-white bg-red-600 hover:bg-red-500 rounded-md">Delete post</button>
|
|
260
|
+
</div>
|
|
261
|
+
</div>
|
|
262
|
+
</div>
|
|
263
|
+
</div>
|
|
264
|
+
|
|
265
|
+
<script client>
|
|
266
|
+
// Plain async transport for /api/dashboard/blog, which reads and writes the
|
|
267
|
+
// markdown files in content/blog. It owns no state; the signals below do.
|
|
268
|
+
import { deleteBlogPost, fetchBlogPost, fetchBlogPosts, saveBlogPost } from '~/storage/framework/defaults/functions/blog'
|
|
269
|
+
|
|
270
|
+
// Fixed-length list purely to render the loading skeleton rows.
|
|
271
|
+
const skeletonRows = [1, 2, 3, 4]
|
|
272
|
+
|
|
273
|
+
const posts = state([])
|
|
274
|
+
const isLoading = state(true)
|
|
275
|
+
const isSaving = state(false)
|
|
276
|
+
const loadError = state('')
|
|
277
|
+
const searchQuery = state('')
|
|
278
|
+
const statusFilter = state('all')
|
|
279
|
+
|
|
280
|
+
// Editor state. `editingSlug` doubles as the mode flag: empty means "new post",
|
|
281
|
+
// and it holds the ORIGINAL slug so a slug edit is sent as a rename.
|
|
282
|
+
const isEditorOpen = state(false)
|
|
283
|
+
const editingSlug = state('')
|
|
284
|
+
const formError = state('')
|
|
285
|
+
const formTitle = state('')
|
|
286
|
+
const formSlug = state('')
|
|
287
|
+
const formDescription = state('')
|
|
288
|
+
const formDate = state('')
|
|
289
|
+
const formAuthor = state('')
|
|
290
|
+
const formPoster = state('')
|
|
291
|
+
const formBody = state('')
|
|
292
|
+
const formDraft = state(false)
|
|
293
|
+
const formFeatured = state(false)
|
|
294
|
+
|
|
295
|
+
const postToDelete = state(null)
|
|
296
|
+
|
|
297
|
+
// Read by the delete dialog's copy; kept as a derived signal so the template
|
|
298
|
+
// never has to reach through a possibly-null post.
|
|
299
|
+
const deleteTargetSlug = derived(() => {
|
|
300
|
+
const post = postToDelete()
|
|
301
|
+
|
|
302
|
+
return post ? post.slug : ''
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
const publishedCount = derived(() => posts().filter(p => !p.draft).length)
|
|
306
|
+
const draftCount = derived(() => posts().filter(p => p.draft).length)
|
|
307
|
+
const featuredCount = derived(() => posts().filter(p => p.featured).length)
|
|
308
|
+
|
|
309
|
+
const filteredPosts = derived(() => {
|
|
310
|
+
const q = searchQuery().trim().toLowerCase()
|
|
311
|
+
const status = statusFilter()
|
|
312
|
+
|
|
313
|
+
return posts().filter((post) => {
|
|
314
|
+
if (status === 'draft' && !post.draft)
|
|
315
|
+
return false
|
|
316
|
+
if (status === 'published' && post.draft)
|
|
317
|
+
return false
|
|
318
|
+
if (!q)
|
|
319
|
+
return true
|
|
320
|
+
|
|
321
|
+
return `${post.title} ${post.description} ${post.slug}`.toLowerCase().includes(q)
|
|
322
|
+
})
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
// Mirrors slugify() in blog-admin.ts so the placeholder matches what the server
|
|
326
|
+
// will actually write when the slug field is left empty.
|
|
327
|
+
function slugify(input) {
|
|
328
|
+
return String(input)
|
|
329
|
+
.toLowerCase()
|
|
330
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
331
|
+
.replace(/^-+|-+$/g, '')
|
|
332
|
+
.slice(0, 80)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const suggestedSlug = derived(() => slugify(formTitle()))
|
|
336
|
+
const bodyWordCount = derived(() => formBody().split(/\s+/).filter(Boolean).length)
|
|
337
|
+
const bodyReadingTime = derived(() => Math.max(1, Math.round(bodyWordCount() / 200)))
|
|
338
|
+
|
|
339
|
+
function statusClass(draft) {
|
|
340
|
+
return draft
|
|
341
|
+
? 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200'
|
|
342
|
+
: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-200'
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function formatDate(value) {
|
|
346
|
+
if (!value)
|
|
347
|
+
return 'No date'
|
|
348
|
+
|
|
349
|
+
const date = new Date(value)
|
|
350
|
+
|
|
351
|
+
return Number.isNaN(date.getTime())
|
|
352
|
+
? value
|
|
353
|
+
: date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function clearFilters() {
|
|
357
|
+
searchQuery.set('')
|
|
358
|
+
statusFilter.set('all')
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function openEditor(post) {
|
|
362
|
+
formError.set('')
|
|
363
|
+
editingSlug.set(post ? post.slug : '')
|
|
364
|
+
isEditorOpen.set(true)
|
|
365
|
+
|
|
366
|
+
if (!post) {
|
|
367
|
+
formTitle.set('')
|
|
368
|
+
formSlug.set('')
|
|
369
|
+
formDescription.set('')
|
|
370
|
+
formDate.set(new Date().toISOString().slice(0, 10))
|
|
371
|
+
formAuthor.set('')
|
|
372
|
+
formPoster.set('')
|
|
373
|
+
formBody.set('')
|
|
374
|
+
formDraft.set(true)
|
|
375
|
+
formFeatured.set(false)
|
|
376
|
+
return
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// The list omits the markdown body, so fetch the full post. Seed the fields
|
|
380
|
+
// from the list row first so the editor is never blank while that request
|
|
381
|
+
// is in flight.
|
|
382
|
+
formTitle.set(post.title)
|
|
383
|
+
formSlug.set(post.slug)
|
|
384
|
+
formDescription.set(post.description)
|
|
385
|
+
formDate.set(post.date)
|
|
386
|
+
formAuthor.set(post.author)
|
|
387
|
+
formPoster.set(post.poster)
|
|
388
|
+
formDraft.set(post.draft)
|
|
389
|
+
formFeatured.set(post.featured)
|
|
390
|
+
formBody.set('')
|
|
391
|
+
|
|
392
|
+
try {
|
|
393
|
+
const full = await fetchBlogPost(post.slug)
|
|
394
|
+
|
|
395
|
+
// Ignore a late response if the user already closed or switched posts.
|
|
396
|
+
if (isEditorOpen() && editingSlug() === post.slug)
|
|
397
|
+
formBody.set(full.body)
|
|
398
|
+
}
|
|
399
|
+
catch (error) {
|
|
400
|
+
formError.set(`Couldn't load this post's content: ${error.message}`)
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function closeEditor() {
|
|
405
|
+
isEditorOpen.set(false)
|
|
406
|
+
editingSlug.set('')
|
|
407
|
+
formError.set('')
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async function submit() {
|
|
411
|
+
if (!formTitle().trim()) {
|
|
412
|
+
formError.set('A title is required.')
|
|
413
|
+
return
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
isSaving.set(true)
|
|
417
|
+
formError.set('')
|
|
418
|
+
|
|
419
|
+
try {
|
|
420
|
+
const saved = await saveBlogPost({
|
|
421
|
+
slug: formSlug().trim() || suggestedSlug(),
|
|
422
|
+
title: formTitle().trim(),
|
|
423
|
+
description: formDescription().trim(),
|
|
424
|
+
date: formDate(),
|
|
425
|
+
author: formAuthor().trim(),
|
|
426
|
+
authorBio: '',
|
|
427
|
+
poster: formPoster().trim(),
|
|
428
|
+
featured: formFeatured(),
|
|
429
|
+
draft: formDraft(),
|
|
430
|
+
body: formBody(),
|
|
431
|
+
}, editingSlug())
|
|
432
|
+
|
|
433
|
+
const original = editingSlug()
|
|
434
|
+
|
|
435
|
+
posts.update(list => (original
|
|
436
|
+
? list.map(p => (p.slug === original ? saved : p))
|
|
437
|
+
: [saved, ...list]))
|
|
438
|
+
|
|
439
|
+
closeEditor()
|
|
440
|
+
}
|
|
441
|
+
catch (error) {
|
|
442
|
+
// Keep the editor open on failure so the author's work is not thrown away.
|
|
443
|
+
formError.set(error.message)
|
|
444
|
+
}
|
|
445
|
+
finally {
|
|
446
|
+
isSaving.set(false)
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function askDelete(post) {
|
|
451
|
+
postToDelete.set(post)
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function cancelDelete() {
|
|
455
|
+
postToDelete.set(null)
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async function confirmDelete() {
|
|
459
|
+
const post = postToDelete()
|
|
460
|
+
if (!post)
|
|
461
|
+
return
|
|
462
|
+
|
|
463
|
+
postToDelete.set(null)
|
|
464
|
+
|
|
465
|
+
try {
|
|
466
|
+
await deleteBlogPost(post.slug)
|
|
467
|
+
posts.update(list => list.filter(p => p.slug !== post.slug))
|
|
468
|
+
}
|
|
469
|
+
catch (error) {
|
|
470
|
+
loadError.set(`Couldn't delete "${post.title}": ${error.message}`)
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async function load() {
|
|
475
|
+
loadError.set('')
|
|
476
|
+
|
|
477
|
+
try {
|
|
478
|
+
const { posts: list } = await fetchBlogPosts()
|
|
479
|
+
posts.set(list)
|
|
480
|
+
}
|
|
481
|
+
catch (error) {
|
|
482
|
+
loadError.set(error.message)
|
|
483
|
+
}
|
|
484
|
+
finally {
|
|
485
|
+
isLoading.set(false)
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// onMount, not effect: load() writes the same `posts` signal the derived signals
|
|
490
|
+
// read, so an effect would re-trigger itself on every load.
|
|
491
|
+
onMount(load)
|
|
492
|
+
</script>
|
|
493
|
+
@endsection
|