Compare commits

..

No commits in common. "7aac1d5e516ed3abb47c8f601ba4f8893fd6c2d3" and "754580eb03ae7c12fe534539efef7a6d7a522574" have entirely different histories.

170 changed files with 237 additions and 31070 deletions

View file

@ -1,28 +0,0 @@
# Keep the build context lean and secrets out of the image. Next.js would
# happily read a baked-in .env at runtime — configuration must come from
# --env-file / -e instead.
.env*
!.env.example
env
node_modules
.next
uploads
test-results
playwright-report
coverage
*.tsbuildinfo
.git
.gitignore
.idea
.claude
.vscode
.DS_Store
Dockerfile
.dockerignore
docker-compose.yml
docker
deploy
README.md

View file

@ -1,20 +0,0 @@
# --- Database -------------------------------------------------------------
# Connection string used by the app, drizzle-kit, and the seed script.
# Matches the Postgres service in docker-compose.yml.
DATABASE_URL=postgresql://blog:blog@localhost:5434/blog
# Host port that docker-compose publishes Postgres on (container port 5432).
POSTGRES_PORT=5434
# --- Initial administrator --------------------------------------------------
# Read by `npm run db:seed`; only a scrypt hash of the password is stored.
# Re-run the seed after changing these to update the stored credentials.
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me-please
# --- Test databases (optional overrides) ------------------------------------
# Both databases are created automatically by docker/initdb on the first
# `docker compose up`. Unit/integration tests use TEST_DATABASE_URL and the
# Playwright suite uses E2E_DATABASE_URL; both are wiped on every run.
TEST_DATABASE_URL=postgresql://blog:blog@localhost:5434/blog_test
E2E_DATABASE_URL=postgresql://blog:blog@localhost:5434/blog_e2e

54
.gitignore vendored
View file

@ -1,54 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
.idea/
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (the template stays tracked)
.env*
!.env.example
/env
# local agent/tool state
/.claude/
# playwright
/test-results/
/playwright-report/
# runtime image uploads (see src/lib/uploads.ts)
/uploads/
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View file

@ -1,5 +0,0 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->

View file

@ -1 +0,0 @@
@AGENTS.md

View file

@ -1,54 +0,0 @@
# Production image for the blog. Build and run:
#
# docker build -t yap-blog .
# docker run -d --name yap-blog -p 3000:3000 --env-file .env \
# -v yap-blog-uploads:/app/uploads yap-blog
#
# Migrations and seeding are NOT part of the runtime image (they need dev
# dependencies); run them from the `tools` stage against the same database:
#
# docker build --target tools -t yap-blog-tools .
# docker run --rm --env-file .env yap-blog-tools npm run db:migrate
# docker run --rm --env-file .env yap-blog-tools npm run db:seed
#
# In .env, DATABASE_URL must be reachable FROM INSIDE the container —
# `localhost` there means the container itself, not the host.
# Node 24 to match development; its npm 11 is also what wrote
# package-lock.json (npm 10's `npm ci` rejects npm 11 lockfile layouts).
FROM node:24-alpine AS base
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
FROM base AS deps
COPY package.json package-lock.json ./
RUN npm ci
# Full source + dev dependencies: the build environment, also reused as the
# `tools` stage for drizzle-kit migrations and the seed script.
FROM deps AS tools
COPY . .
FROM tools AS builder
# The build needs no database — every route renders dynamically at request
# time — but importing src/db fail-fasts when DATABASE_URL is unset, so give
# it a placeholder. The pool connects lazily; nothing dials this address.
RUN NEXT_OUTPUT=standalone \
DATABASE_URL=postgresql://build:build@localhost:5432/placeholder \
npm run build
FROM base AS runner
ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 --ingroup nodejs nextjs
# Pre-create the uploads dir writable so a fresh named volume inherits
# ownership that the non-root server can write to.
RUN mkdir uploads && chown nextjs:nodejs uploads
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
VOLUME /app/uploads
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
CMD wget -qO /dev/null http://127.0.0.1:3000/ || exit 1
CMD ["node", "server.js"]

235
LICENSE Normal file
View file

@ -0,0 +1,235 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users.
When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software.
A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public.
The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version.
An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license.
The precise terms and conditions for copying, distribution and modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based on the Program.
To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work.
A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
The Corresponding Source for a work in source code form is that same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices".
c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
"Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph.
Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation.
If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found.
yap-blog
Copyright (C) 2026 matt
This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License along with this program. If not, see <http://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements.
You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see <http://www.gnu.org/licenses/>.

159
README.md
View file

@ -1,158 +1,3 @@
# Yap Blog — a small self-hosted blogging platform
# yap-blog
A self-hosted blogging platform built with **Next.js (App Router) + React + TypeScript**, backed by **PostgreSQL** via **Drizzle ORM**. One admin plus optional author accounts with granular permissions, a WYSIWYG editor with Markdown paste and typing shortcuts, moderated threaded comments, an RSS feed + sitemap + SEO metadata, JSON backup import/export, fifteen switchable themes, and twelve selectable body fonts — all managed from a session-authenticated admin area.
![Stack](https://img.shields.io/badge/Next.js-16-blue) ![DB](https://img.shields.io/badge/PostgreSQL-17-blue) ![ORM](https://img.shields.io/badge/Drizzle-0.45-blue)
## Quick start
Requirements: **Node.js ≥ 20** with **npm 11** (`package-lock.json` is written by npm 11, and npm 10's `npm ci` rejects its layout — Node 24 bundles the right npm), **Docker** with the compose plugin, and free local ports **5434** (Postgres) and **3000** (dev server) — both configurable.
```bash
# 1. Configuration (set ADMIN_PASSWORD to taste)
cp .env.example .env
# 2. Database
docker compose up -d
# 3. Dependencies
npm install
# 4. Schema + demo content (admin credentials come from .env)
npm run db:migrate
npm run db:seed
# 5. Go
npm run dev
```
Open <http://localhost:3000> for the public site and <http://localhost:3000/admin> for the admin area, signing in with the `ADMIN_USERNAME` / `ADMIN_PASSWORD` from your `.env`.
> The seed is idempotent: the admin password hash is refreshed on every run (re-run it after changing `ADMIN_PASSWORD`), site settings are created only if missing, and demo content is only inserted into an empty database. It creates ~51 published posts (7 handwritten showcases plus generated topic filler so listings paginate to ~11 pages), 2 drafts, 9 tags, and 3 static pages.
## Commands
| Command | Purpose |
| --- | --- |
| `npm run dev` | Development server on :3000 |
| `npm run build` | Production build |
| `npm start` | Serve the production build |
| `npm run lint` | ESLint |
| `npm run typecheck` | TypeScript compiler, no emit |
| `npm test` | Unit + integration tests (Vitest, uses the `blog_test` DB) |
| `npm run test:watch` | Vitest in watch mode |
| `npm run test:e2e` | Production build, then Playwright E2E (uses the `blog_e2e` DB) |
| `npm run test:all` | Both suites |
| `npm run db:generate` | Generate a new migration from schema changes |
| `npm run db:migrate` | Apply migrations |
| `npm run db:seed` | Seed admin, settings, and demo content |
| `npm run db:studio` | Drizzle Studio DB browser |
The `blog_test` and `blog_e2e` databases are created automatically the first time the Postgres volume initializes (`docker/initdb/`). Both are **wiped and re-migrated on every test run** — never point them at data you care about. If you created the volume with an older setup, recreate it with `docker compose down -v && docker compose up -d`.
## Routes
| Route | Purpose |
| --- | --- |
| `/` | Configured home page: all posts, one tag's posts, or a static page |
| `/posts` | All published posts, newest first, paginated (`?page=N`) |
| `/posts/[slug]` | One published post |
| `/tags/[slug]` | Published posts with that tag, paginated |
| `/pages/[slug]` | One published static page |
| `/feed.xml`, `/sitemap.xml`, `/robots.txt` | RSS feed and crawler metadata |
| `/admin` | Dashboard (auth required) |
| `/admin/login` | Sign in |
| `/admin/posts`, `/admin/posts/new`, `/admin/posts/[id]/edit`, `/admin/posts/[id]/preview` | Post management |
| `/admin/pages`, … | Static-page management (same shape as posts) |
| `/admin/comments` | Comment moderation queue |
| `/admin/users`, `/admin/users/new`, `/admin/users/[id]/edit` | Author accounts, per-tag posting rights, permissions (admin only) |
| `/admin/account` | Change your own password |
| `/admin/settings` | Site title & URL, header/footer text, theme, font, navigation, home-page mode, pagination & excerpt limits, import/export |
## Architecture
```
src/
├── app/ # Routes only — thin, no business logic
│ ├── (public)/ # Public site, wrapped in header/sidebar/footer chrome
│ │ ├── (home)/ # / with its own loading skeleton
│ │ └── posts/(list)/ # /posts with its own loading skeleton
│ ├── admin/login/ # Login (outside the guarded group)
│ └── admin/(panel)/ # Guarded admin area (layout + every page re-check auth)
├── actions/ # Server actions: auth, posts, pages, settings, preview
├── components/ # public/ and admin/ UI + shared primitives (ui.tsx)
├── db/ # Drizzle schema + connection pool
├── lib/
│ ├── auth/ # scrypt hashing, session store, cookie, DAL guards
│ ├── services/ # All database access (posts, tags, pages, settings, home)
│ └── … # slug, markdown, excerpt, pagination, validation, forms
├── drizzle/ # Generated SQL migrations (committed)
├── scripts/seed.ts # Idempotent seeding (also reused by the E2E setup)
└── tests/ # unit/ · integration/ · e2e/
```
### Key decisions
- **Server components + server actions, almost no API layer.** Public pages are React Server Components that call the service layer directly; admin mutations are server actions. The only route handlers are the ones a browser actually consumes as URLs — the RSS feed, sitemap/robots, image upload + serving, and the backup export download — and they sit on the same service layer as everything else.
- **A service layer owns all SQL.** Files under `src/lib/services/` are the only place queries live. Pages and actions stay thin, and the integration tests exercise the exact code paths production uses.
- **Everything renders dynamically** (`force-dynamic` in the root layout). All content is admin-editable at runtime, so pages read the DB per request — plenty fast for an MVP and never stale. The obvious next optimization is tag-based caching (`revalidateTag`) around settings/posts.
- **Auth: opaque session tokens, scrypt passwords.** Login verifies against a scrypt hash (Node's built-in crypto; parameters encoded per-hash so they can be raised later). Sessions are 32-byte random tokens in an `httpOnly` `SameSite=Lax` cookie; the database stores only the SHA-256 of the token, so a leaked DB dump cannot forge cookies. Expired sessions are treated as absent and purged on login. A failed login costs one scrypt derivation whether or not the username exists, avoiding a user-enumeration timing signal.
- **Protection is server-side at three layers**: the admin layout redirects, every admin page calls `requireAdmin()` (React-`cache()`d per request), and every mutating server action calls it again — an action invoked directly over HTTP without a valid session cookie redirects instead of mutating. There is deliberately no middleware-only check to rely on.
- **Rich text editing, HTML storage.** Posts and pages are written in a WordPress-style WYSIWYG editor (Tiptap/ProseMirror): headings, bold/italic/underline/strike, inline code, links, lists, blockquotes, code blocks, tables with row/column controls, horizontal rules, undo/redo — plus inline images uploaded straight from the editor (toolbar button, drag-drop, or paste). Pasting plain-text **Markdown converts automatically** through the same remark pipeline used for seeding; pasting rich HTML uses ProseMirror's native handling. Bodies are stored as HTML and pass through one shared `rehype-sanitize` allowlist (GitHub schema + `<u>`/table scaffolding) **both on save and on render** — scripts, event handlers, and `javascript:` URLs cannot survive either path, and the editor is WYSIWYG against the same `.markdown-body` styles the public site uses, so what you see is literally what publishes.
- **Slug policy.** Slugs are generated from titles (NFKD-normalized, diacritics stripped, hyphenated). A *generated* slug that collides is auto-suffixed (`-2`, `-3`, …); an *explicitly chosen* slug that collides is rejected with a field error — the author picked it, silently renaming it would surprise them. Uniqueness is also enforced by DB constraints, so a race between two requests ends in a caught constraint error, not a duplicate.
- **Deletion safety is in the schema.** `settings.home_tag_id` / `home_page_id` are `ON DELETE SET NULL` and nav items are `ON DELETE CASCADE` from their page, so deleting a tag or page featured on the home page or navigation degrades gracefully (home falls back to the post list; the nav item disappears). Unpublishing a page hides its nav items until it is republished. A `CHECK` constraint guarantees a nav item points at exactly one of URL/page.
- **HTTP 404 vs. streaming.** Slug routes (`/posts/[slug]`, `/tags/[slug]`, `/pages/[slug]`) have **no** loading boundary so unknown or draft content returns a real HTTP 404. The two listing routes have loading skeletons, which means an out-of-range `?page=` there streams a not-found UI with HTTP 200 plus a `noindex` meta tag (a "soft 404") — the deliberate trade-off for skeletons on the routes users actually wait on. Malformed pagination (`?page=abc`, `-1`, `1e9`) clamps to page 1.
- **Theming.** `globals.css` maps raw palette values to semantic tokens (`--background`, `--ink`, `--link`, …) and exposes those to Tailwind via `@theme inline`; components only ever use semantic utilities (`bg-surface`, `text-ink-strong`). The admin **Settings → Appearance** section switches the whole site (public + admin) between fifteen themes by stamping `data-theme` on `<html>`; each theme is nothing but a token override block. Dark: **Solarized Dark** (default), **Dracula**, **Nord**, **Gruvbox Dark**, **Catppuccin Mocha**, **Tokyo Night**, **One Dark**, **Rosé Pine**, **Everforest Dark**, **Monokai**, **White on Black**. Light: **Solarized Light**, **Catppuccin Latte**, **GitHub Light**, **Black & White**. The two mono themes are deliberately pure grayscale. Selection color, blockquote borders, badges, and the browser `theme-color` all derive from tokens, so new themes need no component work. Adding a theme = one CSS block, one enum value in `src/db/schema.ts` (+ generated migration), and one entry in the `src/lib/themes.ts` registry — the `Record<Theme, …>` type makes a missing entry a compile error.
- **Fonts.** The admin also picks a site-wide body font: sans — **Geist** (default), **Inter**, **Open Sans**, **Work Sans**, **Space Grotesk**, **Atkinson Hyperlegible**; serif — **Lora**, **Merriweather**, **Source Serif 4**, **EB Garamond**, **Playfair Display**; mono — **JetBrains Mono**. All are self-hosted via `next/font` (downloaded once at build time, no runtime Google requests). Only Geist is preloaded; the rest are declared `@font-face` rules the browser fetches solely when `data-font` on `<html>` makes one active. Tailwind's `font-sans` resolves through `--font-body`, which each `[data-font="…"]` block remaps. Code blocks always stay in Geist Mono. The settings form previews each option in its actual typeface.
- **Accessibility.** Semantic landmarks, labelled navs, a skip-to-content link, visible `:focus-visible` rings, `aria-invalid`/`aria-describedby` wiring on form errors, an `Escape`-closable mobile menu, and alt-text support (with an explicit "decorative" convention) on featured images. Destructive admin actions confirm before submitting.
### Images
**Inline images** are uploaded from the editor to `POST /api/admin/uploads` (auth-required, 8 MB cap, MIME allowlist: PNG/JPEG/WebP/GIF/AVIF — SVG deliberately excluded). Files land in `./uploads/` (gitignored) under random UUID names — client filenames never touch the filesystem — and are served by the `GET /uploads/[name]` route handler with immutable cache headers (Next only serves `public/` files that existed at build time, hence the route). The filename pattern is validated on read, ruling out path traversal. Alt text is editable per image via the toolbar's **Alt** button when an image is selected.
**Featured images** accept either a pasted URL or the same upload flow via the Upload button next to the field. Rendering uses a plain `<img loading="lazy">` with an error fallback ("image unavailable") instead of `next/image`, because arbitrary admin-supplied URLs would require a wildcard `remotePatterns`, which turns the image optimizer into an open proxy.
Moving to object storage later: point `saveUploadedImage` (src/lib/uploads.ts) at S3/R2/MinIO instead of the local directory and return the bucket URL — the endpoint, editor, and schema stay unchanged. Restricting `next/image` to that bucket's hostname would then restore image optimization.
### Error handling
Public and admin groups have scoped `not-found.tsx`; `error.tsx` shows a generic retry card (details stay in server logs, correlated by digest); `global-error.tsx` catches root-layout failures (e.g. DB down) with a self-contained page. Server actions return typed field/form errors — constraint violations and unexpected exceptions surface as friendly messages, never stack traces or connection strings. `generateMetadata` failures fall back to defaults rather than crashing the page.
## Testing
- **Unit** (`tests/unit/`): slugify + unique-slug suffixing, excerpt generation from stored HTML, both sanitizer pipelines (script stripping, event handlers, `javascript:` URLs, allowed editor marks), upload validation (MIME allowlist, size caps, filename generation), pagination parsing, URL validation, rate limiting (window rollover, per-key isolation, memory bound).
- **Integration** (`tests/integration/`, real Postgres): draft exclusion from public queries, reverse-chronological ordering and pagination, publish/unpublish `publishedAt` semantics, duplicate-slug handling on create/update, tag visibility and filtering, home-page mode fallbacks after deletion/unpublication, nav resolution, password hashing, session lifecycle, comment threading/moderation, author permissions, import/export round-trips.
- **E2E** (`tests/e2e/`, Playwright against a production build): admin routes redirect anonymously; bad credentials rejected; a full editorial flow — login → compose in the rich editor (heading + bold via toolbar) → publish → public listing/post/tag pages → draft 404s → logout locks the admin again; an editor-capabilities flow — markdown paste conversion, `<script>` stripped from pasted content, inline image upload through the toolbar, the uploaded file actually served, and the upload endpoint returning 401 anonymously; and an appearance flow (themes + fonts asserted via `data-*` attributes and computed styles).
```bash
npm test # unit + integration (~7s)
npm run test:e2e # build + 3 E2E scenarios (~1 min)
```
## Deploying
The app is a standard Next.js server (`npm run build` + `npm start`) plus PostgreSQL — a small VPS runs both. Checklist for going live:
1. **Postgres.** Point `DATABASE_URL` at a production database. The `blog`/`blog` credentials in `docker-compose.yml` are for local development — if you reuse the compose file on a server, change the password (and don't publish the port beyond localhost).
2. **Seed.** Set a strong `ADMIN_PASSWORD` in `.env`, then `npm run db:migrate && npm run db:seed`. Passwords can be changed later from **Admin → Account**.
3. **Run.** Two ready-made options:
- **systemd**`deploy/yap-blog.service` runs `npm start` as a dedicated locked-down user; setup commands are in the unit file's header comment.
- **Docker** — the `Dockerfile` builds a self-contained standalone image (non-root, uploads on a named volume, healthcheck). Build/run/migrate commands are in its header comment; migrations run from the `tools` build stage, since the runtime image has no dev dependencies.
4. **Reverse proxy + HTTPS.** Serve behind nginx/Caddy/Traefik with TLS — the session cookie is `Secure` in production, so plain HTTP logins will not stick. Make sure the proxy sets `X-Forwarded-For`; the login and comment rate limits key on it.
5. **Site URL.** Set **Admin → Settings → Site URL** (or the `SITE_URL` env var) so canonical URLs, the RSS feed, and the sitemap carry your real domain instead of localhost.
6. **Backups.** Back up Postgres and the `./uploads/` directory (inline images live there). The JSON export on the settings page covers content and settings, but not uploaded files.
## Out of scope (by design)
Public registration, search, analytics, and email are intentionally omitted.
## Known limitations
- Accounts are admin-created; there is no self-service password reset (the admin resets author passwords, and the admin password itself rotates via `.env` + re-seed).
- Login and comment rate limits are in-memory and per-IP: they assume a single app instance and a reverse proxy that sets `X-Forwarded-For` (exposed directly, all traffic shares one bucket).
- Every request hits the database (no caching layer yet — see the caching note above).
- Images are unoptimized `<img>` tags by design (see Images); uploads live on local disk, so a multi-instance deployment needs the object-storage swap described above.
- Upload validation trusts the declared MIME type (plus a strict extension map and SVG exclusion); magic-byte sniffing would be the next hardening step.
- Out-of-range pagination on listing routes is a soft 404 (real 404s everywhere else).
- `window.confirm` guards destructive actions and quick link/alt prompts; styled dialogs would be nicer.
- Bodies are stored as editor HTML. Content from databases seeded before this change (markdown source) renders as plain text — reseed demo databases rather than migrating them.
Lightweight open source blog platform

View file

@ -1,51 +0,0 @@
# systemd unit for running the blog directly on a server (no Docker).
#
# Setup, assuming the checkout lives at /opt/yap-blog:
#
# sudo useradd --system --home-dir /opt/yap-blog --shell /usr/sbin/nologin yap-blog
# cd /opt/yap-blog
# npm ci && npm run build # .env must hold the production DATABASE_URL
# npm run db:migrate && npm run db:seed
# sudo chown -R yap-blog:yap-blog /opt/yap-blog
# sudo cp deploy/yap-blog.service /etc/systemd/system/
# sudo systemctl daemon-reload
# sudo systemctl enable --now yap-blog
#
# After deploying new code: npm ci && npm run build && npm run db:migrate,
# then `sudo systemctl restart yap-blog`. Adjust the npm path in ExecStart
# if `which npm` says something else (e.g. a nodesource or nvm install).
[Unit]
Description=Yap Blog (Next.js)
Wants=network-online.target
After=network-online.target postgresql.service
[Service]
Type=simple
User=yap-blog
Group=yap-blog
WorkingDirectory=/opt/yap-blog
# `next start` runs in production mode and reads .env from the working
# directory. To keep secrets outside the checkout instead, delete .env and
# uncomment:
# EnvironmentFile=/etc/yap-blog/env
ExecStart=/usr/bin/npm start
Restart=on-failure
RestartSec=3
# The filesystem is read-only to the service except where it writes:
# uploaded images, and .next (Next.js keeps runtime caches/traces there).
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/opt/yap-blog/uploads /opt/yap-blog/.next
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
RestrictRealtime=true
LockPersonality=true
[Install]
WantedBy=multi-user.target

View file

@ -1,23 +0,0 @@
services:
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_USER: blog
POSTGRES_PASSWORD: blog
POSTGRES_DB: blog
ports:
# Host port is configurable because 5432/5433 are often taken by local installs.
- "${POSTGRES_PORT:-5434}:5432"
volumes:
- pgdata:/var/lib/postgresql/data
# Creates the blog_test and blog_e2e databases on first startup of a fresh volume.
- ./docker/initdb:/docker-entrypoint-initdb.d:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U blog -d blog"]
interval: 3s
timeout: 3s
retries: 20
volumes:
pgdata:

View file

@ -1,5 +0,0 @@
-- Extra databases used by the automated test suites.
-- This script only runs the first time the Postgres volume is created;
-- run `docker compose down -v && docker compose up -d` to recreate everything.
CREATE DATABASE blog_test OWNER blog;
CREATE DATABASE blog_e2e OWNER blog;

View file

@ -1,15 +0,0 @@
import "dotenv/config";
import { defineConfig } from "drizzle-kit";
if (!process.env.DATABASE_URL) {
throw new Error("DATABASE_URL is not set. Copy .env.example to .env first.");
}
export default defineConfig({
schema: "./src/db/schema.ts",
out: "./drizzle",
dialect: "postgresql",
dbCredentials: { url: process.env.DATABASE_URL },
strict: true,
verbose: true,
});

View file

@ -1,89 +0,0 @@
CREATE TYPE "public"."content_status" AS ENUM('draft', 'published');--> statement-breakpoint
CREATE TYPE "public"."home_mode" AS ENUM('posts', 'tag', 'page');--> statement-breakpoint
CREATE TABLE "nav_items" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "nav_items_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"label" text NOT NULL,
"url" text,
"page_id" integer,
"sort_order" integer DEFAULT 0 NOT NULL,
CONSTRAINT "nav_items_target_check" CHECK (("nav_items"."url" IS NULL) <> ("nav_items"."page_id" IS NULL))
);
--> statement-breakpoint
CREATE TABLE "pages" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "pages_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"title" text NOT NULL,
"slug" text NOT NULL,
"body" text DEFAULT '' NOT NULL,
"status" "content_status" DEFAULT 'draft' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "pages_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE "post_tags" (
"post_id" integer NOT NULL,
"tag_id" integer NOT NULL,
CONSTRAINT "post_tags_post_id_tag_id_pk" PRIMARY KEY("post_id","tag_id")
);
--> statement-breakpoint
CREATE TABLE "posts" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "posts_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"title" text NOT NULL,
"slug" text NOT NULL,
"body" text DEFAULT '' NOT NULL,
"author_name" text NOT NULL,
"featured_image_url" text,
"featured_image_alt" text,
"status" "content_status" DEFAULT 'draft' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"published_at" timestamp with time zone,
CONSTRAINT "posts_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE "sessions" (
"id" text PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "settings" (
"id" integer PRIMARY KEY NOT NULL,
"site_title" text DEFAULT 'My Blog' NOT NULL,
"header_text" text DEFAULT '' NOT NULL,
"footer_text" text DEFAULT '' NOT NULL,
"posts_per_page" integer DEFAULT 10 NOT NULL,
"excerpt_words" integer DEFAULT 40 NOT NULL,
"home_mode" "home_mode" DEFAULT 'posts' NOT NULL,
"home_tag_id" integer,
"home_page_id" integer,
CONSTRAINT "settings_single_row_check" CHECK ("settings"."id" = 1),
CONSTRAINT "settings_posts_per_page_check" CHECK ("settings"."posts_per_page" BETWEEN 1 AND 50),
CONSTRAINT "settings_excerpt_words_check" CHECK ("settings"."excerpt_words" BETWEEN 5 AND 200)
);
--> statement-breakpoint
CREATE TABLE "tags" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "tags_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"name" text NOT NULL,
"slug" text NOT NULL,
CONSTRAINT "tags_name_unique" UNIQUE("name"),
CONSTRAINT "tags_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE "users" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "users_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"username" text NOT NULL,
"password_hash" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "users_username_unique" UNIQUE("username")
);
--> statement-breakpoint
ALTER TABLE "nav_items" ADD CONSTRAINT "nav_items_page_id_pages_id_fk" FOREIGN KEY ("page_id") REFERENCES "public"."pages"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "post_tags" ADD CONSTRAINT "post_tags_post_id_posts_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."posts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "post_tags" ADD CONSTRAINT "post_tags_tag_id_tags_id_fk" FOREIGN KEY ("tag_id") REFERENCES "public"."tags"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "settings" ADD CONSTRAINT "settings_home_tag_id_tags_id_fk" FOREIGN KEY ("home_tag_id") REFERENCES "public"."tags"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "settings" ADD CONSTRAINT "settings_home_page_id_pages_id_fk" FOREIGN KEY ("home_page_id") REFERENCES "public"."pages"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "post_tags_tag_id_idx" ON "post_tags" USING btree ("tag_id");--> statement-breakpoint
CREATE INDEX "posts_status_published_at_idx" ON "posts" USING btree ("status","published_at");

View file

@ -1,2 +0,0 @@
CREATE TYPE "public"."theme" AS ENUM('solarized-dark', 'solarized-light');--> statement-breakpoint
ALTER TABLE "settings" ADD COLUMN "theme" "theme" DEFAULT 'solarized-dark' NOT NULL;

View file

@ -1,6 +0,0 @@
CREATE TYPE "public"."font" AS ENUM('geist', 'inter', 'lora', 'merriweather', 'jetbrains-mono');--> statement-breakpoint
ALTER TYPE "public"."theme" ADD VALUE 'dracula';--> statement-breakpoint
ALTER TYPE "public"."theme" ADD VALUE 'nord';--> statement-breakpoint
ALTER TYPE "public"."theme" ADD VALUE 'gruvbox-dark';--> statement-breakpoint
ALTER TYPE "public"."theme" ADD VALUE 'mono';--> statement-breakpoint
ALTER TABLE "settings" ADD COLUMN "font" "font" DEFAULT 'geist' NOT NULL;

View file

@ -1,16 +0,0 @@
ALTER TYPE "public"."font" ADD VALUE 'source-serif';--> statement-breakpoint
ALTER TYPE "public"."font" ADD VALUE 'eb-garamond';--> statement-breakpoint
ALTER TYPE "public"."font" ADD VALUE 'playfair-display';--> statement-breakpoint
ALTER TYPE "public"."font" ADD VALUE 'open-sans';--> statement-breakpoint
ALTER TYPE "public"."font" ADD VALUE 'work-sans';--> statement-breakpoint
ALTER TYPE "public"."font" ADD VALUE 'atkinson-hyperlegible';--> statement-breakpoint
ALTER TYPE "public"."font" ADD VALUE 'space-grotesk';--> statement-breakpoint
ALTER TYPE "public"."theme" ADD VALUE 'mono-dark';--> statement-breakpoint
ALTER TYPE "public"."theme" ADD VALUE 'catppuccin-mocha';--> statement-breakpoint
ALTER TYPE "public"."theme" ADD VALUE 'catppuccin-latte';--> statement-breakpoint
ALTER TYPE "public"."theme" ADD VALUE 'tokyo-night';--> statement-breakpoint
ALTER TYPE "public"."theme" ADD VALUE 'one-dark';--> statement-breakpoint
ALTER TYPE "public"."theme" ADD VALUE 'rose-pine';--> statement-breakpoint
ALTER TYPE "public"."theme" ADD VALUE 'everforest-dark';--> statement-breakpoint
ALTER TYPE "public"."theme" ADD VALUE 'monokai';--> statement-breakpoint
ALTER TYPE "public"."theme" ADD VALUE 'github-light';

View file

@ -1,18 +0,0 @@
CREATE TYPE "public"."comment_status" AS ENUM('pending', 'approved');--> statement-breakpoint
CREATE TABLE "comments" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "comments_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"post_id" integer NOT NULL,
"parent_id" integer,
"author_name" text NOT NULL,
"author_email" text NOT NULL,
"email_public" boolean DEFAULT false NOT NULL,
"body" text NOT NULL,
"status" "comment_status" DEFAULT 'pending' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "comments" ADD CONSTRAINT "comments_post_id_posts_id_fk" FOREIGN KEY ("post_id") REFERENCES "public"."posts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "comments" ADD CONSTRAINT "comments_parent_id_comments_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."comments"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "comments_post_id_status_idx" ON "comments" USING btree ("post_id","status");--> statement-breakpoint
CREATE INDEX "comments_parent_id_idx" ON "comments" USING btree ("parent_id");--> statement-breakpoint
CREATE INDEX "comments_status_idx" ON "comments" USING btree ("status");

View file

@ -1,17 +0,0 @@
CREATE TYPE "public"."user_role" AS ENUM('admin', 'author');--> statement-breakpoint
CREATE TABLE "user_tags" (
"user_id" integer NOT NULL,
"tag_id" integer NOT NULL,
CONSTRAINT "user_tags_user_id_tag_id_pk" PRIMARY KEY("user_id","tag_id")
);
--> statement-breakpoint
ALTER TABLE "posts" ADD COLUMN "author_id" integer;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "role" "user_role" DEFAULT 'author' NOT NULL;--> statement-breakpoint
ALTER TABLE "user_tags" ADD CONSTRAINT "user_tags_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "user_tags" ADD CONSTRAINT "user_tags_tag_id_tags_id_fk" FOREIGN KEY ("tag_id") REFERENCES "public"."tags"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "user_tags_tag_id_idx" ON "user_tags" USING btree ("tag_id");--> statement-breakpoint
ALTER TABLE "posts" ADD CONSTRAINT "posts_author_id_users_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
-- Backfill: every account that existed before roles was the admin account,
-- and every existing post was written by it.
UPDATE "users" SET "role" = 'admin';--> statement-breakpoint
UPDATE "posts" SET "author_id" = (SELECT "id" FROM "users" WHERE "role" = 'admin' ORDER BY "id" LIMIT 1) WHERE "author_id" IS NULL;

View file

@ -1,8 +0,0 @@
ALTER TABLE "users" ADD COLUMN "can_create_tags" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "can_publish_posts" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "can_unpublish_posts" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "can_delete_posts" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "can_approve_comments" boolean DEFAULT false NOT NULL;--> statement-breakpoint
-- Existing authors could publish and unpublish before permissions existed;
-- keep that behavior for accounts created under the old rules.
UPDATE "users" SET "can_publish_posts" = true, "can_unpublish_posts" = true WHERE "role" = 'author';

View file

@ -1 +0,0 @@
ALTER TABLE "settings" ADD COLUMN "site_url" text DEFAULT '' NOT NULL;

View file

@ -1,671 +0,0 @@
{
"id": "f05fd91c-fca1-471d-a118-3b9f1f253c82",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.nav_items": {
"name": "nav_items",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "nav_items_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": true
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"page_id": {
"name": "page_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
}
},
"indexes": {},
"foreignKeys": {
"nav_items_page_id_pages_id_fk": {
"name": "nav_items_page_id_pages_id_fk",
"tableFrom": "nav_items",
"tableTo": "pages",
"columnsFrom": [
"page_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"nav_items_target_check": {
"name": "nav_items_target_check",
"value": "(\"nav_items\".\"url\" IS NULL) <> (\"nav_items\".\"page_id\" IS NULL)"
}
},
"isRLSEnabled": false
},
"public.pages": {
"name": "pages",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "pages_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true
},
"body": {
"name": "body",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"status": {
"name": "status",
"type": "content_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'draft'"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"pages_slug_unique": {
"name": "pages_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.post_tags": {
"name": "post_tags",
"schema": "",
"columns": {
"post_id": {
"name": "post_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"tag_id": {
"name": "tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"post_tags_tag_id_idx": {
"name": "post_tags_tag_id_idx",
"columns": [
{
"expression": "tag_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"post_tags_post_id_posts_id_fk": {
"name": "post_tags_post_id_posts_id_fk",
"tableFrom": "post_tags",
"tableTo": "posts",
"columnsFrom": [
"post_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"post_tags_tag_id_tags_id_fk": {
"name": "post_tags_tag_id_tags_id_fk",
"tableFrom": "post_tags",
"tableTo": "tags",
"columnsFrom": [
"tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"post_tags_post_id_tag_id_pk": {
"name": "post_tags_post_id_tag_id_pk",
"columns": [
"post_id",
"tag_id"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.posts": {
"name": "posts",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "posts_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true
},
"body": {
"name": "body",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"author_name": {
"name": "author_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"featured_image_url": {
"name": "featured_image_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"featured_image_alt": {
"name": "featured_image_alt",
"type": "text",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "content_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'draft'"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"published_at": {
"name": "published_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"posts_status_published_at_idx": {
"name": "posts_status_published_at_idx",
"columns": [
{
"expression": "status",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "published_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"posts_slug_unique": {
"name": "posts_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sessions": {
"name": "sessions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.settings": {
"name": "settings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true
},
"site_title": {
"name": "site_title",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'My Blog'"
},
"header_text": {
"name": "header_text",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"footer_text": {
"name": "footer_text",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"posts_per_page": {
"name": "posts_per_page",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 10
},
"excerpt_words": {
"name": "excerpt_words",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 40
},
"home_mode": {
"name": "home_mode",
"type": "home_mode",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'posts'"
},
"home_tag_id": {
"name": "home_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"home_page_id": {
"name": "home_page_id",
"type": "integer",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"settings_home_tag_id_tags_id_fk": {
"name": "settings_home_tag_id_tags_id_fk",
"tableFrom": "settings",
"tableTo": "tags",
"columnsFrom": [
"home_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
},
"settings_home_page_id_pages_id_fk": {
"name": "settings_home_page_id_pages_id_fk",
"tableFrom": "settings",
"tableTo": "pages",
"columnsFrom": [
"home_page_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"settings_single_row_check": {
"name": "settings_single_row_check",
"value": "\"settings\".\"id\" = 1"
},
"settings_posts_per_page_check": {
"name": "settings_posts_per_page_check",
"value": "\"settings\".\"posts_per_page\" BETWEEN 1 AND 50"
},
"settings_excerpt_words_check": {
"name": "settings_excerpt_words_check",
"value": "\"settings\".\"excerpt_words\" BETWEEN 5 AND 200"
}
},
"isRLSEnabled": false
},
"public.tags": {
"name": "tags",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "tags_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"tags_name_unique": {
"name": "tags_name_unique",
"nullsNotDistinct": false,
"columns": [
"name"
]
},
"tags_slug_unique": {
"name": "tags_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "users_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_username_unique": {
"name": "users_username_unique",
"nullsNotDistinct": false,
"columns": [
"username"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.content_status": {
"name": "content_status",
"schema": "public",
"values": [
"draft",
"published"
]
},
"public.home_mode": {
"name": "home_mode",
"schema": "public",
"values": [
"posts",
"tag",
"page"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -1,687 +0,0 @@
{
"id": "292ee890-c58e-4035-8e11-cd68e8d49974",
"prevId": "f05fd91c-fca1-471d-a118-3b9f1f253c82",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.nav_items": {
"name": "nav_items",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "nav_items_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": true
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"page_id": {
"name": "page_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
}
},
"indexes": {},
"foreignKeys": {
"nav_items_page_id_pages_id_fk": {
"name": "nav_items_page_id_pages_id_fk",
"tableFrom": "nav_items",
"tableTo": "pages",
"columnsFrom": [
"page_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"nav_items_target_check": {
"name": "nav_items_target_check",
"value": "(\"nav_items\".\"url\" IS NULL) <> (\"nav_items\".\"page_id\" IS NULL)"
}
},
"isRLSEnabled": false
},
"public.pages": {
"name": "pages",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "pages_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true
},
"body": {
"name": "body",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"status": {
"name": "status",
"type": "content_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'draft'"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"pages_slug_unique": {
"name": "pages_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.post_tags": {
"name": "post_tags",
"schema": "",
"columns": {
"post_id": {
"name": "post_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"tag_id": {
"name": "tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"post_tags_tag_id_idx": {
"name": "post_tags_tag_id_idx",
"columns": [
{
"expression": "tag_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"post_tags_post_id_posts_id_fk": {
"name": "post_tags_post_id_posts_id_fk",
"tableFrom": "post_tags",
"tableTo": "posts",
"columnsFrom": [
"post_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"post_tags_tag_id_tags_id_fk": {
"name": "post_tags_tag_id_tags_id_fk",
"tableFrom": "post_tags",
"tableTo": "tags",
"columnsFrom": [
"tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"post_tags_post_id_tag_id_pk": {
"name": "post_tags_post_id_tag_id_pk",
"columns": [
"post_id",
"tag_id"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.posts": {
"name": "posts",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "posts_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true
},
"body": {
"name": "body",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"author_name": {
"name": "author_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"featured_image_url": {
"name": "featured_image_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"featured_image_alt": {
"name": "featured_image_alt",
"type": "text",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "content_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'draft'"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"published_at": {
"name": "published_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"posts_status_published_at_idx": {
"name": "posts_status_published_at_idx",
"columns": [
{
"expression": "status",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "published_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"posts_slug_unique": {
"name": "posts_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sessions": {
"name": "sessions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.settings": {
"name": "settings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true
},
"site_title": {
"name": "site_title",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'My Blog'"
},
"header_text": {
"name": "header_text",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"footer_text": {
"name": "footer_text",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"posts_per_page": {
"name": "posts_per_page",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 10
},
"excerpt_words": {
"name": "excerpt_words",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 40
},
"home_mode": {
"name": "home_mode",
"type": "home_mode",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'posts'"
},
"home_tag_id": {
"name": "home_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"home_page_id": {
"name": "home_page_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"theme": {
"name": "theme",
"type": "theme",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'solarized-dark'"
}
},
"indexes": {},
"foreignKeys": {
"settings_home_tag_id_tags_id_fk": {
"name": "settings_home_tag_id_tags_id_fk",
"tableFrom": "settings",
"tableTo": "tags",
"columnsFrom": [
"home_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
},
"settings_home_page_id_pages_id_fk": {
"name": "settings_home_page_id_pages_id_fk",
"tableFrom": "settings",
"tableTo": "pages",
"columnsFrom": [
"home_page_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"settings_single_row_check": {
"name": "settings_single_row_check",
"value": "\"settings\".\"id\" = 1"
},
"settings_posts_per_page_check": {
"name": "settings_posts_per_page_check",
"value": "\"settings\".\"posts_per_page\" BETWEEN 1 AND 50"
},
"settings_excerpt_words_check": {
"name": "settings_excerpt_words_check",
"value": "\"settings\".\"excerpt_words\" BETWEEN 5 AND 200"
}
},
"isRLSEnabled": false
},
"public.tags": {
"name": "tags",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "tags_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"tags_name_unique": {
"name": "tags_name_unique",
"nullsNotDistinct": false,
"columns": [
"name"
]
},
"tags_slug_unique": {
"name": "tags_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "users_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_username_unique": {
"name": "users_username_unique",
"nullsNotDistinct": false,
"columns": [
"username"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.content_status": {
"name": "content_status",
"schema": "public",
"values": [
"draft",
"published"
]
},
"public.home_mode": {
"name": "home_mode",
"schema": "public",
"values": [
"posts",
"tag",
"page"
]
},
"public.theme": {
"name": "theme",
"schema": "public",
"values": [
"solarized-dark",
"solarized-light"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -1,710 +0,0 @@
{
"id": "40b668fb-afde-426c-8368-80f1a624fd99",
"prevId": "292ee890-c58e-4035-8e11-cd68e8d49974",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.nav_items": {
"name": "nav_items",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "nav_items_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": true
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"page_id": {
"name": "page_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
}
},
"indexes": {},
"foreignKeys": {
"nav_items_page_id_pages_id_fk": {
"name": "nav_items_page_id_pages_id_fk",
"tableFrom": "nav_items",
"tableTo": "pages",
"columnsFrom": [
"page_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"nav_items_target_check": {
"name": "nav_items_target_check",
"value": "(\"nav_items\".\"url\" IS NULL) <> (\"nav_items\".\"page_id\" IS NULL)"
}
},
"isRLSEnabled": false
},
"public.pages": {
"name": "pages",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "pages_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true
},
"body": {
"name": "body",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"status": {
"name": "status",
"type": "content_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'draft'"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"pages_slug_unique": {
"name": "pages_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.post_tags": {
"name": "post_tags",
"schema": "",
"columns": {
"post_id": {
"name": "post_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"tag_id": {
"name": "tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"post_tags_tag_id_idx": {
"name": "post_tags_tag_id_idx",
"columns": [
{
"expression": "tag_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"post_tags_post_id_posts_id_fk": {
"name": "post_tags_post_id_posts_id_fk",
"tableFrom": "post_tags",
"tableTo": "posts",
"columnsFrom": [
"post_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"post_tags_tag_id_tags_id_fk": {
"name": "post_tags_tag_id_tags_id_fk",
"tableFrom": "post_tags",
"tableTo": "tags",
"columnsFrom": [
"tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"post_tags_post_id_tag_id_pk": {
"name": "post_tags_post_id_tag_id_pk",
"columns": [
"post_id",
"tag_id"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.posts": {
"name": "posts",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "posts_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true
},
"body": {
"name": "body",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"author_name": {
"name": "author_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"featured_image_url": {
"name": "featured_image_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"featured_image_alt": {
"name": "featured_image_alt",
"type": "text",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "content_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'draft'"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"published_at": {
"name": "published_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"posts_status_published_at_idx": {
"name": "posts_status_published_at_idx",
"columns": [
{
"expression": "status",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "published_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"posts_slug_unique": {
"name": "posts_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sessions": {
"name": "sessions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.settings": {
"name": "settings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true
},
"site_title": {
"name": "site_title",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'My Blog'"
},
"header_text": {
"name": "header_text",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"footer_text": {
"name": "footer_text",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"posts_per_page": {
"name": "posts_per_page",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 10
},
"excerpt_words": {
"name": "excerpt_words",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 40
},
"home_mode": {
"name": "home_mode",
"type": "home_mode",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'posts'"
},
"home_tag_id": {
"name": "home_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"home_page_id": {
"name": "home_page_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"theme": {
"name": "theme",
"type": "theme",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'solarized-dark'"
},
"font": {
"name": "font",
"type": "font",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'geist'"
}
},
"indexes": {},
"foreignKeys": {
"settings_home_tag_id_tags_id_fk": {
"name": "settings_home_tag_id_tags_id_fk",
"tableFrom": "settings",
"tableTo": "tags",
"columnsFrom": [
"home_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
},
"settings_home_page_id_pages_id_fk": {
"name": "settings_home_page_id_pages_id_fk",
"tableFrom": "settings",
"tableTo": "pages",
"columnsFrom": [
"home_page_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"settings_single_row_check": {
"name": "settings_single_row_check",
"value": "\"settings\".\"id\" = 1"
},
"settings_posts_per_page_check": {
"name": "settings_posts_per_page_check",
"value": "\"settings\".\"posts_per_page\" BETWEEN 1 AND 50"
},
"settings_excerpt_words_check": {
"name": "settings_excerpt_words_check",
"value": "\"settings\".\"excerpt_words\" BETWEEN 5 AND 200"
}
},
"isRLSEnabled": false
},
"public.tags": {
"name": "tags",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "tags_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"tags_name_unique": {
"name": "tags_name_unique",
"nullsNotDistinct": false,
"columns": [
"name"
]
},
"tags_slug_unique": {
"name": "tags_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "users_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_username_unique": {
"name": "users_username_unique",
"nullsNotDistinct": false,
"columns": [
"username"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.content_status": {
"name": "content_status",
"schema": "public",
"values": [
"draft",
"published"
]
},
"public.font": {
"name": "font",
"schema": "public",
"values": [
"geist",
"inter",
"lora",
"merriweather",
"jetbrains-mono"
]
},
"public.home_mode": {
"name": "home_mode",
"schema": "public",
"values": [
"posts",
"tag",
"page"
]
},
"public.theme": {
"name": "theme",
"schema": "public",
"values": [
"solarized-dark",
"solarized-light",
"dracula",
"nord",
"gruvbox-dark",
"mono"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -1,726 +0,0 @@
{
"id": "ad3939eb-f52e-4a81-8732-6bcede794ad2",
"prevId": "40b668fb-afde-426c-8368-80f1a624fd99",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.nav_items": {
"name": "nav_items",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "nav_items_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": true
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"page_id": {
"name": "page_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
}
},
"indexes": {},
"foreignKeys": {
"nav_items_page_id_pages_id_fk": {
"name": "nav_items_page_id_pages_id_fk",
"tableFrom": "nav_items",
"tableTo": "pages",
"columnsFrom": [
"page_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"nav_items_target_check": {
"name": "nav_items_target_check",
"value": "(\"nav_items\".\"url\" IS NULL) <> (\"nav_items\".\"page_id\" IS NULL)"
}
},
"isRLSEnabled": false
},
"public.pages": {
"name": "pages",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "pages_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true
},
"body": {
"name": "body",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"status": {
"name": "status",
"type": "content_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'draft'"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"pages_slug_unique": {
"name": "pages_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.post_tags": {
"name": "post_tags",
"schema": "",
"columns": {
"post_id": {
"name": "post_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"tag_id": {
"name": "tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"post_tags_tag_id_idx": {
"name": "post_tags_tag_id_idx",
"columns": [
{
"expression": "tag_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"post_tags_post_id_posts_id_fk": {
"name": "post_tags_post_id_posts_id_fk",
"tableFrom": "post_tags",
"tableTo": "posts",
"columnsFrom": [
"post_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"post_tags_tag_id_tags_id_fk": {
"name": "post_tags_tag_id_tags_id_fk",
"tableFrom": "post_tags",
"tableTo": "tags",
"columnsFrom": [
"tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"post_tags_post_id_tag_id_pk": {
"name": "post_tags_post_id_tag_id_pk",
"columns": [
"post_id",
"tag_id"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.posts": {
"name": "posts",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "posts_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true
},
"body": {
"name": "body",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"author_name": {
"name": "author_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"featured_image_url": {
"name": "featured_image_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"featured_image_alt": {
"name": "featured_image_alt",
"type": "text",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "content_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'draft'"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"published_at": {
"name": "published_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"posts_status_published_at_idx": {
"name": "posts_status_published_at_idx",
"columns": [
{
"expression": "status",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "published_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"posts_slug_unique": {
"name": "posts_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sessions": {
"name": "sessions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.settings": {
"name": "settings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true
},
"site_title": {
"name": "site_title",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'My Blog'"
},
"header_text": {
"name": "header_text",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"footer_text": {
"name": "footer_text",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"posts_per_page": {
"name": "posts_per_page",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 10
},
"excerpt_words": {
"name": "excerpt_words",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 40
},
"home_mode": {
"name": "home_mode",
"type": "home_mode",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'posts'"
},
"home_tag_id": {
"name": "home_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"home_page_id": {
"name": "home_page_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"theme": {
"name": "theme",
"type": "theme",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'solarized-dark'"
},
"font": {
"name": "font",
"type": "font",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'geist'"
}
},
"indexes": {},
"foreignKeys": {
"settings_home_tag_id_tags_id_fk": {
"name": "settings_home_tag_id_tags_id_fk",
"tableFrom": "settings",
"tableTo": "tags",
"columnsFrom": [
"home_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
},
"settings_home_page_id_pages_id_fk": {
"name": "settings_home_page_id_pages_id_fk",
"tableFrom": "settings",
"tableTo": "pages",
"columnsFrom": [
"home_page_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"settings_single_row_check": {
"name": "settings_single_row_check",
"value": "\"settings\".\"id\" = 1"
},
"settings_posts_per_page_check": {
"name": "settings_posts_per_page_check",
"value": "\"settings\".\"posts_per_page\" BETWEEN 1 AND 50"
},
"settings_excerpt_words_check": {
"name": "settings_excerpt_words_check",
"value": "\"settings\".\"excerpt_words\" BETWEEN 5 AND 200"
}
},
"isRLSEnabled": false
},
"public.tags": {
"name": "tags",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "tags_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"tags_name_unique": {
"name": "tags_name_unique",
"nullsNotDistinct": false,
"columns": [
"name"
]
},
"tags_slug_unique": {
"name": "tags_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "users_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_username_unique": {
"name": "users_username_unique",
"nullsNotDistinct": false,
"columns": [
"username"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.content_status": {
"name": "content_status",
"schema": "public",
"values": [
"draft",
"published"
]
},
"public.font": {
"name": "font",
"schema": "public",
"values": [
"geist",
"inter",
"lora",
"merriweather",
"jetbrains-mono",
"source-serif",
"eb-garamond",
"playfair-display",
"open-sans",
"work-sans",
"atkinson-hyperlegible",
"space-grotesk"
]
},
"public.home_mode": {
"name": "home_mode",
"schema": "public",
"values": [
"posts",
"tag",
"page"
]
},
"public.theme": {
"name": "theme",
"schema": "public",
"values": [
"solarized-dark",
"solarized-light",
"dracula",
"nord",
"gruvbox-dark",
"mono",
"mono-dark",
"catppuccin-mocha",
"catppuccin-latte",
"tokyo-night",
"one-dark",
"rose-pine",
"everforest-dark",
"monokai",
"github-light"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View file

@ -1,895 +0,0 @@
{
"id": "b1253e7e-39b6-4405-b99a-427b27e0266f",
"prevId": "ad3939eb-f52e-4a81-8732-6bcede794ad2",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.comments": {
"name": "comments",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "comments_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"post_id": {
"name": "post_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"parent_id": {
"name": "parent_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"author_name": {
"name": "author_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"author_email": {
"name": "author_email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_public": {
"name": "email_public",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"body": {
"name": "body",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "comment_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'pending'"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"comments_post_id_status_idx": {
"name": "comments_post_id_status_idx",
"columns": [
{
"expression": "post_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "status",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"comments_parent_id_idx": {
"name": "comments_parent_id_idx",
"columns": [
{
"expression": "parent_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"comments_status_idx": {
"name": "comments_status_idx",
"columns": [
{
"expression": "status",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"comments_post_id_posts_id_fk": {
"name": "comments_post_id_posts_id_fk",
"tableFrom": "comments",
"tableTo": "posts",
"columnsFrom": [
"post_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"comments_parent_id_comments_id_fk": {
"name": "comments_parent_id_comments_id_fk",
"tableFrom": "comments",
"tableTo": "comments",
"columnsFrom": [
"parent_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.nav_items": {
"name": "nav_items",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "nav_items_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": true
},
"url": {
"name": "url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"page_id": {
"name": "page_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
}
},
"indexes": {},
"foreignKeys": {
"nav_items_page_id_pages_id_fk": {
"name": "nav_items_page_id_pages_id_fk",
"tableFrom": "nav_items",
"tableTo": "pages",
"columnsFrom": [
"page_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"nav_items_target_check": {
"name": "nav_items_target_check",
"value": "(\"nav_items\".\"url\" IS NULL) <> (\"nav_items\".\"page_id\" IS NULL)"
}
},
"isRLSEnabled": false
},
"public.pages": {
"name": "pages",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "pages_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true
},
"body": {
"name": "body",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"status": {
"name": "status",
"type": "content_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'draft'"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"pages_slug_unique": {
"name": "pages_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.post_tags": {
"name": "post_tags",
"schema": "",
"columns": {
"post_id": {
"name": "post_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"tag_id": {
"name": "tag_id",
"type": "integer",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"post_tags_tag_id_idx": {
"name": "post_tags_tag_id_idx",
"columns": [
{
"expression": "tag_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {
"post_tags_post_id_posts_id_fk": {
"name": "post_tags_post_id_posts_id_fk",
"tableFrom": "post_tags",
"tableTo": "posts",
"columnsFrom": [
"post_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"post_tags_tag_id_tags_id_fk": {
"name": "post_tags_tag_id_tags_id_fk",
"tableFrom": "post_tags",
"tableTo": "tags",
"columnsFrom": [
"tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {
"post_tags_post_id_tag_id_pk": {
"name": "post_tags_post_id_tag_id_pk",
"columns": [
"post_id",
"tag_id"
]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.posts": {
"name": "posts",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "posts_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true
},
"body": {
"name": "body",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"author_name": {
"name": "author_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"featured_image_url": {
"name": "featured_image_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"featured_image_alt": {
"name": "featured_image_alt",
"type": "text",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "content_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'draft'"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"published_at": {
"name": "published_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"posts_status_published_at_idx": {
"name": "posts_status_published_at_idx",
"columns": [
{
"expression": "status",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "published_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"posts_slug_unique": {
"name": "posts_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.sessions": {
"name": "sessions",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"sessions_user_id_users_id_fk": {
"name": "sessions_user_id_users_id_fk",
"tableFrom": "sessions",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.settings": {
"name": "settings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true
},
"site_title": {
"name": "site_title",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'My Blog'"
},
"header_text": {
"name": "header_text",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"footer_text": {
"name": "footer_text",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"posts_per_page": {
"name": "posts_per_page",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 10
},
"excerpt_words": {
"name": "excerpt_words",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 40
},
"home_mode": {
"name": "home_mode",
"type": "home_mode",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'posts'"
},
"home_tag_id": {
"name": "home_tag_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"home_page_id": {
"name": "home_page_id",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"theme": {
"name": "theme",
"type": "theme",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'solarized-dark'"
},
"font": {
"name": "font",
"type": "font",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'geist'"
}
},
"indexes": {},
"foreignKeys": {
"settings_home_tag_id_tags_id_fk": {
"name": "settings_home_tag_id_tags_id_fk",
"tableFrom": "settings",
"tableTo": "tags",
"columnsFrom": [
"home_tag_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
},
"settings_home_page_id_pages_id_fk": {
"name": "settings_home_page_id_pages_id_fk",
"tableFrom": "settings",
"tableTo": "pages",
"columnsFrom": [
"home_page_id"
],
"columnsTo": [
"id"
],
"onDelete": "set null",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"settings_single_row_check": {
"name": "settings_single_row_check",
"value": "\"settings\".\"id\" = 1"
},
"settings_posts_per_page_check": {
"name": "settings_posts_per_page_check",
"value": "\"settings\".\"posts_per_page\" BETWEEN 1 AND 50"
},
"settings_excerpt_words_check": {
"name": "settings_excerpt_words_check",
"value": "\"settings\".\"excerpt_words\" BETWEEN 5 AND 200"
}
},
"isRLSEnabled": false
},
"public.tags": {
"name": "tags",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "tags_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"tags_name_unique": {
"name": "tags_name_unique",
"nullsNotDistinct": false,
"columns": [
"name"
]
},
"tags_slug_unique": {
"name": "tags_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "users_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"username": {
"name": "username",
"type": "text",
"primaryKey": false,
"notNull": true
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"users_username_unique": {
"name": "users_username_unique",
"nullsNotDistinct": false,
"columns": [
"username"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.comment_status": {
"name": "comment_status",
"schema": "public",
"values": [
"pending",
"approved"
]
},
"public.content_status": {
"name": "content_status",
"schema": "public",
"values": [
"draft",
"published"
]
},
"public.font": {
"name": "font",
"schema": "public",
"values": [
"geist",
"inter",
"lora",
"merriweather",
"jetbrains-mono",
"source-serif",
"eb-garamond",
"playfair-display",
"open-sans",
"work-sans",
"atkinson-hyperlegible",
"space-grotesk"
]
},
"public.home_mode": {
"name": "home_mode",
"schema": "public",
"values": [
"posts",
"tag",
"page"
]
},
"public.theme": {
"name": "theme",
"schema": "public",
"values": [
"solarized-dark",
"solarized-light",
"dracula",
"nord",
"gruvbox-dark",
"mono",
"mono-dark",
"catppuccin-mocha",
"catppuccin-latte",
"tokyo-night",
"one-dark",
"rose-pine",
"everforest-dark",
"monokai",
"github-light"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -1,62 +0,0 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1782951442209,
"tag": "0000_init",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1782954277640,
"tag": "0001_theme",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1783037024442,
"tag": "0002_themes-and-fonts",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1783037839786,
"tag": "0003_more-themes-fonts",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1783213112664,
"tag": "0004_comments",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1783214024801,
"tag": "0005_accounts",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1783266633118,
"tag": "0006_author-permissions",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1783271305140,
"tag": "0007_site-url",
"breakpoints": true
}
]
}

View file

@ -1,18 +0,0 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

View file

@ -1,19 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// Pin the workspace root so stray lockfiles in parent directories
// don't confuse Turbopack's project detection.
turbopack: { root: __dirname },
// The Docker image runs the self-contained .next/standalone server.
// Gated behind an env var because `next start` (systemd/local) refuses
// to run a build produced with output: "standalone".
...(process.env.NEXT_OUTPUT === "standalone" ? { output: "standalone" as const } : {}),
experimental: {
// Site-import uploads carry a whole backup in one action request;
// the default 1 MB cap is far too small. Imports themselves are
// capped at 20 MB in importSiteAction.
serverActions: { bodySizeLimit: "25mb" },
},
};
export default nextConfig;

11714
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,60 +0,0 @@
{
"name": "yap-blog",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"typecheck": "next typegen && tsc --noEmit -p tsconfig.typecheck.json",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:seed": "tsx scripts/seed.ts",
"db:studio": "drizzle-kit studio",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "next build && tsx tests/e2e/setup-db.ts && playwright test",
"test:all": "npm run test && npm run test:e2e"
},
"dependencies": {
"@tiptap/extension-image": "^3.27.1",
"@tiptap/extension-placeholder": "^3.27.1",
"@tiptap/extension-table": "^3.27.1",
"@tiptap/pm": "^3.27.1",
"@tiptap/react": "^3.27.1",
"@tiptap/starter-kit": "^3.27.1",
"dotenv": "^17.4.2",
"drizzle-orm": "^0.45.2",
"hast-util-to-text": "^4.0.2",
"next": "16.2.10",
"pg": "^8.22.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"rehype-parse": "^9.0.1",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"rehype-stringify": "^10.0.1",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"remark-rehype": "^11.1.2",
"unified": "^11.0.5",
"unist-util-visit": "^5.1.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/pg": "^8.20.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"drizzle-kit": "^0.31.10",
"eslint": "^9",
"eslint-config-next": "16.2.10",
"tailwindcss": "^4",
"tsx": "^4.22.4",
"typescript": "^5",
"vitest": "^4.1.9"
}
}

View file

@ -1,30 +0,0 @@
import "dotenv/config";
import { defineConfig, devices } from "@playwright/test";
const E2E_DATABASE_URL =
process.env.E2E_DATABASE_URL || "postgresql://blog:blog@localhost:5434/blog_e2e";
const PORT = 3100;
export default defineConfig({
testDir: "./tests/e2e",
fullyParallel: false,
workers: 1,
reporter: [["list"]],
timeout: 60_000,
use: {
baseURL: `http://localhost:${PORT}`,
trace: "retain-on-failure",
},
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
webServer: {
// Requires a prior `next build`; `npm run test:e2e` chains both.
command: `npx next start --port ${PORT}`,
url: `http://localhost:${PORT}/posts`,
reuseExistingServer: false,
timeout: 60_000,
env: {
...(process.env as Record<string, string>),
DATABASE_URL: E2E_DATABASE_URL,
},
},
});

View file

@ -1,7 +0,0 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View file

@ -1,766 +0,0 @@
import "dotenv/config";
import { pathToFileURL } from "node:url";
import { count } from "drizzle-orm";
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import {
navItems,
pages,
postTags,
posts,
settings,
tags,
users,
} from "../src/db/schema";
import { hashPassword } from "../src/lib/auth/password";
// Bodies below are authored in Markdown for maintainability, but the
// database stores editor HTML — convert at insert time.
import { renderMarkdown } from "../src/lib/markdown";
import { slugify } from "../src/lib/slug";
const POST_BODIES = {
hello: `Welcome to **Yap Blog**, a small blog that runs on Next.js, PostgreSQL, and Drizzle ORM.
This post exists so the front page is not empty on first boot. Log in at [/admin](/admin) to write your own, or delete everything here and start fresh.
## What you can do
- Write posts in Markdown with a live preview
- Organize them with tags
- Publish static pages and pin them to the navigation
- Point the home page at the post list, a tag, or a page
> The best time to start a blog was ten years ago. The second-best time is tonight, after dark, in base03.
Happy writing!`,
solarized: `Every terminal eventually goes through a phase. Mine never left it.
[Solarized](https://ethanschoonover.com/solarized/) is a sixteen-color palette designed by Ethan Schoonover with *fixed contrast relationships* — the light and dark variants share the same four accent-friendly content tones, so switching themes never changes how loud your text feels.
## The dark half
| Name | Hex | Role |
| ------ | --------- | ----------------------- |
| base03 | \`#002b36\` | background |
| base02 | \`#073642\` | highlighted background |
| base01 | \`#586e75\` | secondary text |
| base0 | \`#839496\` | body text |
| base1 | \`#93a1a1\` | emphasized text |
The trick is that nothing is ever pure black or pure white. The background is a deep blue-green lagoon, and the text hovers above it like fog.
## Why it survives
Fashion cycles through editor themes the way it cycles through denim. Solarized persists because it was *engineered*, not just picked: every pair of tones was checked for perceptual contrast on calibrated displays in both CIELAB and by tired human eyes at 2 a.m.
This blog wears it out of gratitude.`,
markdown: `Everything on this site is written in Markdown and rendered server-side through a sanitizing pipeline. This post is the kitchen sink that proves it.
## Text
Plain paragraphs, **bold**, *italics*, ~~strikethrough~~, and \`inline code\` all work. So do [links](https://www.markdownguide.org/) and footnote-ish parentheticals (like this one).
## Lists
1. Ordered lists
2. With multiple items
- And nested bullets
- Like these
## Code
\`\`\`ts
export function slugify(input: string): string {
return input
.normalize("NFKD")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
\`\`\`
## Quotes and rules
> A blockquote, styled with a Solarized comment-tone border.
---
## Tables
| Feature | Supported |
| --------- | --------- |
| GFM tables | yes |
| Task lists | mostly |
## What does *not* work
Raw \`<script>\` tags are stripped by the sanitizer, event handlers never survive, and \`javascript:\` URLs are removed. Try it in the editor preview — the pipeline is identical.`,
drizzle: `Drizzle sits in a comfortable middle ground: more structure than raw SQL strings, far less machinery than a heavyweight ORM.
## Schema as the source of truth
The whole database lives in one TypeScript file. Columns, enums, foreign keys, check constraints all plain declarations that \`drizzle-kit generate\` turns into versioned SQL migrations.
\`\`\`ts
export const posts = pgTable("posts", {
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
slug: text("slug").notNull().unique(),
status: contentStatusEnum("status").notNull().default("draft"),
});
\`\`\`
## Queries that read like SQL
\`\`\`ts
db.select()
.from(posts)
.where(eq(posts.status, "published"))
.orderBy(desc(posts.publishedAt));
\`\`\`
No magic query builder dialect to memorize if you can read SQL, you can read this. And because every column is typed, renaming one breaks the build instead of production.`,
keyboard: `A blog admin you cannot drive from the keyboard is a blog you will slowly stop using.
## Small things that compound
- Every control on this site is reachable with <kbd>Tab</kbd> and visible when focused the focus ring is Solarized blue, two pixels, unmissable.
- The mobile menu closes on <kbd>Escape</kbd>.
- Destructive buttons ask for confirmation in a dialog the keyboard already owns.
- The first thing focus lands on after the page loads is a *skip to content* link.
## Semantic HTML is most of the work
Screen readers and keyboards both navigate by landmarks: \`<header>\`, \`<nav>\`, \`<main>\`, \`<aside>\`, \`<footer>\`. Get those right, label the navs, and half of accessibility falls out for free.
The other half is discipline about focus and contrast, which conveniently Solarized already solved.`,
postgres: `PostgreSQL is the only database this blog will ever need, and probably the only one you need too.
## Constraints are features
The schema pushes invariants into the database itself:
- \`UNIQUE\` on every slug — duplicate URLs are impossible, even under race conditions
- \`CHECK ((url IS NULL) <> (page_id IS NULL))\` — a navigation item points at exactly one thing
- \`ON DELETE SET NULL\` — deleting the tag your home page features cannot break the site
## The nice-to-haves you get for free
Transactional DDL means migrations either fully apply or fully roll back. \`TRUNCATE ... RESTART IDENTITY CASCADE\` resets the test database in one statement. And \`GENERATED ALWAYS AS IDENTITY\` ends the serial-vs-sequence confusion forever.
Boring technology, chosen deliberately, is a superpower.`,
writing: `The hardest part of maintaining a blog is not the software. It is the sitting down.
## Lower the activation energy
This is why the editor here does so little: a title, a textarea, a preview button. No blocks to arrange, no toolbar to negotiate with. The distance between *having a thought* and *publishing it* is four form fields.
## Drafts are a promise to yourself
Half-finished ideas go in as drafts. They never appear publicly not on the front page, not in tag listings, not in the sidebar counts but they sit in the admin list, quietly accusing, until you finish them.
Write badly, publish anyway, revise tomorrow. The edit button forgives everything.`,
draftIdeas: `Rough backlog — do not publish.
- [ ] Post about the session model (hashed tokens, 7-day expiry)
- [ ] Compare rehype-sanitize schemas
- [ ] Dark/light theme toggle using the semantic token layer
- [ ] Benchmark: how many posts before pagination matters?`,
draftSecret: `This draft exists purely so the test suite can verify that draft posts and their tags never leak onto the public site.
If you can read this without being logged in, something is very wrong.`,
} as const;
const PAGE_BODIES = {
about: `**Yap Blog** is a demonstration blog for a small, self-hosted publishing platform built with Next.js, PostgreSQL, and Drizzle ORM.
## The stack
- **Next.js App Router** server components and server actions, no client-side data fetching
- **PostgreSQL** one normalized schema, real constraints
- **Drizzle ORM** typed schema, generated SQL migrations
- **Solarized Dark** the only correct terminal palette, now on the web
## The author
The administrator account on this instance is created from environment variables at seed time. Only a scrypt hash of the password ever touches the database.
Want one of these yourself? Clone the repository, run \`docker compose up -d\`, and follow the README.`,
colophon: `This site is set in **Geist** with code in **Geist Mono**, colored exclusively with the sixteen Solarized values, and served by a single Next.js process talking to a single PostgreSQL database.
No analytics, no trackers, no cookies except the one that keeps the admin signed in.
Pages like this one are written in Markdown in the admin area and can be linked from the top navigation. Unpublish a page and any navigation item pointing at it vanishes until it returns.`,
roadmap: `Unpublished scratchpad for future work:
1. Image uploads to object storage
2. RSS feed
3. Full-text search with \`tsvector\`
4. A second theme to prove the token layer works`,
} as const;
function daysAgo(days: number): Date {
return new Date(Date.now() - days * 24 * 60 * 60 * 1000);
}
/*
* Bulk demo posts
* ---------------
* The handful of posts above are handwritten showcases; the ~44 below are
* generated filler so listings paginate realistically (~11 pages at the
* seeded 5 posts/page). Each post gets a real title and a body assembled
* deterministically from its topic's paragraph pool, so posts within a
* topic share prose but no two bodies are identical.
*/
type BulkTopic = {
tagSlugs: string[];
intros: string[];
sections: Array<{ heading: string; body: string }>;
closers: string[];
code?: string;
};
const BULK_TOPICS: Record<string, BulkTopic> = {
design: {
tagSlugs: ["design"],
intros: [
"Most interface problems are not creativity problems. They are restraint problems.",
"You can learn a lot about a design system by looking at what it forbids.",
],
sections: [
{
heading: "Start from the reading experience",
body: "A blog is a reading machine. Line length around seventy characters, generous leading, and a type scale with only a few stops will do more for the page than any amount of decoration. Everything else is negotiable; the paragraph is not.",
},
{
heading: "Constraints make consistency cheap",
body: "When the palette is sixteen colors and the spacing scale has eight steps, most decisions are already made. The remaining ones are small enough to make quickly and reverse painlessly. That is the entire trick behind design tokens.",
},
{
heading: "Polish is mostly alignment",
body: "If two edges almost line up, make them line up. If two grays are almost the same, make them the same. A screen full of *almosts* reads as sloppy even when nobody can say why, and a screen full of exact matches reads as intentional.",
},
{
heading: "Design for the second visit",
body: "First impressions matter less than the hundredth impression. Navigation that never moves, headers that never surprise, and links that always look like links are boring on day one and priceless on day ninety.",
},
],
closers: [
"None of this requires taste. It requires deciding once and then refusing to redecide every week.",
"Boring, applied consistently, compounds into beautiful.",
],
},
typescript: {
tagSlugs: ["typescript"],
intros: [
"The compiler is the cheapest reviewer you will ever hire.",
"Types are documentation that cannot drift out of date.",
],
sections: [
{
heading: "Model states, not fields",
body: "A form that is either *editing*, *saving*, or *failed* should be a union of three shapes, not five booleans that can contradict each other. Once illegal states cannot be represented, half the defensive code deletes itself.",
},
{
heading: "Let inference do the typing",
body: "Annotate the boundaries — function arguments, module exports, API responses — and let inference handle everything in between. Code with type noise on every line is as hard to read as code with none.",
},
{
heading: "Parse at the edges",
body: "Data that enters the system through a form, a request, or an environment variable gets parsed once, immediately, into a known shape. Everything downstream then works with honest types instead of optimistic assertions.",
},
{
heading: "Strictness is a one-way door",
body: "Turning strict mode on late in a project is a week of archaeology. Turning it on from the first commit costs nothing. There is no third option where it stays off and the codebase stays healthy.",
},
],
closers: [
"The goal is not type gymnastics. The goal is deleting the tests you no longer need.",
"Every `any` is a small loan against future debugging time, at a terrible interest rate.",
],
code: '```ts\ntype SaveState =\n | { status: "editing" }\n | { status: "saving" }\n | { status: "failed"; error: string };\n```',
},
postgres: {
tagSlugs: ["postgresql"],
intros: [
"The database outlives every framework that talks to it.",
"Ask the database to enforce the rule, and it will never forget to.",
],
sections: [
{
heading: "Constraints beat conventions",
body: "A unique index does not care that two requests arrived in the same millisecond. A check constraint does not care that a new teammate skipped the onboarding doc. Rules that live in the schema are the only rules that hold under concurrency.",
},
{
heading: "EXPLAIN before you optimize",
body: "Most slow queries are slow for one boring reason: a sequential scan that should be an index scan. Reading the plan takes a minute; guessing takes an afternoon and usually lands on the wrong fix.",
},
{
heading: "Migrations are code review for your data model",
body: "Generated SQL sitting in a diff is the moment to catch the nullable column that should not be nullable. Once it ships, the mistake acquires rows, and rows have gravity.",
},
{
heading: "Use fewer databases than you think you need",
body: "Postgres will happily be your queue, your cache, your search index, and your JSON store while your project earns the traffic that justifies specialized tools. One backup, one connection string, one thing to learn deeply.",
},
],
closers: [
"Boring technology is a compliment, and Postgres is the most complimented software alive.",
"Data quality is not a cleanup task. It is a schema design decision from day one.",
],
code: "```sql\nALTER TABLE posts\n ADD CONSTRAINT posts_slug_format\n CHECK (slug ~ '^[a-z0-9]+(-[a-z0-9]+)*$');\n```",
},
nextjs: {
tagSlugs: ["nextjs"],
intros: [
"The server is a better place for most of the work than we spent a decade pretending it was.",
"Every kilobyte of JavaScript you do not ship is a feature.",
],
sections: [
{
heading: "Server components change the default",
body: "Data fetching next to rendering, no client bundle cost, no loading spinner choreography. The client is reserved for the parts that are genuinely interactive, which in a blog is a menu button and a couple of forms.",
},
{
heading: "Server actions are just functions",
body: "A mutation is a typed function call that happens to cross the network. No endpoint naming committee, no JSON envelope bikeshed, no client-side fetch wrapper. Validate at the top, authorize before anything else, return field errors as data.",
},
{
heading: "Layouts are an ownership boundary",
body: "The chrome fetches what the chrome needs; the page fetches what the page needs. Route groups let two trees share a URL space without sharing chrome, which is exactly how an admin panel wants to live inside a public site.",
},
{
heading: "Streaming needs a status-code budget",
body: "The moment the shell flushes, the status code is spent. Routes that can 404 should resolve before streaming begins; routes that never 404 can stream skeletons freely. Decide per route, not per app.",
},
],
closers: [
"The mental model is old: render on the server, enhance where needed. It just has good tooling now.",
"Fewer moving parts on the client means fewer places for the bug to hide.",
],
code: '```tsx\nexport default async function Page() {\n const posts = await listPublishedPosts({ page: 1, perPage: 10 });\n return <PostList posts={posts.items} />;\n}\n```',
},
writing: {
tagSlugs: ["writing"],
intros: [
"The blank page is not the enemy. The closed editor is.",
"Nobody is waiting for your post, which is exactly why you can publish it.",
],
sections: [
{
heading: "Lower the stakes on purpose",
body: "A post is not a thesis. Three paragraphs that say one true thing beat three thousand words that circle four maybe-true things. If it grows, it grows in the editor, not in your head.",
},
{
heading: "Write for one specific reader",
body: "Pick a person — a colleague, a past version of yourself, the next stranger with your exact bug — and explain it to them. Prose addressed to everyone lands on no one.",
},
{
heading: "Endings are allowed to be abrupt",
body: "You do not owe the reader a summary of what they just read. When the point has been made, stop. The best closing line is usually the one you almost deleted for being too plain.",
},
{
heading: "Momentum beats inspiration",
body: "A mediocre paragraph on Tuesday makes a good paragraph possible on Wednesday. The drafts folder is not a graveyard; it is a compost heap, and compost is how gardens work.",
},
],
closers: [
"Publish it. You can be embarrassed and findable, or polished and imaginary.",
"The archive you envy is just someone else's pile of Tuesdays.",
],
},
tooling: {
tagSlugs: ["tooling"],
intros: [
"Good tooling is invisible until you work somewhere without it.",
"Every manual step is a future incident report.",
],
sections: [
{
heading: "Scripts are institutional memory",
body: "The deploy ritual that lives in someone's shell history is one resignation away from being lost. The same ritual as a script in the repo is documentation that executes.",
},
{
heading: "Make the fast path the right path",
body: "If linting runs on save and tests run in a keystroke, they happen constantly. If they require remembering a command with four flags, they happen the night before release. Friction decides behavior more than policy does.",
},
{
heading: "Update tools on a schedule, not in a panic",
body: "Small weekly bumps fail in small ways. The eighteen-month mega-upgrade fails in ways that get their own retrospective document and a nickname.",
},
],
closers: [
"The best developer experience improvements are the ones nobody thanks you for, because nobody notices the problem is gone.",
"Sharpen the saw, but also: stop carrying the saw everywhere by hand.",
],
code: '```json\n{\n "scripts": {\n "check": "npm run lint && npm run typecheck && npm test"\n }\n}\n```',
},
accessibility: {
tagSlugs: ["accessibility", "design"],
intros: [
"Accessibility is not a feature you add. It is damage you stop doing.",
"The keyboard user is not an edge case; they are the test you can run yourself, today.",
],
sections: [
{
heading: "Semantics do the heavy lifting",
body: "A real button, a real nav, a real heading hierarchy: assistive tech understands these for free. Recreating them from divs means re-implementing the browser badly, one ARIA attribute at a time.",
},
{
heading: "Focus must be visible, always",
body: "Removing the focus ring because it 'looks busy' is unplugging the only steering wheel some users have. Style it boldly instead — a confident ring reads as designed, not accidental.",
},
{
heading: "Alt text is editorial, not technical",
body: "The question is not 'what pixels are here' but 'what would the sighted reader take away'. Sometimes that is a description; sometimes it is an empty string, because the image was decoration all along.",
},
],
closers: [
"Tab through your site once a week. It costs ninety seconds and finds bugs your test suite cannot see.",
"Accessible sites are faster, simpler, and easier to test. The virtue is a side effect of the quality.",
],
},
performance: {
tagSlugs: ["performance", "nextjs"],
intros: [
"Performance is a feature users notice by its absence.",
"The profiler has ended more arguments than any style guide ever will.",
],
sections: [
{
heading: "Measure, then touch",
body: "The slow part is never where intuition points. Ten minutes with real timings regularly reveals that the 'expensive render' is fine and the innocent-looking query runs four hundred times.",
},
{
heading: "The cheapest work is the skipped kind",
body: "Before making a request faster, ask whether it needs to happen. Caching, deduplication, and pagination are not optimizations; they are decisions not to do the work at all.",
},
{
heading: "Budgets keep you honest",
body: "A page-weight budget turns 'it feels slower lately' into 'we crossed 200 KB in March'. Numbers with thresholds get defended; vibes get eroded one dependency at a time.",
},
],
closers: [
"Fast software is mostly the accumulation of small refusals.",
"Users cannot tell you the site is slow. They just come back less often.",
],
code: "```ts\nconst [countRows, itemRows] = await Promise.all([\n countQuery,\n pageQuery.limit(perPage).offset(offset),\n]);\n```",
},
};
const BULK_POSTS: Array<{ title: string; topic: keyof typeof BULK_TOPICS }> = [
{ title: "Contrast is a budget", topic: "design" },
{ title: "Narrowing is the whole game", topic: "typescript" },
{ title: "Indexes I actually use", topic: "postgres" },
{ title: "Server components, one year in", topic: "nextjs" },
{ title: "Write the middle first", topic: "writing" },
{ title: "My terminal is my IDE", topic: "tooling" },
{ title: "Focus rings are not optional", topic: "accessibility" },
{ title: "Measure before you memoize", topic: "performance" },
{ title: "Whitespace does the heavy lifting", topic: "design" },
{ title: "satisfies changed how I write configs", topic: "typescript" },
{ title: "CHECK constraints are cheap insurance", topic: "postgres" },
{ title: "Streaming is a UX decision", topic: "nextjs" },
{ title: "Short posts are allowed", topic: "writing" },
{ title: "Dotfiles as documentation", topic: "tooling" },
{ title: "Alt text is an editorial skill", topic: "accessibility" },
{ title: "The fastest request is no request", topic: "performance" },
{ title: "Designing empty states first", topic: "design" },
{ title: "Discriminated unions for UI state", topic: "typescript" },
{ title: "Explaining EXPLAIN to myself", topic: "postgres" },
{ title: "Route groups keep layouts honest", topic: "nextjs" },
{ title: "Editing is deleting", topic: "writing" },
{ title: "The linter argues so we don't have to", topic: "tooling" },
{ title: "Keyboard first, mouse second", topic: "accessibility" },
{ title: "Lazy loading below the fold", topic: "performance" },
{ title: "The case for boring navigation", topic: "design" },
{ title: "The readonly habit", topic: "typescript" },
{ title: "Migrations without fear", topic: "postgres" },
{ title: "Caching is a contract", topic: "nextjs" },
{ title: "Keep a someday file", topic: "writing" },
{ title: "Scripts over memory", topic: "tooling" },
{ title: "Semantic HTML is free accessibility", topic: "accessibility" },
{ title: "Budgets make performance a feature", topic: "performance" },
{ title: "Color tokens before color choices", topic: "design" },
{ title: "Generics you can actually read", topic: "typescript" },
{ title: "The case against clever SQL", topic: "postgres" },
{ title: "Server actions without the footguns", topic: "nextjs" },
{ title: "Publish on a schedule, not a mood", topic: "writing" },
{ title: "Slow tools teach bad habits", topic: "tooling" },
{ title: "Typography defaults worth stealing", topic: "design" },
{ title: "Parsing, not validating, in practice", topic: "typescript" },
{ title: "Timestamps, time zones, and regret", topic: "postgres" },
{ title: "The app router mental model", topic: "nextjs" },
{ title: "Titles are promises", topic: "writing" },
{ title: "Small screens are the honest ones", topic: "design" },
];
function buildBulkBody(topicKey: keyof typeof BULK_TOPICS, index: number): string {
const topic = BULK_TOPICS[topicKey];
const intro = topic.intros[index % topic.intros.length];
const first = topic.sections[index % topic.sections.length];
let second = topic.sections[(index + 2) % topic.sections.length];
if (second === first) {
second = topic.sections[(index + 1) % topic.sections.length];
}
const closer = topic.closers[index % topic.closers.length];
const parts = [intro, `## ${first.heading}`, first.body];
if (topic.code && index % 2 === 0) parts.push(topic.code);
parts.push(`## ${second.heading}`, second.body, closer);
return parts.join("\n\n");
}
export async function seed(databaseUrl: string, log: (msg: string) => void = () => {}) {
const pool = new Pool({ connectionString: databaseUrl, max: 3 });
const db = drizzle(pool);
try {
// --- Administrator (from env; hash updated on every run) ---------------
const username = process.env.ADMIN_USERNAME?.trim() || "admin";
const password = process.env.ADMIN_PASSWORD;
if (!password) {
throw new Error("ADMIN_PASSWORD is not set — copy .env.example to .env first.");
}
const passwordHash = await hashPassword(password);
await db
.insert(users)
.values({ username, passwordHash, role: "admin" })
.onConflictDoUpdate({
target: users.username,
set: { passwordHash, role: "admin" },
});
log(`admin user “${username}” ready`);
// --- Site settings (only created, never overwritten) -------------------
await db
.insert(settings)
.values({
id: 1,
siteTitle: "Yap Blog",
headerText: "Field notes from a solarized terminal.",
footerText: "© 2026 Yap Blog · Set in base03 · Powered by Next.js, Postgres & Drizzle",
postsPerPage: 5,
excerptWords: 40,
homeMode: "posts",
theme: "solarized-dark",
font: "geist",
})
.onConflictDoNothing({ target: settings.id });
// --- Content (skipped when posts already exist) ------------------------
const [{ value: postCount }] = await db.select({ value: count() }).from(posts);
if (postCount > 0) {
log("content already present — skipping demo content");
return;
}
const tagRows = await db
.insert(tags)
.values([
{ name: "Design", slug: "design" },
{ name: "Next.js", slug: "nextjs" },
{ name: "PostgreSQL", slug: "postgresql" },
{ name: "TypeScript", slug: "typescript" },
{ name: "Writing", slug: "writing" },
{ name: "Tooling", slug: "tooling" },
{ name: "Accessibility", slug: "accessibility" },
{ name: "Performance", slug: "performance" },
// Used only by a draft post — must never appear in the public sidebar.
{ name: "Secrets", slug: "secrets" },
])
.returning();
const tagId = new Map(tagRows.map((t) => [t.slug, t.id]));
const postRows = await db
.insert(posts)
.values([
{
title: "Hello, Nightfall",
slug: "hello-nightfall",
body: renderMarkdown(POST_BODIES.hello),
authorName: "Matt",
status: "published",
publishedAt: daysAgo(42),
featuredImageUrl: "https://picsum.photos/seed/nightfall/1200/600",
featuredImageAlt: "Abstract dark landscape at dusk",
},
{
title: "Why Solarized Dark refuses to die",
slug: "why-solarized-dark-refuses-to-die",
body: renderMarkdown(POST_BODIES.solarized),
authorName: "Matt",
status: "published",
publishedAt: daysAgo(35),
featuredImageUrl: "https://picsum.photos/seed/solarized/1200/600",
featuredImageAlt: "Deep blue-green gradient reminiscent of the Solarized base tones",
},
{
title: "The Markdown kitchen sink",
slug: "markdown-kitchen-sink",
body: renderMarkdown(POST_BODIES.markdown),
authorName: "Matt",
status: "published",
publishedAt: daysAgo(28),
},
{
title: "Drizzle ORM in anger: schema, migrations, and calm",
slug: "drizzle-orm-in-anger",
body: renderMarkdown(POST_BODIES.drizzle),
authorName: "Matt",
status: "published",
publishedAt: daysAgo(21),
featuredImageUrl: "https://picsum.photos/seed/drizzle/1200/600",
featuredImageAlt: "Rain drizzling on a window at night",
},
{
title: "Keyboard-first blogging",
slug: "keyboard-first-blogging",
body: renderMarkdown(POST_BODIES.keyboard),
authorName: "Matt",
status: "published",
publishedAt: daysAgo(14),
},
{
title: "Postgres is enough",
slug: "postgres-is-enough",
body: renderMarkdown(POST_BODIES.postgres),
authorName: "Matt",
status: "published",
publishedAt: daysAgo(7),
// Deliberately broken URL: demonstrates the image-error fallback.
featuredImageUrl: "https://broken.invalid/elephant.jpg",
featuredImageAlt: "A sturdy elephant carrying a database",
},
{
title: "On actually writing",
slug: "on-actually-writing",
body: renderMarkdown(POST_BODIES.writing),
authorName: "Matt",
status: "published",
publishedAt: daysAgo(2),
},
{
title: "Draft: ideas backlog",
slug: "draft-ideas-backlog",
body: renderMarkdown(POST_BODIES.draftIdeas),
authorName: "Matt",
status: "draft",
},
{
title: "Secret draft (should never be public)",
slug: "secret-draft",
body: renderMarkdown(POST_BODIES.draftSecret),
authorName: "Matt",
status: "draft",
},
])
.returning();
const postId = new Map(postRows.map((p) => [p.slug, p.id]));
const link = (postSlug: string, ...tagSlugs: string[]) =>
tagSlugs.map((slug) => ({
postId: postId.get(postSlug)!,
tagId: tagId.get(slug)!,
}));
await db.insert(postTags).values([
...link("hello-nightfall", "writing", "design"),
...link("why-solarized-dark-refuses-to-die", "design"),
...link("markdown-kitchen-sink", "writing", "design"),
...link("drizzle-orm-in-anger", "typescript", "postgresql", "nextjs"),
...link("keyboard-first-blogging", "design", "nextjs"),
...link("postgres-is-enough", "postgresql"),
...link("on-actually-writing", "writing"),
...link("draft-ideas-backlog", "secrets"),
...link("secret-draft", "secrets"),
]);
// Bulk demo posts, older than the handwritten ones so those stay on
// page 1. Published every ~2 weeks going back roughly two years.
const bulkRows = await db
.insert(posts)
.values(
BULK_POSTS.map((spec, i) => {
const slug = slugify(spec.title);
return {
title: spec.title,
slug,
body: renderMarkdown(buildBulkBody(spec.topic, i)),
authorName: "Matt",
status: "published" as const,
publishedAt: daysAgo(50 + i * 15),
...(i % 3 === 0
? {
featuredImageUrl: `https://picsum.photos/seed/${slug}/1200/600`,
featuredImageAlt: `Abstract illustration for “${spec.title}`,
}
: {}),
};
}),
)
.returning({ id: posts.id, slug: posts.slug });
const bulkSlugToId = new Map(bulkRows.map((r) => [r.slug, r.id]));
await db.insert(postTags).values(
BULK_POSTS.flatMap((spec) => {
const postId = bulkSlugToId.get(slugify(spec.title))!;
return BULK_TOPICS[spec.topic].tagSlugs.map((slug) => ({
postId,
tagId: tagId.get(slug)!,
}));
}),
);
const pageRows = await db
.insert(pages)
.values([
{ title: "About", slug: "about", body: renderMarkdown(PAGE_BODIES.about), status: "published" },
{ title: "Colophon", slug: "colophon", body: renderMarkdown(PAGE_BODIES.colophon), status: "published" },
{ title: "Roadmap", slug: "roadmap", body: renderMarkdown(PAGE_BODIES.roadmap), status: "draft" },
])
.returning();
const aboutPage = pageRows.find((p) => p.slug === "about")!;
await db.insert(navItems).values([
{ label: "All posts", url: "/posts", pageId: null, sortOrder: 0 },
{ label: "About", url: null, pageId: aboutPage.id, sortOrder: 1 },
{ label: "Solarized", url: "https://ethanschoonover.com/solarized/", pageId: null, sortOrder: 2 },
]);
log(
`seeded ${postRows.filter((p) => p.status === "published").length + bulkRows.length} published posts, ` +
`${postRows.filter((p) => p.status === "draft").length} drafts, ` +
`${tagRows.length} tags, ${pageRows.length} pages`,
);
} finally {
await pool.end();
}
}
const isDirectRun =
process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
if (isDirectRun) {
const url = process.env.DATABASE_URL;
if (!url) {
console.error("DATABASE_URL is not set — copy .env.example to .env first.");
process.exit(1);
}
seed(url, (msg) => console.log(`[seed] ${msg}`))
.then(() => console.log("[seed] done"))
.catch((error) => {
console.error("[seed] failed:", error);
process.exit(1);
});
}

View file

@ -1,84 +0,0 @@
"use server";
import { eq } from "drizzle-orm";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
import { db } from "@/db";
import { users } from "@/db/schema";
import {
clearSessionCookie,
readSessionCookie,
setSessionCookie,
} from "@/lib/auth/cookies";
import { hashPassword, verifyPassword } from "@/lib/auth/password";
import { createSession, deleteExpiredSessions, deleteSession } from "@/lib/auth/session";
import type { FormState } from "@/lib/forms";
import { zodErrorToFormState } from "@/lib/forms";
import { clientKeyFrom, createRateLimiter } from "@/lib/rate-limit";
import { loginFormSchema } from "@/lib/validation";
const GENERIC_LOGIN_ERROR = "Invalid username or password.";
// Slows credential stuffing and caps how much scrypt work an attacker can
// demand. Successful logins reset the counter, so legitimate re-logins
// (and repeated dev/E2E runs) never trip it.
const loginLimiter = createRateLimiter({ limit: 10, windowMs: 15 * 60 * 1000 });
// Verified against when the username doesn't exist, so both failure paths
// cost one scrypt derivation (no username-probing timing signal).
let dummyHashPromise: Promise<string> | null = null;
function dummyHash(): Promise<string> {
dummyHashPromise ??= hashPassword("dummy-password-for-timing");
return dummyHashPromise;
}
export async function loginAction(_prev: FormState, formData: FormData): Promise<FormState> {
const parsed = loginFormSchema.safeParse({
username: formData.get("username"),
password: formData.get("password"),
});
if (!parsed.success) return zodErrorToFormState(parsed.error);
const clientKey = clientKeyFrom(await headers());
if (!loginLimiter.allow(clientKey)) {
return { formError: "Too many sign-in attempts. Please wait a few minutes and try again." };
}
let ok = false;
try {
const [user] = await db
.select()
.from(users)
.where(eq(users.username, parsed.data.username))
.limit(1);
const storedHash = user?.passwordHash ?? (await dummyHash());
const passwordOk = await verifyPassword(storedHash, parsed.data.password);
ok = passwordOk && user !== undefined;
if (ok && user) {
loginLimiter.reset(clientKey);
await deleteExpiredSessions();
const { token, expiresAt } = await createSession(user.id);
await setSessionCookie(token, expiresAt);
}
} catch (error) {
console.error("loginAction failed", error);
return { formError: "Could not sign in right now. Please try again." };
}
if (!ok) return { formError: GENERIC_LOGIN_ERROR };
redirect("/admin");
}
export async function logoutAction(): Promise<void> {
try {
const token = await readSessionCookie();
if (token) await deleteSession(token);
} catch (error) {
// Losing the DB row is not fatal — the cookie is cleared regardless.
console.error("logoutAction failed", error);
}
await clearSessionCookie();
redirect("/admin/login");
}

View file

@ -1,98 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { headers } from "next/headers";
import { z } from "zod";
import { requireAdmin, requireUser } from "@/lib/auth/dal";
import type { FormState } from "@/lib/forms";
import { zodErrorToFormState } from "@/lib/forms";
import { clientKeyFrom, createRateLimiter } from "@/lib/rate-limit";
import {
createComment,
deleteComment,
getCommentPostAuthorId,
setCommentStatus,
} from "@/lib/services/comments";
import { commentFormSchema } from "@/lib/validation";
// The honeypot below catches naive bots; this caps what the ones that skip
// it can insert. Generous enough for an enthusiastic human in a thread.
const commentLimiter = createRateLimiter({ limit: 5, windowMs: 10 * 60 * 1000 });
/**
* The one unauthenticated mutation in the app. Safe because the result is
* always a pending comment nothing shows publicly until an admin approves
* it and the service re-checks that the post is published and the parent
* comment is approved.
*/
export async function submitCommentAction(
_prev: FormState,
formData: FormData,
): Promise<FormState> {
// Honeypot: real visitors never see this field; bots that fill it get a
// fake success so they don't learn to skip it.
const honeypot = formData.get("website");
if (typeof honeypot === "string" && honeypot !== "") {
return { status: "success" };
}
if (!commentLimiter.allow(clientKeyFrom(await headers()))) {
return {
formError: "You are commenting too quickly. Please wait a few minutes and try again.",
};
}
const parsed = commentFormSchema.safeParse({
postId: formData.get("postId"),
parentId: formData.get("parentId"),
authorName: formData.get("authorName"),
authorEmail: formData.get("authorEmail"),
emailPublic: formData.get("emailPublic"),
body: formData.get("body"),
});
if (!parsed.success) return zodErrorToFormState(parsed.error);
const data = parsed.data;
try {
const result = await createComment({
postId: data.postId,
parentId: data.parentId,
authorName: data.authorName,
authorEmail: data.authorEmail,
emailPublic: data.emailPublic,
body: data.body,
});
if (!result.ok) return { formError: result.error };
} catch (error) {
console.error("submitCommentAction failed", error);
return { formError: "Something went wrong while posting. Please try again." };
}
// Pending comments are invisible publicly, so nothing to revalidate here.
return { status: "success" };
}
export async function setCommentStatusAction(
id: number,
status: "pending" | "approved",
): Promise<void> {
const user = await requireUser();
const commentId = z.number().int().positive().parse(id);
const nextStatus = z.enum(["pending", "approved"]).parse(status);
if (user.role !== "admin") {
// Authors with the approve-comments permission moderate the
// comments sitting on their own posts, nothing else.
if (!user.permissions.approveComments) return;
const row = await getCommentPostAuthorId(commentId);
if (!row || row.postAuthorId !== user.id) return;
}
await setCommentStatus(commentId, nextStatus);
revalidatePath("/", "layout");
}
export async function deleteCommentAction(id: number): Promise<void> {
await requireAdmin();
const commentId = z.number().int().positive().parse(id);
await deleteComment(commentId);
revalidatePath("/", "layout");
}

View file

@ -1,80 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { requireAdmin } from "@/lib/auth/dal";
import type { FormState } from "@/lib/forms";
import { zodErrorToFormState } from "@/lib/forms";
import { sanitizeHtml } from "@/lib/html";
import { isUniqueViolation, SlugConflictError } from "@/lib/services/errors";
import {
createPage,
deletePage,
setPageStatus,
updatePage,
} from "@/lib/services/pages";
import { pageFormSchema } from "@/lib/validation";
async function savePage(id: number | null, formData: FormData): Promise<FormState> {
await requireAdmin();
const parsed = pageFormSchema.safeParse({
title: formData.get("title"),
slug: formData.get("slug"),
body: formData.get("body"),
status: formData.get("status"),
});
if (!parsed.success) return zodErrorToFormState(parsed.error);
// Editor HTML is sanitized at the trust boundary; render sanitizes again.
const input = { ...parsed.data, body: sanitizeHtml(parsed.data.body) };
let pageId: number;
try {
if (id === null) {
const page = await createPage(input);
pageId = page.id;
} else {
const page = await updatePage(id, input);
if (!page) return { formError: "This page no longer exists." };
pageId = page.id;
}
} catch (error) {
if (error instanceof SlugConflictError) {
return { fieldErrors: { slug: [error.message] } };
}
if (isUniqueViolation(error)) {
return { fieldErrors: { slug: ["That slug was just taken. Choose another."] } };
}
console.error("savePage failed", error);
return { formError: "Something went wrong while saving. Please try again." };
}
revalidatePath("/", "layout");
redirect(`/admin/pages/${pageId}/edit?saved=1`);
}
export async function createPageAction(_prev: FormState, formData: FormData) {
return savePage(null, formData);
}
export async function updatePageAction(id: number, _prev: FormState, formData: FormData) {
const pageId = z.number().int().positive().parse(id);
return savePage(pageId, formData);
}
export async function setPageStatusAction(id: number, status: "draft" | "published") {
await requireAdmin();
const pageId = z.number().int().positive().parse(id);
const nextStatus = z.enum(["draft", "published"]).parse(status);
await setPageStatus(pageId, nextStatus);
revalidatePath("/", "layout");
}
export async function deletePageAction(id: number) {
await requireAdmin();
const pageId = z.number().int().positive().parse(id);
await deletePage(pageId);
revalidatePath("/", "layout");
redirect("/admin/pages?deleted=1");
}

View file

@ -1,180 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { requireUser } from "@/lib/auth/dal";
import type { FormState } from "@/lib/forms";
import { zodErrorToFormState } from "@/lib/forms";
import { sanitizeHtml } from "@/lib/html";
import { resolveAuthorTagIds, statusChangeError } from "@/lib/permissions";
import { isUniqueViolation, SlugConflictError } from "@/lib/services/errors";
import {
createPost,
deletePost,
getPostById,
type PostInput,
setPostStatus,
updatePost,
} from "@/lib/services/posts";
import { getTagsBySlugs } from "@/lib/services/tags";
import { getAllowedTagIds, grantTags } from "@/lib/services/users";
import { slugify } from "@/lib/slug";
import { postFormSchema } from "@/lib/validation";
function readPostForm(formData: FormData) {
return postFormSchema.safeParse({
title: formData.get("title"),
slug: formData.get("slug"),
authorName: formData.get("authorName"),
body: formData.get("body"),
featuredImageUrl: formData.get("featuredImageUrl"),
featuredImageAlt: formData.get("featuredImageAlt"),
status: formData.get("status"),
tagIds: formData.getAll("tagIds"),
// Author forms omit the new-tags input entirely — treat missing as "".
newTags: formData.get("newTags") ?? "",
});
}
function toPostInput(data: z.infer<typeof postFormSchema>): PostInput {
return {
title: data.title,
slug: data.slug,
// Editor HTML is sanitized at the trust boundary; render sanitizes again.
body: sanitizeHtml(data.body),
authorName: data.authorName,
featuredImageUrl: data.featuredImageUrl === "" ? null : data.featuredImageUrl,
featuredImageAlt: data.featuredImageAlt === "" ? null : data.featuredImageAlt,
status: data.status,
tagIds: data.tagIds,
newTagNames: data.newTags.split(",").map((s) => s.trim()).filter(Boolean),
};
}
async function savePost(id: number | null, formData: FormData): Promise<FormState> {
const user = await requireUser();
const parsed = readPostForm(formData);
if (!parsed.success) return zodErrorToFormState(parsed.error);
const input = toPostInput(parsed.data);
if (user.role !== "admin") {
let existingTagIds: number[] = [];
let fromStatus: "draft" | "published" | null = null;
if (id !== null) {
const existing = await getPostById(id);
if (!existing) return { formError: "This post no longer exists." };
if (existing.authorId !== user.id) {
return { formError: "You can only edit your own posts." };
}
existingTagIds = existing.tags.map((t) => t.id);
fromStatus = existing.status;
}
const statusErr = statusChangeError(user.permissions, fromStatus, input.status);
if (statusErr) return { formError: statusErr };
const allowed = new Set(await getAllowedTagIds(user.id));
if (input.newTagNames.length > 0) {
if (!user.permissions.createTags) {
return {
fieldErrors: { newTags: ["You do not have permission to create new tags."] },
};
}
// "Creating" a tag that already exists would silently self-grant
// access to it — refuse unless the author already has that grant.
const slugs = input.newTagNames.map(slugify).filter(Boolean);
const existingTags = await getTagsBySlugs(slugs);
const offLimits = existingTags.find((t) => !allowed.has(t.id));
if (offLimits) {
return {
fieldErrors: {
newTags: [
`The tag “${offLimits.name}” already exists — ask the admin for access to it.`,
],
},
};
}
}
const resolved = resolveAuthorTagIds({
submitted: input.tagIds,
existing: existingTagIds,
allowed,
creatingTags: input.newTagNames.length > 0,
});
if ("error" in resolved) return { fieldErrors: { tagIds: [resolved.error] } };
input.tagIds = resolved.tagIds;
}
let postId: number;
try {
if (id === null) {
const post = await createPost(input, user.id);
postId = post.id;
} else {
const post = await updatePost(id, input);
if (!post) return { formError: "This post no longer exists." };
postId = post.id;
}
} catch (error) {
if (error instanceof SlugConflictError) {
return { fieldErrors: { slug: [error.message] } };
}
if (isUniqueViolation(error)) {
return { fieldErrors: { slug: ["That slug was just taken. Choose another."] } };
}
console.error("savePost failed", error);
return { formError: "Something went wrong while saving. Please try again." };
}
// Tags the author just created become part of their grants, so their
// next edit doesn't reject their own post.
if (user.role !== "admin" && input.newTagNames.length > 0) {
const saved = await getPostById(postId);
const known = new Set(input.tagIds);
const createdIds = (saved?.tags ?? []).filter((t) => !known.has(t.id)).map((t) => t.id);
await grantTags(user.id, createdIds);
}
revalidatePath("/", "layout");
redirect(`/admin/posts/${postId}/edit?saved=1`);
}
export async function createPostAction(_prev: FormState, formData: FormData) {
return savePost(null, formData);
}
export async function updatePostAction(id: number, _prev: FormState, formData: FormData) {
const postId = z.number().int().positive().parse(id);
return savePost(postId, formData);
}
export async function setPostStatusAction(id: number, status: "draft" | "published") {
const user = await requireUser();
const postId = z.number().int().positive().parse(id);
const nextStatus = z.enum(["draft", "published"]).parse(status);
if (user.role !== "admin") {
// Authors may change status on their own posts, within their
// publish/unpublish permissions.
const post = await getPostById(postId);
if (!post || post.authorId !== user.id) return;
if (statusChangeError(user.permissions, post.status, nextStatus) !== null) return;
}
await setPostStatus(postId, nextStatus);
revalidatePath("/", "layout");
}
export async function deletePostAction(id: number) {
const user = await requireUser();
const postId = z.number().int().positive().parse(id);
if (user.role !== "admin") {
// Authors with the delete permission may delete their own posts.
if (!user.permissions.deletePosts) return;
const post = await getPostById(postId);
if (!post || post.authorId !== user.id) return;
}
await deletePost(postId);
revalidatePath("/", "layout");
redirect("/admin/posts?deleted=1");
}

View file

@ -1,140 +0,0 @@
"use server";
import { inArray } from "drizzle-orm";
import { revalidatePath } from "next/cache";
import { db } from "@/db";
import { pages, tags } from "@/db/schema";
import { requireAdmin } from "@/lib/auth/dal";
import type { FormState } from "@/lib/forms";
import { zodErrorToFormState } from "@/lib/forms";
import { importSiteExport, parseSiteExportJson } from "@/lib/services/import-export";
import { type NavItemInput, saveSettings } from "@/lib/services/settings";
import { parseNavItemsJson, settingsFormSchema } from "@/lib/validation";
export async function updateSettingsAction(
_prev: FormState,
formData: FormData,
): Promise<FormState> {
await requireAdmin();
const parsed = settingsFormSchema.safeParse({
siteTitle: formData.get("siteTitle"),
siteUrl: formData.get("siteUrl") ?? "",
headerText: formData.get("headerText"),
footerText: formData.get("footerText"),
postsPerPage: formData.get("postsPerPage"),
excerptWords: formData.get("excerptWords"),
homeMode: formData.get("homeMode"),
homeTagId: formData.get("homeTagId"),
homePageId: formData.get("homePageId"),
theme: formData.get("theme"),
font: formData.get("font"),
navItemsJson: formData.get("navItemsJson"),
});
if (!parsed.success) return zodErrorToFormState(parsed.error);
const data = parsed.data;
const navResult = parseNavItemsJson(data.navItemsJson);
if ("error" in navResult) return { formError: navResult.error };
const nav: NavItemInput[] = navResult.items.map((item) => ({
label: item.label,
url: item.url === "" ? null : item.url,
pageId: item.pageId,
}));
try {
// Verify referenced rows still exist (they may have been deleted in
// another tab); dangling ids become validation errors, not FK crashes.
if (data.homeMode === "tag") {
if (!data.homeTagId) {
return { fieldErrors: { homeTagId: ["Choose a tag for the home page."] } };
}
const found = await db
.select({ id: tags.id })
.from(tags)
.where(inArray(tags.id, [data.homeTagId]));
if (found.length === 0) {
return { fieldErrors: { homeTagId: ["That tag no longer exists."] } };
}
}
if (data.homeMode === "page") {
if (!data.homePageId) {
return { fieldErrors: { homePageId: ["Choose a page for the home page."] } };
}
const found = await db
.select({ id: pages.id })
.from(pages)
.where(inArray(pages.id, [data.homePageId]));
if (found.length === 0) {
return { fieldErrors: { homePageId: ["That page no longer exists."] } };
}
}
const navPageIds = nav.flatMap((i) => (i.pageId !== null ? [i.pageId] : []));
if (navPageIds.length > 0) {
const found = await db
.select({ id: pages.id })
.from(pages)
.where(inArray(pages.id, navPageIds));
if (found.length !== new Set(navPageIds).size) {
return { formError: "A navigation item points at a page that no longer exists." };
}
}
await saveSettings(
{
siteTitle: data.siteTitle,
siteUrl: data.siteUrl,
headerText: data.headerText,
footerText: data.footerText,
postsPerPage: data.postsPerPage,
excerptWords: data.excerptWords,
homeMode: data.homeMode,
homeTagId: data.homeMode === "tag" ? (data.homeTagId ?? null) : null,
homePageId: data.homeMode === "page" ? (data.homePageId ?? null) : null,
theme: data.theme,
font: data.font,
},
nav,
);
} catch (error) {
console.error("updateSettingsAction failed", error);
return { formError: "Something went wrong while saving settings. Please try again." };
}
revalidatePath("/", "layout");
return { status: "success" };
}
// Keep under next.config.ts serverActions.bodySizeLimit (with multipart overhead).
const MAX_IMPORT_BYTES = 20 * 1024 * 1024;
export async function importSiteAction(
_prev: FormState,
formData: FormData,
): Promise<FormState> {
await requireAdmin();
const file = formData.get("file");
if (!(file instanceof File) || file.size === 0) {
return { formError: "Choose an export file (.json) to import." };
}
if (file.size > MAX_IMPORT_BYTES) {
return { formError: "That file is too large to import (20 MB max)." };
}
const result = parseSiteExportJson(await file.text());
if ("error" in result) return { formError: result.error };
try {
await importSiteExport(result.data);
} catch (error) {
console.error("importSiteAction failed", error);
return {
formError: "Something went wrong while importing. The database was not changed.",
};
}
revalidatePath("/", "layout");
return { status: "success" };
}

View file

@ -1,118 +0,0 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { z } from "zod";
import { requireAdmin, requireUser } from "@/lib/auth/dal";
import type { FormState } from "@/lib/forms";
import { zodErrorToFormState } from "@/lib/forms";
import { isUniqueViolation } from "@/lib/services/errors";
import {
changeOwnPassword,
createUser,
deleteUser,
updateUser,
} from "@/lib/services/users";
import {
changePasswordFormSchema,
createUserFormSchema,
updateUserFormSchema,
} from "@/lib/validation";
function readPermissions(formData: FormData) {
return {
canCreateTags: formData.get("canCreateTags"),
canPublishPosts: formData.get("canPublishPosts"),
canUnpublishPosts: formData.get("canUnpublishPosts"),
canDeletePosts: formData.get("canDeletePosts"),
canApproveComments: formData.get("canApproveComments"),
};
}
export async function createUserAction(
_prev: FormState,
formData: FormData,
): Promise<FormState> {
await requireAdmin();
const parsed = createUserFormSchema.safeParse({
username: formData.get("username"),
password: formData.get("password"),
tagIds: formData.getAll("tagIds"),
permissions: readPermissions(formData),
});
if (!parsed.success) return zodErrorToFormState(parsed.error);
try {
await createUser(parsed.data);
} catch (error) {
if (isUniqueViolation(error)) {
return { fieldErrors: { username: ["That username is already taken."] } };
}
console.error("createUserAction failed", error);
return { formError: "Something went wrong while creating the account." };
}
redirect("/admin/users?created=1");
}
export async function updateUserAction(
id: number,
_prev: FormState,
formData: FormData,
): Promise<FormState> {
await requireAdmin();
const userId = z.number().int().positive().parse(id);
const parsed = updateUserFormSchema.safeParse({
password: formData.get("password"),
tagIds: formData.getAll("tagIds"),
permissions: readPermissions(formData),
});
if (!parsed.success) return zodErrorToFormState(parsed.error);
try {
const user = await updateUser(userId, parsed.data);
if (!user) return { formError: "That account no longer exists." };
} catch (error) {
console.error("updateUserAction failed", error);
return { formError: "Something went wrong while saving the account." };
}
// Tag grants gate what authors can post — refresh admin pages.
revalidatePath("/admin", "layout");
return { status: "success" };
}
/** Any signed-in account may change its own password. */
export async function changeOwnPasswordAction(
_prev: FormState,
formData: FormData,
): Promise<FormState> {
const user = await requireUser();
const parsed = changePasswordFormSchema.safeParse({
currentPassword: formData.get("currentPassword"),
newPassword: formData.get("newPassword"),
});
if (!parsed.success) return zodErrorToFormState(parsed.error);
try {
const result = await changeOwnPassword(
user.id,
parsed.data.currentPassword,
parsed.data.newPassword,
);
if (!result.ok) return { fieldErrors: { currentPassword: [result.error] } };
} catch (error) {
console.error("changeOwnPasswordAction failed", error);
return { formError: "Something went wrong while changing your password." };
}
return { status: "success" };
}
export async function deleteUserAction(id: number): Promise<void> {
const admin = await requireAdmin();
const userId = z.number().int().positive().parse(id);
// requireAdmin + the service's admin-role guard both protect the admin
// account; this guards the sillier accident of deleting yourself.
if (userId === admin.id) return;
await deleteUser(userId);
revalidatePath("/admin", "layout");
redirect("/admin/users?deleted=1");
}

View file

@ -1,21 +0,0 @@
export default function PublicLoading() {
return (
<div role="status" aria-live="polite" className="grid gap-6">
<span className="sr-only">Loading</span>
{[0, 1, 2].map((i) => (
<div
key={i}
aria-hidden="true"
className="animate-pulse rounded-lg border border-edge bg-surface p-6"
>
<div className="h-5 w-2/3 rounded bg-background" />
<div className="mt-3 h-3 w-1/3 rounded bg-background" />
<div className="mt-5 space-y-2">
<div className="h-3 w-full rounded bg-background" />
<div className="h-3 w-5/6 rounded bg-background" />
</div>
</div>
))}
</div>
);
}

View file

@ -1,44 +0,0 @@
import type { Metadata } from "next";
import { PageArticle } from "@/components/public/PageArticle";
import { PostListSection } from "@/components/public/PostListSection";
import { parsePage } from "@/lib/pagination";
import { pageAlternates } from "@/lib/seo";
import { resolveHomeContent } from "@/lib/services/home";
import { getSettings } from "@/lib/services/settings";
export const metadata: Metadata = { alternates: pageAlternates("/") };
/**
* Configured home page: the full post list, a tag's post list, or a
* static page decided by site settings, with a safe fallback to the
* post list when the configured tag/page has gone away.
*/
export default async function HomePage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const [sp, settings] = await Promise.all([searchParams, getSettings()]);
const home = await resolveHomeContent(settings);
if (home.kind === "page") {
return <PageArticle page={home.page} />;
}
const heading = home.kind === "tag" ? `Posts tagged “${home.tag.name}` : "Latest posts";
return (
<section aria-labelledby="home-heading">
<h1 id="home-heading" className="mb-6 text-2xl font-bold tracking-tight text-ink-bright">
{heading}
</h1>
<PostListSection
page={parsePage(sp.page)}
perPage={settings.postsPerPage}
excerptWords={settings.excerptWords}
tagId={home.kind === "tag" ? home.tag.id : undefined}
basePath="/"
emptyMessage="No posts have been published yet. Check back soon!"
/>
</section>
);
}

View file

@ -1,9 +0,0 @@
import { notFound } from "next/navigation";
/**
* Catch-all for unknown public URLs so they render the themed 404 inside
* the public layout instead of the bare root not-found page.
*/
export default function CatchAllPage() {
notFound();
}

View file

@ -1,31 +0,0 @@
import { SiteFooter } from "@/components/public/SiteFooter";
import { SiteHeader } from "@/components/public/SiteHeader";
import { SiteSidebar } from "@/components/public/SiteSidebar";
import { getSettings, listPublicNav } from "@/lib/services/settings";
import { listPublicTags } from "@/lib/services/tags";
export default async function PublicLayout({ children }: { children: React.ReactNode }) {
const [settings, nav, tags] = await Promise.all([
getSettings(),
listPublicNav(),
listPublicTags(),
]);
return (
<>
<SiteHeader
siteTitle={settings.siteTitle}
headerText={settings.headerText}
nav={nav}
tags={tags}
/>
<div className="container-site flex-1 py-8 sm:py-10 lg:grid lg:grid-cols-[minmax(0,1fr)_16rem] lg:items-start lg:gap-10">
<main id="main" className="min-w-0">
{children}
</main>
<SiteSidebar tags={tags} />
</div>
<SiteFooter text={settings.footerText} />
</>
);
}

View file

@ -1,20 +0,0 @@
import Link from "next/link";
/** 404 shown inside the public chrome (header, sidebar, footer stay put). */
export default function PublicNotFound() {
return (
<div className="py-16 text-center">
<p className="font-mono text-sm text-ink-muted">404</p>
<h1 className="mt-2 text-2xl font-semibold text-ink-strong">Not found</h1>
<p className="mt-3 text-sm text-ink-muted">
That post, tag, or page does not exist it may have been unpublished or removed.
</p>
<Link
href="/"
className="mt-6 inline-flex items-center rounded-md bg-link px-4 py-2 text-sm font-medium text-ink-inverse transition-colors hover:bg-link-hover"
>
Back to the front page
</Link>
</div>
);
}

View file

@ -1,26 +0,0 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { PageArticle } from "@/components/public/PageArticle";
import { pageAlternates } from "@/lib/seo";
import { getPublishedPageBySlug } from "@/lib/services/pages";
type Props = { params: Promise<{ slug: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const page = await getPublishedPageBySlug(slug);
if (!page) return {};
return {
title: page.title,
alternates: pageAlternates(`/pages/${page.slug}`),
openGraph: { title: page.title, url: `/pages/${page.slug}` },
};
}
export default async function StaticPage({ params }: Props) {
const { slug } = await params;
// Draft pages are filtered inside the query — they 404 like unknown slugs.
const page = await getPublishedPageBySlug(slug);
if (!page) notFound();
return <PageArticle page={page} />;
}

View file

@ -1,21 +0,0 @@
export default function PublicLoading() {
return (
<div role="status" aria-live="polite" className="grid gap-6">
<span className="sr-only">Loading</span>
{[0, 1, 2].map((i) => (
<div
key={i}
aria-hidden="true"
className="animate-pulse rounded-lg border border-edge bg-surface p-6"
>
<div className="h-5 w-2/3 rounded bg-background" />
<div className="mt-3 h-3 w-1/3 rounded bg-background" />
<div className="mt-5 space-y-2">
<div className="h-3 w-full rounded bg-background" />
<div className="h-3 w-5/6 rounded bg-background" />
</div>
</div>
))}
</div>
);
}

View file

@ -1,33 +0,0 @@
import type { Metadata } from "next";
import { PostListSection } from "@/components/public/PostListSection";
import { parsePage } from "@/lib/pagination";
import { pageAlternates } from "@/lib/seo";
import { getSettings } from "@/lib/services/settings";
export const metadata: Metadata = {
title: "All posts",
alternates: pageAlternates("/posts"),
};
export default async function PostsPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const [sp, settings] = await Promise.all([searchParams, getSettings()]);
return (
<section aria-labelledby="posts-heading">
<h1 id="posts-heading" className="mb-6 text-2xl font-bold tracking-tight text-ink-bright">
All posts
</h1>
<PostListSection
page={parsePage(sp.page)}
perPage={settings.postsPerPage}
excerptWords={settings.excerptWords}
basePath="/posts"
emptyMessage="No posts have been published yet. Check back soon!"
/>
</section>
);
}

View file

@ -1,88 +0,0 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { submitCommentAction } from "@/actions/comments";
import { CommentsSection } from "@/components/public/CommentsSection";
import { PostArticle } from "@/components/public/PostArticle";
import { generateExcerpt } from "@/lib/excerpt";
import { absoluteUrl, pageAlternates, resolveSiteUrl } from "@/lib/seo";
import { listApprovedComments } from "@/lib/services/comments";
import { getPublishedPostBySlug } from "@/lib/services/posts";
import { getSettings } from "@/lib/services/settings";
type Props = { params: Promise<{ slug: string }> };
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = await getPublishedPostBySlug(slug);
if (!post) return {};
const description = generateExcerpt(post.body, 30) || undefined;
return {
title: post.title,
description,
alternates: pageAlternates(`/posts/${post.slug}`),
openGraph: {
type: "article",
title: post.title,
description,
url: `/posts/${post.slug}`,
publishedTime: post.publishedAt?.toISOString(),
modifiedTime: post.updatedAt.toISOString(),
authors: [post.authorName],
tags: post.tags.map((t) => t.name),
images: post.featuredImageUrl
? [{ url: post.featuredImageUrl, alt: post.featuredImageAlt ?? undefined }]
: undefined,
},
twitter: {
card: post.featuredImageUrl ? "summary_large_image" : "summary",
title: post.title,
description,
},
};
}
export default async function PostPage({ params }: Props) {
const { slug } = await params;
// Draft posts are filtered inside the query — they 404 like unknown slugs.
const post = await getPublishedPostBySlug(slug);
if (!post) notFound();
const [comments, settings] = await Promise.all([
listApprovedComments(post.id),
getSettings(),
]);
const siteUrl = resolveSiteUrl(settings);
const jsonLd = {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: post.title,
url: absoluteUrl(siteUrl, `/posts/${post.slug}`),
datePublished: post.publishedAt?.toISOString(),
dateModified: post.updatedAt.toISOString(),
author: { "@type": "Person", name: post.authorName },
keywords: post.tags.map((t) => t.name).join(", ") || undefined,
description: generateExcerpt(post.body, 30) || undefined,
...(post.featuredImageUrl
? {
image: post.featuredImageUrl.startsWith("/")
? absoluteUrl(siteUrl, post.featuredImageUrl)
: post.featuredImageUrl,
}
: {}),
};
return (
<>
<script
type="application/ld+json"
// Structured data for search engines; content is JSON built from
// trusted fields (escaped "<" defends against </script> breakout).
dangerouslySetInnerHTML={{
__html: JSON.stringify(jsonLd).replaceAll("<", "\\u003c"),
}}
/>
<PostArticle post={post} />
<CommentsSection postId={post.id} comments={comments} action={submitCommentAction} />
</>
);
}

View file

@ -1,49 +0,0 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { PostListSection } from "@/components/public/PostListSection";
import { parsePage } from "@/lib/pagination";
import { pageAlternates } from "@/lib/seo";
import { getSettings } from "@/lib/services/settings";
import { getPublicTagBySlug } from "@/lib/services/tags";
type Props = {
params: Promise<{ slug: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
};
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const tag = await getPublicTagBySlug(slug);
if (!tag) return {};
return {
title: `Posts tagged “${tag.name}`,
alternates: pageAlternates(`/tags/${tag.slug}`),
};
}
export default async function TagPage({ params, searchParams }: Props) {
const [{ slug }, sp, settings] = await Promise.all([params, searchParams, getSettings()]);
// Unknown tags and tags used only by drafts both resolve to null → 404.
const tag = await getPublicTagBySlug(slug);
if (!tag) notFound();
return (
<section aria-labelledby="tag-heading">
<h1 id="tag-heading" className="text-2xl font-bold tracking-tight text-ink-bright">
Posts tagged {tag.name}
</h1>
<p className="mb-6 mt-1 text-sm text-ink-muted">
{tag.postCount} {tag.postCount === 1 ? "post" : "posts"}
</p>
<PostListSection
page={parsePage(sp.page)}
perPage={settings.postsPerPage}
excerptWords={settings.excerptWords}
tagId={tag.id}
basePath={`/tags/${tag.slug}`}
emptyMessage="No published posts carry this tag yet."
/>
</section>
);
}

View file

@ -1,29 +0,0 @@
import type { Metadata } from "next";
import { changeOwnPasswordAction } from "@/actions/users";
import { ChangePasswordForm } from "@/components/admin/ChangePasswordForm";
import { requireUser } from "@/lib/auth/dal";
export const metadata: Metadata = { title: "Your account" };
/** Self-service account page — available to every signed-in account. */
export default async function AccountPage() {
const user = await requireUser();
return (
<div>
<h1 className="mb-2 text-2xl font-bold tracking-tight text-ink-bright">Your account</h1>
<p className="mb-6 text-sm text-ink-muted">
Signed in as <span className="font-medium text-ink-strong">{user.username}</span>
{" · "}
{user.role === "admin" ? "administrator" : "author"}
</p>
<section aria-labelledby="password-heading">
<h2 id="password-heading" className="mb-4 text-lg font-semibold text-ink-strong">
Change password
</h2>
<ChangePasswordForm action={changeOwnPasswordAction} />
</section>
</div>
);
}

View file

@ -1,118 +0,0 @@
import type { Metadata } from "next";
import Link from "next/link";
import { redirect } from "next/navigation";
import { deleteCommentAction, setCommentStatusAction } from "@/actions/comments";
import { ConfirmButton } from "@/components/admin/ConfirmButton";
import { Button } from "@/components/ui";
import { requireUser } from "@/lib/auth/dal";
import { formatDate } from "@/lib/format";
import { type AdminComment, listCommentsForAdmin } from "@/lib/services/comments";
export const metadata: Metadata = { title: "Comments" };
function CommentCard({ comment, canDelete }: { comment: AdminComment; canDelete: boolean }) {
return (
<li className="rounded-lg border border-edge bg-surface p-4">
<div className="flex flex-wrap items-baseline gap-x-2 text-sm">
<span className="font-semibold text-ink-strong">{comment.authorName}</span>
<span className="text-xs text-ink-muted">
{comment.authorEmail}{" "}
<span
className={
comment.emailPublic
? "rounded border border-warning/40 px-1 text-warning"
: "rounded border border-edge px-1"
}
>
{comment.emailPublic ? "email shown publicly" : "email private"}
</span>
</span>
<span className="text-xs text-ink-muted">{formatDate(comment.createdAt)}</span>
</div>
<p className="mt-1 text-xs text-ink-muted">
On{" "}
<Link
href={`/posts/${comment.postSlug}`}
className="text-link hover:underline"
>
{comment.postTitle}
</Link>
{comment.parentAuthorName && <> · replying to {comment.parentAuthorName}</>}
</p>
<p className="mt-3 whitespace-pre-wrap text-sm leading-relaxed text-ink">
{comment.body}
</p>
<div className="mt-3 flex items-center gap-2">
{comment.status === "pending" ? (
<form action={setCommentStatusAction.bind(null, comment.id, "approved")}>
<Button type="submit" className="px-3 py-1.5 text-xs">Approve</Button>
</form>
) : (
<form action={setCommentStatusAction.bind(null, comment.id, "pending")}>
<Button type="submit" variant="secondary" className="px-3 py-1.5 text-xs">
Unapprove
</Button>
</form>
)}
{canDelete && (
<form action={deleteCommentAction.bind(null, comment.id)}>
<ConfirmButton
confirmMessage={`Delete this comment by ${comment.authorName}? Replies to it are deleted too. This cannot be undone.`}
className="px-3 py-1.5 text-xs"
>
Delete
</ConfirmButton>
</form>
)}
</div>
</li>
);
}
export default async function AdminCommentsPage() {
const user = await requireUser();
const isAdmin = user.role === "admin";
// Authors with the approve-comments permission moderate their own posts.
if (!user.permissions.approveComments) redirect("/admin/posts");
const all = await listCommentsForAdmin(isAdmin ? undefined : { postAuthorId: user.id });
const pending = all.filter((c) => c.status === "pending");
const approved = all.filter((c) => c.status === "approved");
return (
<div className="max-w-3xl">
<h1 className="mb-6 text-2xl font-bold tracking-tight text-ink-bright">Comments</h1>
<section aria-labelledby="pending-heading">
<h2 id="pending-heading" className="text-lg font-semibold text-ink-strong">
Awaiting approval {pending.length > 0 && `(${pending.length})`}
</h2>
{pending.length === 0 ? (
<p className="mt-3 rounded-lg border border-dashed border-edge-strong px-4 py-8 text-center text-sm text-ink-muted">
Nothing waiting all caught up.
</p>
) : (
<ul className="mt-3 space-y-3">
{pending.map((comment) => (
<CommentCard key={comment.id} comment={comment} canDelete={isAdmin} />
))}
</ul>
)}
</section>
<section aria-labelledby="approved-heading" className="mt-10">
<h2 id="approved-heading" className="text-lg font-semibold text-ink-strong">
Approved {approved.length > 0 && `(${approved.length})`}
</h2>
{approved.length === 0 ? (
<p className="mt-3 text-sm text-ink-muted">No approved comments yet.</p>
) : (
<ul className="mt-3 space-y-3">
{approved.map((comment) => (
<CommentCard key={comment.id} comment={comment} canDelete={isAdmin} />
))}
</ul>
)}
</section>
</div>
);
}

View file

@ -1,102 +0,0 @@
import Link from "next/link";
import { logoutAction } from "@/actions/auth";
import { requireUser } from "@/lib/auth/dal";
import { countCommentsByStatus } from "@/lib/services/comments";
import { getSettings } from "@/lib/services/settings";
// Belt to robots.txt's suspenders: even a stray crawler that reaches an
// admin URL is told not to index it.
export const metadata = { robots: { index: false, follow: false } };
const navLinkClasses =
"rounded-md px-2.5 py-1.5 text-sm font-medium text-ink transition-colors hover:bg-background hover:text-ink-strong";
/**
* Every route in this group is server-guarded: the layout redirects
* anonymous visitors, each page calls requireAdmin() again (defense in
* depth), and every mutating server action re-checks on its own.
*/
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const user = await requireUser();
const isAdmin = user.role === "admin";
const canModerate = user.permissions.approveComments;
const [settings, commentCounts] = await Promise.all([
getSettings(),
canModerate
? countCommentsByStatus(isAdmin ? undefined : { postAuthorId: user.id })
: { pending: 0, approved: 0 },
]);
return (
<>
<header className="border-b border-edge bg-surface">
<div className="container-site flex flex-wrap items-center justify-between gap-x-6 gap-y-2 py-3">
<div className="flex min-w-0 flex-wrap items-center gap-x-6 gap-y-2">
<Link
href="/admin"
className="font-semibold text-ink-bright transition-colors hover:text-link"
>
{settings.siteTitle}
<span className="font-normal text-ink-muted"> · Admin</span>
</Link>
<nav aria-label="Admin sections">
{/* Authors only manage posts; everything else is the admin's. */}
<ul className="flex items-center gap-1">
{isAdmin && <li><Link href="/admin" className={navLinkClasses}>Dashboard</Link></li>}
<li><Link href="/admin/posts" className={navLinkClasses}>Posts</Link></li>
{isAdmin && (
<li><Link href="/admin/pages" className={navLinkClasses}>Pages</Link></li>
)}
{canModerate && (
<li>
<Link href="/admin/comments" className={navLinkClasses}>
Comments
{commentCounts.pending > 0 && (
<span className="ml-1.5 inline-flex min-w-5 items-center justify-center rounded-full bg-warning/20 px-1.5 py-0.5 text-xs font-semibold text-warning">
{commentCounts.pending}
</span>
)}
</Link>
</li>
)}
{isAdmin && (
<>
<li><Link href="/admin/users" className={navLinkClasses}>Users</Link></li>
<li><Link href="/admin/settings" className={navLinkClasses}>Settings</Link></li>
</>
)}
</ul>
</nav>
</div>
<div className="flex items-center gap-3 text-sm">
<Link href="/" className="text-ink-muted transition-colors hover:text-link">
View site
</Link>
<span aria-hidden="true" className="text-edge-strong">|</span>
<span className="text-ink-muted">
Signed in as{" "}
<Link
href="/admin/account"
className="text-ink-strong underline-offset-4 transition-colors hover:text-link hover:underline"
title="Account settings"
>
{user.username}
</Link>
</span>
<form action={logoutAction}>
<button
type="submit"
className="rounded-md border border-edge px-3 py-1.5 font-medium text-ink transition-colors hover:border-edge-strong hover:text-ink-strong"
>
Log out
</button>
</form>
</div>
</div>
</header>
<main id="main" className="container-site flex-1 py-8">
{children}
</main>
</>
);
}

View file

@ -1,7 +0,0 @@
export default function AdminLoading() {
return (
<p role="status" className="py-16 text-center text-sm text-ink-muted">
Loading
</p>
);
}

View file

@ -1,19 +0,0 @@
import Link from "next/link";
export default function AdminNotFound() {
return (
<div className="py-16 text-center">
<p className="font-mono text-sm text-ink-muted">404</p>
<h1 className="mt-2 text-xl font-semibold text-ink-strong">Not found</h1>
<p className="mt-3 text-sm text-ink-muted">
That post or page does not exist it may have been deleted.
</p>
<Link
href="/admin"
className="mt-6 inline-flex items-center rounded-md bg-link px-4 py-2 text-sm font-medium text-ink-inverse transition-colors hover:bg-link-hover"
>
Back to the dashboard
</Link>
</div>
);
}

View file

@ -1,95 +0,0 @@
import type { Metadata } from "next";
import Link from "next/link";
import { StatusBadge } from "@/components/admin/StatusBadge";
import { LinkButton } from "@/components/ui";
import { requireAdmin } from "@/lib/auth/dal";
import { formatDate } from "@/lib/format";
import { countCommentsByStatus } from "@/lib/services/comments";
import { listAllPages } from "@/lib/services/pages";
import { countPostsByStatus, listAllPosts } from "@/lib/services/posts";
import { listAllTags } from "@/lib/services/tags";
export const metadata: Metadata = { title: "Dashboard" };
export default async function AdminDashboard() {
await requireAdmin();
const [postCounts, allPosts, allPages, allTags, commentCounts] = await Promise.all([
countPostsByStatus(),
listAllPosts(),
listAllPages(),
listAllTags(),
countCommentsByStatus(),
]);
const recentPosts = allPosts.slice(0, 5);
const stats = [
{ label: "Published posts", value: postCounts.published },
{ label: "Draft posts", value: postCounts.draft },
{ label: "Pages", value: allPages.length },
{ label: "Tags", value: allTags.length },
{ label: "Pending comments", value: commentCounts.pending, href: "/admin/comments" },
];
return (
<div>
<div className="flex flex-wrap items-center justify-between gap-4">
<h1 className="text-2xl font-bold tracking-tight text-ink-bright">Dashboard</h1>
<div className="flex gap-2">
<LinkButton href="/admin/posts/new">New post</LinkButton>
<LinkButton href="/admin/pages/new" variant="secondary">New page</LinkButton>
</div>
</div>
<dl className="mt-8 grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-5">
{stats.map((stat) => (
<div key={stat.label} className="rounded-lg border border-edge bg-surface p-5">
<dt className="text-sm text-ink-muted">
{stat.href ? (
<Link href={stat.href} className="transition-colors hover:text-link">
{stat.label}
</Link>
) : (
stat.label
)}
</dt>
<dd className="mt-1 text-3xl font-semibold text-ink-bright">{stat.value}</dd>
</div>
))}
</dl>
<section aria-labelledby="recent-heading" className="mt-10">
<h2 id="recent-heading" className="text-lg font-semibold text-ink-strong">
Recently updated posts
</h2>
{recentPosts.length === 0 ? (
<p className="mt-4 rounded-lg border border-dashed border-edge-strong px-4 py-8 text-center text-sm text-ink-muted">
No posts yet {" "}
<Link href="/admin/posts/new" className="text-link underline underline-offset-4">
write the first one
</Link>
.
</p>
) : (
<ul className="mt-4 divide-y divide-edge rounded-lg border border-edge bg-surface">
{recentPosts.map((post) => (
<li key={post.id} className="flex items-center justify-between gap-4 px-4 py-3">
<div className="min-w-0">
<Link
href={`/admin/posts/${post.id}/edit`}
className="font-medium text-ink-strong transition-colors hover:text-link"
>
{post.title}
</Link>
<p className="mt-0.5 text-xs text-ink-muted">
Updated {formatDate(post.updatedAt)}
</p>
</div>
<StatusBadge status={post.status} />
</li>
))}
</ul>
)}
</section>
</div>
);
}

View file

@ -1,56 +0,0 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { updatePageAction } from "@/actions/pages";
import { Flash } from "@/components/admin/Flash";
import { PageForm } from "@/components/admin/PageForm";
import { requireAdmin } from "@/lib/auth/dal";
import { parseIdParam } from "@/lib/params";
import { getPageById } from "@/lib/services/pages";
export const metadata: Metadata = { title: "Edit page" };
export default async function EditPagePage({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
await requireAdmin();
const [{ id: rawId }, sp] = await Promise.all([params, searchParams]);
const id = parseIdParam(rawId);
if (id === null) notFound();
const page = await getPageById(id);
if (!page) notFound();
return (
<div>
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
<h1 className="text-2xl font-bold tracking-tight text-ink-bright">Edit page</h1>
<div className="flex items-center gap-4 text-sm">
<Link
href={`/admin/pages/${page.id}/preview`}
className="text-ink-muted transition-colors hover:text-link"
>
Preview
</Link>
{page.status === "published" && (
<Link
href={`/pages/${page.slug}`}
className="text-ink-muted transition-colors hover:text-link"
>
View on site
</Link>
)}
</div>
</div>
{sp.saved === "1" && <Flash>Saved.</Flash>}
<PageForm page={page} action={updatePageAction.bind(null, page.id)} />
</div>
);
}

View file

@ -1,43 +0,0 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { StatusBadge } from "@/components/admin/StatusBadge";
import { PageArticle } from "@/components/public/PageArticle";
import { requireAdmin } from "@/lib/auth/dal";
import { parseIdParam } from "@/lib/params";
import { getPageById } from "@/lib/services/pages";
export const metadata: Metadata = { title: "Preview page" };
/** Renders the static page exactly as the public site would — drafts included. */
export default async function PagePreviewPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
await requireAdmin();
const { id: rawId } = await params;
const id = parseIdParam(rawId);
if (id === null) notFound();
const page = await getPageById(id);
if (!page) notFound();
return (
<div>
<div className="mb-8 flex flex-wrap items-center justify-between gap-3 rounded-lg border border-warning/40 bg-warning/10 px-4 py-3 text-sm">
<p className="flex items-center gap-2 text-warning">
<span className="font-medium">Preview</span>
<StatusBadge status={page.status} />
</p>
<Link
href={`/admin/pages/${page.id}/edit`}
className="font-medium text-warning underline underline-offset-4"
>
Back to editor
</Link>
</div>
<PageArticle page={page} />
</div>
);
}

View file

@ -1,16 +0,0 @@
import type { Metadata } from "next";
import { createPageAction } from "@/actions/pages";
import { PageForm } from "@/components/admin/PageForm";
import { requireAdmin } from "@/lib/auth/dal";
export const metadata: Metadata = { title: "New page" };
export default async function NewPagePage() {
await requireAdmin();
return (
<div>
<h1 className="mb-6 text-2xl font-bold tracking-tight text-ink-bright">New page</h1>
<PageForm action={createPageAction} />
</div>
);
}

View file

@ -1,104 +0,0 @@
import type { Metadata } from "next";
import Link from "next/link";
import { deletePageAction, setPageStatusAction } from "@/actions/pages";
import { ConfirmButton } from "@/components/admin/ConfirmButton";
import { Flash } from "@/components/admin/Flash";
import { StatusBadge } from "@/components/admin/StatusBadge";
import { LinkButton } from "@/components/ui";
import { requireAdmin } from "@/lib/auth/dal";
import { formatDate } from "@/lib/format";
import { listAllPages } from "@/lib/services/pages";
export const metadata: Metadata = { title: "Pages" };
const actionButtonClasses =
"rounded-md px-2 py-1 text-xs font-medium text-ink-muted transition-colors hover:bg-background hover:text-ink-strong";
export default async function AdminPagesPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
await requireAdmin();
const [sp, pages] = await Promise.all([searchParams, listAllPages()]);
return (
<div>
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
<h1 className="text-2xl font-bold tracking-tight text-ink-bright">Pages</h1>
<LinkButton href="/admin/pages/new">New page</LinkButton>
</div>
{sp.deleted === "1" && <Flash>Page deleted.</Flash>}
{pages.length === 0 ? (
<p className="rounded-lg border border-dashed border-edge-strong px-4 py-10 text-center text-sm text-ink-muted">
No static pages yet. Create an About page, perhaps?
</p>
) : (
<div className="overflow-x-auto rounded-lg border border-edge bg-surface">
<table className="w-full min-w-[38rem] border-collapse text-sm">
<thead>
<tr className="border-b border-edge text-left text-xs uppercase tracking-wider text-ink-muted">
<th scope="col" className="px-4 py-3 font-medium">Title</th>
<th scope="col" className="px-4 py-3 font-medium">Status</th>
<th scope="col" className="px-4 py-3 font-medium">Updated</th>
<th scope="col" className="px-4 py-3 text-right font-medium">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-edge">
{pages.map((page) => (
<tr key={page.id}>
<td className="px-4 py-3">
<Link
href={`/admin/pages/${page.id}/edit`}
className="font-medium text-ink-strong transition-colors hover:text-link"
>
{page.title}
</Link>
<span className="mt-0.5 block font-mono text-xs text-ink-muted">
/pages/{page.slug}
</span>
</td>
<td className="px-4 py-3"><StatusBadge status={page.status} /></td>
<td className="whitespace-nowrap px-4 py-3 text-ink-muted">
{formatDate(page.updatedAt)}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
<Link
href={`/admin/pages/${page.id}/preview`}
className={actionButtonClasses}
>
Preview
</Link>
<form
action={setPageStatusAction.bind(
null,
page.id,
page.status === "published" ? "draft" : "published",
)}
>
<button type="submit" className={actionButtonClasses}>
{page.status === "published" ? "Unpublish" : "Publish"}
</button>
</form>
<form action={deletePageAction.bind(null, page.id)}>
<ConfirmButton
confirmMessage={`Delete “${page.title}”? Navigation items pointing at it will be removed too. This cannot be undone.`}
className="border-none px-2 py-1 text-xs"
>
Delete
</ConfirmButton>
</form>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View file

@ -1,85 +0,0 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { updatePostAction } from "@/actions/posts";
import { Flash } from "@/components/admin/Flash";
import { PostForm } from "@/components/admin/PostForm";
import { requireUser } from "@/lib/auth/dal";
import { parseIdParam } from "@/lib/params";
import { getPostById } from "@/lib/services/posts";
import { listAllTags } from "@/lib/services/tags";
import { getUserWithTags } from "@/lib/services/users";
export const metadata: Metadata = { title: "Edit post" };
export default async function EditPostPage({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const user = await requireUser();
const isAdmin = user.role === "admin";
const [{ id: rawId }, sp] = await Promise.all([params, searchParams]);
const id = parseIdParam(rawId);
if (id === null) notFound();
const [post, allTags] = await Promise.all([
getPostById(id),
isAdmin ? listAllTags() : getUserWithTags(user.id).then((u) => u?.tags ?? []),
]);
if (!post) notFound();
// Authors only reach their own posts; others 404 like unknown ids.
if (!isAdmin && post.authorId !== user.id) notFound();
// Admin-added tags outside the author's grants are shown locked; the
// server preserves them across the author's saves.
const grantedIds = new Set(allTags.map((t) => t.id));
const lockedTags = isAdmin ? [] : post.tags.filter((t) => !grantedIds.has(t.id));
const statusOptions: Array<"draft" | "published"> =
post.status === "published"
? user.permissions.unpublishPosts
? ["draft", "published"]
: ["published"]
: user.permissions.publishPosts
? ["draft", "published"]
: ["draft"];
return (
<div>
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
<h1 className="text-2xl font-bold tracking-tight text-ink-bright">Edit post</h1>
<div className="flex items-center gap-4 text-sm">
<Link
href={`/admin/posts/${post.id}/preview`}
className="text-ink-muted transition-colors hover:text-link"
>
Preview
</Link>
{post.status === "published" && (
<Link
href={`/posts/${post.slug}`}
className="text-ink-muted transition-colors hover:text-link"
>
View on site
</Link>
)}
</div>
</div>
{sp.saved === "1" && <Flash>Saved.</Flash>}
<PostForm
post={post}
allTags={allTags}
lockedTags={lockedTags}
defaultAuthor={user.username}
canCreateTags={user.permissions.createTags}
statusOptions={statusOptions}
action={updatePostAction.bind(null, post.id)}
/>
</div>
);
}

View file

@ -1,44 +0,0 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { StatusBadge } from "@/components/admin/StatusBadge";
import { PostArticle } from "@/components/public/PostArticle";
import { requireUser } from "@/lib/auth/dal";
import { parseIdParam } from "@/lib/params";
import { getPostById } from "@/lib/services/posts";
export const metadata: Metadata = { title: "Preview post" };
/** Renders the post exactly as the public site would — drafts included. */
export default async function PostPreviewPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const user = await requireUser();
const { id: rawId } = await params;
const id = parseIdParam(rawId);
if (id === null) notFound();
const post = await getPostById(id);
if (!post) notFound();
if (user.role !== "admin" && post.authorId !== user.id) notFound();
return (
<div>
<div className="mb-8 flex flex-wrap items-center justify-between gap-3 rounded-lg border border-warning/40 bg-warning/10 px-4 py-3 text-sm">
<p className="flex items-center gap-2 text-warning">
<span className="font-medium">Preview</span>
<StatusBadge status={post.status} />
</p>
<Link
href={`/admin/posts/${post.id}/edit`}
className="font-medium text-warning underline underline-offset-4"
>
Back to editor
</Link>
</div>
<PostArticle post={post} />
</div>
);
}

View file

@ -1,30 +0,0 @@
import type { Metadata } from "next";
import { createPostAction } from "@/actions/posts";
import { PostForm } from "@/components/admin/PostForm";
import { requireUser } from "@/lib/auth/dal";
import { listAllTags } from "@/lib/services/tags";
import { getUserWithTags } from "@/lib/services/users";
export const metadata: Metadata = { title: "New post" };
export default async function NewPostPage() {
const user = await requireUser();
const isAdmin = user.role === "admin";
// Authors only ever see (and can only use) their granted tags.
const allTags = isAdmin
? await listAllTags()
: ((await getUserWithTags(user.id))?.tags ?? []);
return (
<div>
<h1 className="mb-6 text-2xl font-bold tracking-tight text-ink-bright">New post</h1>
<PostForm
allTags={allTags}
defaultAuthor={user.username}
canCreateTags={user.permissions.createTags}
statusOptions={user.permissions.publishPosts ? ["draft", "published"] : ["draft"]}
action={createPostAction}
/>
</div>
);
}

View file

@ -1,119 +0,0 @@
import type { Metadata } from "next";
import Link from "next/link";
import { deletePostAction, setPostStatusAction } from "@/actions/posts";
import { ConfirmButton } from "@/components/admin/ConfirmButton";
import { Flash } from "@/components/admin/Flash";
import { StatusBadge } from "@/components/admin/StatusBadge";
import { LinkButton } from "@/components/ui";
import { requireUser } from "@/lib/auth/dal";
import { formatDate } from "@/lib/format";
import { listAllPosts } from "@/lib/services/posts";
export const metadata: Metadata = { title: "Posts" };
const actionButtonClasses =
"rounded-md px-2 py-1 text-xs font-medium text-ink-muted transition-colors hover:bg-background hover:text-ink-strong";
export default async function AdminPostsPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
const user = await requireUser();
const isAdmin = user.role === "admin";
// Session permissions are normalized: the admin has all of them.
const { publishPosts, unpublishPosts, deletePosts } = user.permissions;
const [sp, posts] = await Promise.all([
searchParams,
// Authors manage only their own posts; the admin manages everything.
listAllPosts(isAdmin ? undefined : { authorId: user.id }),
]);
return (
<div>
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
<h1 className="text-2xl font-bold tracking-tight text-ink-bright">Posts</h1>
<LinkButton href="/admin/posts/new">New post</LinkButton>
</div>
{sp.deleted === "1" && <Flash>Post deleted.</Flash>}
{posts.length === 0 ? (
<p className="rounded-lg border border-dashed border-edge-strong px-4 py-10 text-center text-sm text-ink-muted">
No posts yet. Create the first one!
</p>
) : (
<div className="overflow-x-auto rounded-lg border border-edge bg-surface">
<table className="w-full min-w-[44rem] border-collapse text-sm">
<thead>
<tr className="border-b border-edge text-left text-xs uppercase tracking-wider text-ink-muted">
<th scope="col" className="px-4 py-3 font-medium">Title</th>
<th scope="col" className="px-4 py-3 font-medium">Status</th>
<th scope="col" className="px-4 py-3 font-medium">Published</th>
<th scope="col" className="px-4 py-3 font-medium">Updated</th>
<th scope="col" className="px-4 py-3 text-right font-medium">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-edge">
{posts.map((post) => (
<tr key={post.id}>
<td className="px-4 py-3">
<Link
href={`/admin/posts/${post.id}/edit`}
className="font-medium text-ink-strong transition-colors hover:text-link"
>
{post.title}
</Link>
<span className="mt-0.5 block font-mono text-xs text-ink-muted">
/posts/{post.slug}
</span>
</td>
<td className="px-4 py-3"><StatusBadge status={post.status} /></td>
<td className="whitespace-nowrap px-4 py-3 text-ink-muted">
{formatDate(post.publishedAt)}
</td>
<td className="whitespace-nowrap px-4 py-3 text-ink-muted">
{formatDate(post.updatedAt)}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
<Link
href={`/admin/posts/${post.id}/preview`}
className={actionButtonClasses}
>
Preview
</Link>
{(post.status === "published" ? unpublishPosts : publishPosts) && (
<form
action={setPostStatusAction.bind(
null,
post.id,
post.status === "published" ? "draft" : "published",
)}
>
<button type="submit" className={actionButtonClasses}>
{post.status === "published" ? "Unpublish" : "Publish"}
</button>
</form>
)}
{deletePosts && (
<form action={deletePostAction.bind(null, post.id)}>
<ConfirmButton
confirmMessage={`Delete “${post.title}”? This cannot be undone.`}
className="border-none px-2 py-1 text-xs"
>
Delete
</ConfirmButton>
</form>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View file

@ -1,36 +0,0 @@
import type { Metadata } from "next";
import { importSiteAction, updateSettingsAction } from "@/actions/settings";
import { ImportExportSection } from "@/components/admin/ImportExportSection";
import { SettingsForm } from "@/components/admin/SettingsForm";
import { requireAdmin } from "@/lib/auth/dal";
import { listPublishedPages } from "@/lib/services/pages";
import { getSettings, listNavItems } from "@/lib/services/settings";
import { listAllTags } from "@/lib/services/tags";
export const metadata: Metadata = { title: "Site settings" };
export default async function AdminSettingsPage() {
await requireAdmin();
const [settings, navItems, allTags, publishedPages] = await Promise.all([
getSettings(),
listNavItems(),
listAllTags(),
listPublishedPages(),
]);
return (
<div>
<h1 className="mb-6 text-2xl font-bold tracking-tight text-ink-bright">Site settings</h1>
<SettingsForm
settings={settings}
navItems={navItems}
allTags={allTags}
publishedPages={publishedPages}
action={updateSettingsAction}
/>
<div className="mt-10">
<ImportExportSection importAction={importSiteAction} />
</div>
</div>
);
}

View file

@ -1,31 +0,0 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { updateUserAction } from "@/actions/users";
import { UserForm } from "@/components/admin/UserForm";
import { requireAdmin } from "@/lib/auth/dal";
import { parseIdParam } from "@/lib/params";
import { listAllTags } from "@/lib/services/tags";
import { getUserWithTags } from "@/lib/services/users";
export const metadata: Metadata = { title: "Edit account" };
export default async function EditUserPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
await requireAdmin();
const { id: rawId } = await params;
const id = parseIdParam(rawId);
if (id === null) notFound();
const [user, allTags] = await Promise.all([getUserWithTags(id), listAllTags()]);
if (!user) notFound();
return (
<div>
<h1 className="mb-6 text-2xl font-bold tracking-tight text-ink-bright">Edit account</h1>
<UserForm user={user} allTags={allTags} action={updateUserAction.bind(null, user.id)} />
</div>
);
}

View file

@ -1,19 +0,0 @@
import type { Metadata } from "next";
import { createUserAction } from "@/actions/users";
import { UserForm } from "@/components/admin/UserForm";
import { requireAdmin } from "@/lib/auth/dal";
import { listAllTags } from "@/lib/services/tags";
export const metadata: Metadata = { title: "New account" };
export default async function NewUserPage() {
await requireAdmin();
const allTags = await listAllTags();
return (
<div>
<h1 className="mb-6 text-2xl font-bold tracking-tight text-ink-bright">New account</h1>
<UserForm allTags={allTags} action={createUserAction} />
</div>
);
}

View file

@ -1,119 +0,0 @@
import type { Metadata } from "next";
import Link from "next/link";
import { deleteUserAction } from "@/actions/users";
import { ConfirmButton } from "@/components/admin/ConfirmButton";
import { Flash } from "@/components/admin/Flash";
import { LinkButton } from "@/components/ui";
import { requireAdmin } from "@/lib/auth/dal";
import { formatDate } from "@/lib/format";
import { listUsersWithTags } from "@/lib/services/users";
export const metadata: Metadata = { title: "Users" };
export default async function AdminUsersPage({
searchParams,
}: {
searchParams: Promise<Record<string, string | string[] | undefined>>;
}) {
await requireAdmin();
const [sp, users] = await Promise.all([searchParams, listUsersWithTags()]);
return (
<div>
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
<h1 className="text-2xl font-bold tracking-tight text-ink-bright">Users</h1>
<LinkButton href="/admin/users/new">New account</LinkButton>
</div>
{sp.created === "1" && <Flash>Account created.</Flash>}
{sp.deleted === "1" && <Flash>Account deleted.</Flash>}
<div className="overflow-x-auto rounded-lg border border-edge bg-surface">
<table className="w-full min-w-[38rem] border-collapse text-sm">
<thead>
<tr className="border-b border-edge text-left text-xs uppercase tracking-wider text-ink-muted">
<th scope="col" className="px-4 py-3 font-medium">Username</th>
<th scope="col" className="px-4 py-3 font-medium">Role</th>
<th scope="col" className="px-4 py-3 font-medium">Tag access</th>
<th scope="col" className="px-4 py-3 font-medium">Permissions</th>
<th scope="col" className="px-4 py-3 font-medium">Created</th>
<th scope="col" className="px-4 py-3 text-right font-medium">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-edge">
{users.map((user) => (
<tr key={user.id}>
<td className="px-4 py-3">
<Link
href={`/admin/users/${user.id}/edit`}
className="font-medium text-ink-strong transition-colors hover:text-link"
>
{user.username}
</Link>
</td>
<td className="px-4 py-3">
{user.role === "admin" ? (
<span className="inline-flex items-center rounded-full border border-link/40 bg-link/10 px-2 py-0.5 text-xs font-medium text-link">
Admin
</span>
) : (
<span className="inline-flex items-center rounded-full border border-edge-strong px-2 py-0.5 text-xs font-medium text-ink-muted">
Author
</span>
)}
</td>
<td className="px-4 py-3 text-ink-muted">
{user.role === "admin"
? "All tags"
: user.tags.length === 0
? "None yet"
: user.tags.map((t) => t.name).join(", ")}
</td>
<td className="px-4 py-3 text-ink-muted">
{user.role === "admin"
? "Everything"
: [
user.canPublishPosts && "publish",
user.canUnpublishPosts && "unpublish",
user.canDeletePosts && "delete",
user.canCreateTags && "create tags",
user.canApproveComments && "approve comments",
]
.filter(Boolean)
.join(", ") || "Write drafts only"}
</td>
<td className="whitespace-nowrap px-4 py-3 text-ink-muted">
{formatDate(user.createdAt)}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
<Link
href={`/admin/users/${user.id}/edit`}
className="rounded-md px-2 py-1 text-xs font-medium text-ink-muted transition-colors hover:bg-background hover:text-ink-strong"
>
Edit
</Link>
{user.role !== "admin" && (
<form action={deleteUserAction.bind(null, user.id)}>
<ConfirmButton
confirmMessage={`Delete the account “${user.username}”? Their posts are kept and become admin-managed. This cannot be undone.`}
className="border-none px-2 py-1 text-xs"
>
Delete
</ConfirmButton>
</form>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="mt-3 text-sm text-ink-muted">
Authors can write, edit, and publish their own posts under the tags you grant them.
Only you can delete posts, manage pages, moderate comments, or change settings.
</p>
</div>
);
}

View file

@ -1,31 +0,0 @@
import type { Metadata } from "next";
import Link from "next/link";
import { redirect } from "next/navigation";
import { LoginForm } from "@/components/admin/LoginForm";
import { getSessionUser } from "@/lib/auth/dal";
export const metadata: Metadata = {
title: "Sign in",
robots: { index: false, follow: false },
};
export default async function LoginPage() {
const user = await getSessionUser();
if (user) redirect("/admin");
return (
<main id="main" className="container-site flex flex-1 items-center justify-center py-16">
<div className="w-full max-w-sm">
<div className="rounded-lg border border-edge bg-surface p-6 sm:p-8">
<h1 className="mb-6 text-xl font-semibold text-ink-bright">Admin sign in</h1>
<LoginForm />
</div>
<p className="mt-6 text-center text-sm">
<Link href="/" className="text-ink-muted underline underline-offset-4 hover:text-link">
Back to the site
</Link>
</p>
</div>
</main>
);
}

View file

@ -1,19 +0,0 @@
import { requireAdmin } from "@/lib/auth/dal";
import { buildSiteExport } from "@/lib/services/import-export";
/**
* Full-site backup download. A plain browser navigation (link on the
* settings page), so signed-out visitors are redirected to the login page
* by requireAdmin rather than shown a JSON error.
*/
export async function GET(): Promise<Response> {
await requireAdmin();
const data = await buildSiteExport();
const date = data.exportedAt.toISOString().slice(0, 10);
return new Response(JSON.stringify(data, null, 2), {
headers: {
"Content-Type": "application/json",
"Content-Disposition": `attachment; filename="yap-blog-export-${date}.json"`,
},
});
}

View file

@ -1,39 +0,0 @@
import { NextResponse } from "next/server";
import { getSessionUser } from "@/lib/auth/dal";
import { saveUploadedImage } from "@/lib/uploads";
/**
* Image upload endpoint for the admin editor. JSON errors (not redirects)
* because the caller is fetch(), not a browser navigation. Auth is checked
* server-side exactly like every admin mutation.
*/
export async function POST(request: Request): Promise<NextResponse> {
const user = await getSessionUser();
if (!user) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let file: FormDataEntryValue | null = null;
try {
file = (await request.formData()).get("file");
} catch {
return NextResponse.json({ error: "Malformed upload request." }, { status: 400 });
}
if (!(file instanceof File)) {
return NextResponse.json({ error: "No file was provided." }, { status: 400 });
}
try {
const result = await saveUploadedImage(file);
if (!result.ok) {
return NextResponse.json({ error: result.error }, { status: result.status });
}
return NextResponse.json({ url: `/uploads/${result.filename}` }, { status: 201 });
} catch (error) {
console.error("upload failed", error);
return NextResponse.json(
{ error: "The image could not be saved. Please try again." },
{ status: 500 },
);
}
}

View file

@ -1,35 +0,0 @@
"use client";
import { useEffect } from "react";
export default function ErrorPage({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// Full details stay in the server logs; the digest links the two.
console.error(error);
}, [error]);
return (
<main id="main" className="container-site flex flex-1 items-center justify-center py-24">
<div className="w-full max-w-md rounded-lg border border-edge bg-surface p-8 text-center">
<h1 className="text-xl font-semibold text-ink-strong">Something went wrong</h1>
<p className="mt-3 text-sm text-ink-muted">
An unexpected error occurred. It has been logged on the server
{error.digest ? ` (reference ${error.digest})` : ""}.
</p>
<button
type="button"
onClick={reset}
className="mt-6 inline-flex items-center rounded-md bg-link px-4 py-2 text-sm font-medium text-ink-inverse transition-colors hover:bg-link-hover"
>
Try again
</button>
</div>
</main>
);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View file

@ -1,20 +0,0 @@
import { buildRssXml } from "@/lib/feed";
import { resolveSiteUrl } from "@/lib/seo";
import { listPublishedPosts } from "@/lib/services/posts";
import { getSettings } from "@/lib/services/settings";
/** RSS 2.0 feed of the 20 newest published posts. */
export async function GET(): Promise<Response> {
const [settings, page] = await Promise.all([
getSettings(),
listPublishedPosts({ page: 1, perPage: 20 }),
]);
const xml = buildRssXml({
settings,
siteUrl: resolveSiteUrl(settings),
posts: page.items,
});
return new Response(xml, {
headers: { "Content-Type": "application/rss+xml; charset=utf-8" },
});
}

View file

@ -1,52 +0,0 @@
"use client";
// Last-resort boundary: rendered when the root layout itself fails
// (e.g. the database is unreachable). Must provide its own <html>.
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
return (
<html lang="en">
<body
style={{
margin: 0,
minHeight: "100vh",
display: "grid",
placeItems: "center",
background: "#002b36",
color: "#839496",
fontFamily: "ui-sans-serif, system-ui, sans-serif",
}}
>
<div style={{ textAlign: "center", padding: "2rem" }}>
<h1 style={{ color: "#93a1a1", fontSize: "1.25rem" }}>The site is unavailable</h1>
<p style={{ fontSize: "0.875rem", maxWidth: "28rem" }}>
Something went wrong while loading the site
{error.digest ? ` (reference ${error.digest})` : ""}. Please try again in a moment.
</p>
<button
type="button"
onClick={reset}
style={{
marginTop: "1rem",
background: "#268bd2",
color: "#002b36",
border: "none",
borderRadius: "6px",
padding: "0.5rem 1rem",
fontSize: "0.875rem",
fontWeight: 600,
cursor: "pointer",
}}
>
Try again
</button>
</div>
</body>
</html>
);
}

View file

@ -1,591 +0,0 @@
@import "tailwindcss";
/*
* Theming architecture
* --------------------
* 1. Raw Solarized palette (--sol-*). Components never reference these.
* 2. Semantic tokens (--background, --ink, ...) the theme contract.
* :root carries Solarized Dark; each additional theme overrides ONLY
* the tokens, keyed by the data-theme attribute the root layout sets
* on <html> from the site settings (see [data-theme="solarized-light"]
* below). The eight Solarized accent colors are shared by design
* only the base tones flip between dark and light.
* 3. `@theme inline` exposes the semantic tokens as Tailwind utilities
* (bg-background, text-ink, border-edge, ...). The generated utilities
* resolve through var() at runtime, so swapping tokens re-skins the app
* without touching any component.
*
* Fonts follow the same pattern: --font-body defaults to Geist and is
* remapped per [data-font="..."]; Tailwind's font-sans resolves through it.
*
* Adding a theme = one override block here + one enum value in
* src/db/schema.ts + one entry in src/lib/themes.ts (the Record type
* makes a missing entry a compile error). Fonts additionally need a
* next/font instance in src/app/layout.tsx.
*/
:root {
--sol-base03: #002b36;
--sol-base02: #073642;
--sol-base01: #586e75;
--sol-base00: #657b83;
--sol-base0: #839496;
--sol-base1: #93a1a1;
--sol-base2: #eee8d5;
--sol-base3: #fdf6e3;
--sol-yellow: #b58900;
--sol-orange: #cb4b16;
--sol-red: #dc322f;
--sol-magenta: #d33682;
--sol-violet: #6c71c4;
--sol-blue: #268bd2;
--sol-cyan: #2aa198;
--sol-green: #859900;
/* Semantic tokens (Solarized Dark) */
--background: var(--sol-base03);
--surface: var(--sol-base02);
--edge: color-mix(in oklab, var(--sol-base02) 70%, var(--sol-base01));
--edge-strong: color-mix(in oklab, var(--sol-base02) 35%, var(--sol-base01));
--ink: var(--sol-base0);
--ink-muted: color-mix(in oklab, var(--sol-base01) 78%, var(--sol-base0));
--ink-strong: var(--sol-base1);
--ink-bright: var(--sol-base2);
--ink-inverse: var(--sol-base03);
--link: var(--sol-blue);
--link-hover: var(--sol-cyan);
--accent: var(--sol-violet);
--danger: var(--sol-red);
--warning: var(--sol-yellow);
--success: var(--sol-green);
--code: var(--sol-cyan);
--focus: var(--sol-blue);
}
@theme inline {
--color-background: var(--background);
--color-surface: var(--surface);
--color-edge: var(--edge);
--color-edge-strong: var(--edge-strong);
--color-ink: var(--ink);
--color-ink-muted: var(--ink-muted);
--color-ink-strong: var(--ink-strong);
--color-ink-bright: var(--ink-bright);
--color-ink-inverse: var(--ink-inverse);
--color-link: var(--link);
--color-link-hover: var(--link-hover);
--color-accent: var(--accent);
--color-danger: var(--danger);
--color-warning: var(--warning);
--color-success: var(--success);
--color-code: var(--code);
--color-focus: var(--focus);
--font-sans: var(--font-body);
--font-mono: var(--font-geist-mono);
}
/* Solarized Light: same accents, base tones mirrored around the midpoint. */
[data-theme="solarized-light"] {
color-scheme: light;
--background: var(--sol-base3);
--surface: var(--sol-base2);
--edge: color-mix(in oklab, var(--sol-base2) 70%, var(--sol-base1));
--edge-strong: color-mix(in oklab, var(--sol-base2) 35%, var(--sol-base1));
--ink: var(--sol-base00);
--ink-muted: color-mix(in oklab, var(--sol-base1) 55%, var(--sol-base00));
--ink-strong: var(--sol-base01);
--ink-bright: var(--sol-base02);
/* --ink-inverse stays base03: dark-on-blue keeps AA-ish contrast in both themes. */
}
/* Dracula — https://draculatheme.com (bg 282a36, fg f8f8f2, comment 6272a4) */
[data-theme="dracula"] {
color-scheme: dark;
--background: #282a36;
--surface: #21222c;
--edge: color-mix(in oklab, #21222c 65%, #6272a4);
--edge-strong: color-mix(in oklab, #21222c 30%, #6272a4);
--ink: color-mix(in oklab, #f8f8f2 78%, #6272a4);
--ink-muted: #6272a4;
--ink-strong: #f8f8f2;
--ink-bright: #ffffff;
--ink-inverse: #282a36;
--link: #bd93f9;
--link-hover: #ff79c6;
--accent: #8be9fd;
--danger: #ff5555;
--warning: #f1fa8c;
--success: #50fa7b;
--code: #8be9fd;
--focus: #bd93f9;
}
/* Nord — https://nordtheme.com (Polar Night / Snow Storm / Frost / Aurora) */
[data-theme="nord"] {
color-scheme: dark;
--background: #2e3440;
--surface: #3b4252;
--edge: #434c5e;
--edge-strong: #4c566a;
--ink: #d8dee9;
--ink-muted: color-mix(in oklab, #4c566a 45%, #d8dee9);
--ink-strong: #e5e9f0;
--ink-bright: #eceff4;
--ink-inverse: #2e3440;
--link: #88c0d0;
--link-hover: #8fbcbb;
--accent: #b48ead;
--danger: #bf616a;
--warning: #ebcb8b;
--success: #a3be8c;
--code: #8fbcbb;
--focus: #88c0d0;
}
/* Gruvbox Dark — https://github.com/morhetz/gruvbox (medium contrast) */
[data-theme="gruvbox-dark"] {
color-scheme: dark;
--background: #282828;
--surface: #3c3836;
--edge: color-mix(in oklab, #3c3836 65%, #928374);
--edge-strong: color-mix(in oklab, #3c3836 30%, #928374);
--ink: #d5c4a1;
--ink-muted: #928374;
--ink-strong: #ebdbb2;
--ink-bright: #fbf1c7;
--ink-inverse: #282828;
--link: #fe8019;
--link-hover: #fabd2f;
--accent: #d3869b;
--danger: #fb4934;
--warning: #fabd2f;
--success: #b8bb26;
--code: #8ec07c;
--focus: #fe8019;
}
/* Catppuccin Mocha — https://catppuccin.com (base/mantle + pastel accents) */
[data-theme="catppuccin-mocha"] {
color-scheme: dark;
--background: #1e1e2e;
--surface: #181825;
--edge: color-mix(in oklab, #181825 65%, #6c7086);
--edge-strong: color-mix(in oklab, #181825 35%, #6c7086);
--ink: #bac2de;
--ink-muted: color-mix(in oklab, #6c7086 70%, #a6adc8);
--ink-strong: #cdd6f4;
--ink-bright: color-mix(in oklab, #cdd6f4 70%, white);
--ink-inverse: #1e1e2e;
--link: #89b4fa;
--link-hover: #b4befe;
--accent: #cba6f7;
--danger: #f38ba8;
--warning: #f9e2af;
--success: #a6e3a1;
--code: #94e2d5;
--focus: #89b4fa;
}
/* Catppuccin Latte — the light Catppuccin flavor */
[data-theme="catppuccin-latte"] {
color-scheme: light;
--background: #eff1f5;
--surface: #e6e9ef;
--edge: #ccd0da;
--edge-strong: #acb0be;
--ink: #5c5f77;
--ink-muted: #8c8fa1;
--ink-strong: #4c4f69;
--ink-bright: color-mix(in oklab, #4c4f69 75%, black);
--ink-inverse: #eff1f5;
--link: #1e66f5;
--link-hover: #8839ef;
--accent: #8839ef;
--danger: #d20f39;
--warning: #df8e1d;
--success: #40a02b;
--code: #179299;
--focus: #1e66f5;
}
/* Tokyo Night — https://github.com/tokyo-night (storm-free classic) */
[data-theme="tokyo-night"] {
color-scheme: dark;
--background: #1a1b26;
--surface: #16161e;
--edge: #292e42;
--edge-strong: #3b4261;
--ink: #a9b1d6;
--ink-muted: #565f89;
--ink-strong: #c0caf5;
--ink-bright: color-mix(in oklab, #c0caf5 75%, white);
--ink-inverse: #1a1b26;
--link: #7aa2f7;
--link-hover: #7dcfff;
--accent: #bb9af7;
--danger: #f7768e;
--warning: #e0af68;
--success: #9ece6a;
--code: #7dcfff;
--focus: #7aa2f7;
}
/* One Dark — Atom's classic */
[data-theme="one-dark"] {
color-scheme: dark;
--background: #282c34;
--surface: #21252b;
--edge: color-mix(in oklab, #21252b 60%, #4b5263);
--edge-strong: #4b5263;
--ink: #abb2bf;
--ink-muted: #5c6370;
--ink-strong: #d7dae0;
--ink-bright: color-mix(in oklab, #d7dae0 70%, white);
--ink-inverse: #282c34;
--link: #61afef;
--link-hover: #56b6c2;
--accent: #c678dd;
--danger: #e06c75;
--warning: #e5c07b;
--success: #98c379;
--code: #56b6c2;
--focus: #61afef;
}
/* Rosé Pine — https://rosepinetheme.com (main variant) */
[data-theme="rose-pine"] {
color-scheme: dark;
--background: #191724;
--surface: #1f1d2e;
--edge: #26233a;
--edge-strong: #403d52;
--ink: color-mix(in oklab, #e0def4 75%, #908caa);
--ink-muted: #908caa;
--ink-strong: #e0def4;
--ink-bright: color-mix(in oklab, #e0def4 75%, white);
--ink-inverse: #191724;
--link: #c4a7e7;
--link-hover: #ebbcba;
--accent: #9ccfd8;
--danger: #eb6f92;
--warning: #f6c177;
--success: #9ccfd8;
--code: #ebbcba;
--focus: #c4a7e7;
}
/* Everforest Dark — https://github.com/sainnhe/everforest (medium) */
[data-theme="everforest-dark"] {
color-scheme: dark;
--background: #2d353b;
--surface: #343f44;
--edge: #475258;
--edge-strong: #4f585e;
--ink: color-mix(in oklab, #d3c6aa 82%, #859289);
--ink-muted: #859289;
--ink-strong: #d3c6aa;
--ink-bright: color-mix(in oklab, #d3c6aa 78%, white);
--ink-inverse: #2d353b;
--link: #7fbbb3;
--link-hover: #83c092;
--accent: #d699b6;
--danger: #e67e80;
--warning: #dbbc7f;
--success: #a7c080;
--code: #83c092;
--focus: #7fbbb3;
}
/* Monokai — the TextMate/Sublime classic */
[data-theme="monokai"] {
color-scheme: dark;
--background: #272822;
--surface: #1e1f1c;
--edge: #3e3d32;
--edge-strong: #57584f;
--ink: color-mix(in oklab, #f8f8f2 78%, #75715e);
--ink-muted: #75715e;
--ink-strong: #f8f8f2;
--ink-bright: #ffffff;
--ink-inverse: #272822;
--link: #66d9ef;
--link-hover: #a6e22e;
--accent: #ae81ff;
--danger: #f92672;
--warning: #e6db74;
--success: #a6e22e;
--code: #e6db74;
--focus: #66d9ef;
}
/* GitHub Light — the default github.com palette */
[data-theme="github-light"] {
color-scheme: light;
--background: #ffffff;
--surface: #f6f8fa;
--edge: #d8dee4;
--edge-strong: #afb8c1;
--ink: #24292f;
--ink-muted: #656d76;
--ink-strong: #1f2328;
--ink-bright: #000000;
--ink-inverse: #ffffff;
--link: #0969da;
--link-hover: #0550ae;
--accent: #8250df;
--danger: #cf222e;
--warning: #9a6700;
--success: #1a7f37;
--code: #953800;
--focus: #0969da;
}
/* White on Black — the inverted companion to Black & White. */
[data-theme="mono-dark"] {
color-scheme: dark;
--background: #0a0a0a;
--surface: #171717;
--edge: #2e2e2e;
--edge-strong: #454545;
--ink: #d4d4d4;
--ink-muted: #8a8a8a;
--ink-strong: #f5f5f5;
--ink-bright: #ffffff;
--ink-inverse: #0a0a0a;
--link: #ffffff;
--link-hover: #b3b3b3;
--accent: #ffffff;
--danger: #f0f0f0;
--warning: #bdbdbd;
--success: #f0f0f0;
--code: #fafafa;
--focus: #ffffff;
}
/* Black & White — plain paper: grayscale only, by design. */
[data-theme="mono"] {
color-scheme: light;
--background: #ffffff;
--surface: #f5f5f5;
--edge: #e2e2e2;
--edge-strong: #c6c6c6;
--ink: #333333;
--ink-muted: #6e6e6e;
--ink-strong: #111111;
--ink-bright: #000000;
--ink-inverse: #ffffff;
--link: #000000;
--link-hover: #555555;
--accent: #000000;
--danger: #1a1a1a;
--warning: #4a4a4a;
--success: #1a1a1a;
--code: #111111;
--focus: #000000;
}
/* Body font: default Geist, remapped per data-font (see layout.tsx). */
:root {
--font-body: var(--font-geist-sans);
}
[data-font="inter"] {
--font-body: var(--font-inter);
}
[data-font="lora"] {
--font-body: var(--font-lora);
}
[data-font="merriweather"] {
--font-body: var(--font-merriweather);
}
[data-font="jetbrains-mono"] {
--font-body: var(--font-jetbrains-mono);
}
[data-font="source-serif"] {
--font-body: var(--font-source-serif);
}
[data-font="eb-garamond"] {
--font-body: var(--font-eb-garamond);
}
[data-font="playfair-display"] {
--font-body: var(--font-playfair);
}
[data-font="open-sans"] {
--font-body: var(--font-open-sans);
}
[data-font="work-sans"] {
--font-body: var(--font-work-sans);
}
[data-font="atkinson-hyperlegible"] {
--font-body: var(--font-atkinson);
}
[data-font="space-grotesk"] {
--font-body: var(--font-space-grotesk);
}
html {
color-scheme: dark;
}
body {
background: var(--background);
color: var(--ink);
}
/* Token-driven so every theme gets a sensible selection color. */
::selection {
background: var(--link);
color: var(--ink-inverse);
}
:focus-visible {
outline: 2px solid var(--focus);
outline-offset: 2px;
border-radius: 2px;
}
.container-site {
margin-inline: auto;
width: 100%;
max-width: 72rem;
padding-inline: 1rem;
}
@media (min-width: 640px) {
.container-site {
padding-inline: 1.5rem;
}
}
/* Rendered Markdown (posts, pages, editor preview) */
.markdown-body {
line-height: 1.75;
overflow-wrap: break-word;
}
.markdown-body > * + * {
margin-top: 1em;
}
.markdown-body h1,
.markdown-body h2,
.markdown-body h3,
.markdown-body h4,
.markdown-body h5,
.markdown-body h6 {
color: var(--ink-strong);
font-weight: 600;
line-height: 1.3;
margin-top: 1.6em;
}
.markdown-body h1 { font-size: 1.6rem; }
.markdown-body h2 { font-size: 1.35rem; }
.markdown-body h3 { font-size: 1.15rem; }
.markdown-body h4 { font-size: 1rem; }
.markdown-body a {
color: var(--link);
text-decoration: underline;
text-underline-offset: 3px;
}
.markdown-body a:hover {
color: var(--link-hover);
}
.markdown-body strong {
color: var(--ink-strong);
font-weight: 600;
}
.markdown-body ul,
.markdown-body ol {
padding-left: 1.5rem;
}
.markdown-body ul { list-style: disc; }
.markdown-body ol { list-style: decimal; }
.markdown-body li + li { margin-top: 0.35em; }
.markdown-body li::marker { color: var(--ink-muted); }
.markdown-body blockquote {
border-left: 3px solid var(--ink-muted);
padding-left: 1rem;
color: var(--ink-strong);
font-style: italic;
}
.markdown-body code {
font-family: var(--font-geist-mono), monospace;
font-size: 0.875em;
color: var(--code);
background: var(--surface);
border: 1px solid var(--edge);
border-radius: 4px;
padding: 0.125em 0.375em;
}
.markdown-body pre {
background: var(--surface);
border: 1px solid var(--edge);
border-radius: 8px;
padding: 1rem;
overflow-x: auto;
}
.markdown-body pre code {
background: none;
border: none;
padding: 0;
color: var(--ink-strong);
font-size: 0.85rem;
}
.markdown-body table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
display: block;
overflow-x: auto;
}
.markdown-body th,
.markdown-body td {
border: 1px solid var(--edge-strong);
padding: 0.5rem 0.75rem;
text-align: left;
}
.markdown-body th {
background: var(--surface);
color: var(--ink-strong);
font-weight: 600;
}
.markdown-body img {
max-width: 100%;
height: auto;
border-radius: 8px;
border: 1px solid var(--edge);
}
.markdown-body hr {
border: none;
border-top: 1px solid var(--edge-strong);
margin-block: 2rem;
}
/* Rich text editor (Tiptap) chrome */
.editor-shell:focus-within {
outline: 2px solid var(--focus);
outline-offset: 2px;
}
.editor-shell .tiptap p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
color: var(--ink-muted);
}
.editor-shell .tiptap img.ProseMirror-selectednode {
outline: 2px solid var(--focus);
outline-offset: 2px;
}
.editor-shell .tiptap .selectedCell {
position: relative;
}
.editor-shell .tiptap .selectedCell::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
background: color-mix(in oklab, var(--focus) 18%, transparent);
}
.editor-shell .tiptap table {
display: table; /* editable tables need real table layout, not scroll wrapper */
}
.editor-shell .tiptap .ProseMirror-gapcursor:after {
border-top: 1px solid var(--ink-strong);
}

View file

@ -1,191 +0,0 @@
import type { Metadata, Viewport } from "next";
import {
Atkinson_Hyperlegible,
EB_Garamond,
Geist,
Geist_Mono,
Inter,
JetBrains_Mono,
Lora,
Merriweather,
Open_Sans,
Playfair_Display,
Source_Serif_4,
Space_Grotesk,
Work_Sans,
} from "next/font/google";
import "./globals.css";
import type { Settings } from "@/db/schema";
import { FEED_PATH, resolveSiteUrl } from "@/lib/seo";
import { DEFAULT_SETTINGS, getSettings } from "@/lib/services/settings";
import { THEME_META } from "@/lib/themes";
// The entire site is driven by database content that admins change at any
// time, so every route renders per request (no build-time DB dependency).
// Incremental caching/revalidation is a documented future optimization.
export const dynamic = "force-dynamic";
// All selectable body fonts are self-hosted by next/font at build time.
// Only Geist (the default) is preloaded; the others declare @font-face
// rules and are fetched by the browser solely when the admin-selected
// data-font attribute makes one of them the active --font-body.
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
const inter = Inter({
variable: "--font-inter",
subsets: ["latin"],
preload: false,
});
const lora = Lora({
variable: "--font-lora",
subsets: ["latin"],
style: ["normal", "italic"],
preload: false,
});
const merriweather = Merriweather({
variable: "--font-merriweather",
subsets: ["latin"],
weight: ["300", "400", "700"],
style: ["normal", "italic"],
preload: false,
});
const jetbrainsMono = JetBrains_Mono({
variable: "--font-jetbrains-mono",
subsets: ["latin"],
style: ["normal", "italic"],
preload: false,
});
const sourceSerif = Source_Serif_4({
variable: "--font-source-serif",
subsets: ["latin"],
style: ["normal", "italic"],
preload: false,
});
const ebGaramond = EB_Garamond({
variable: "--font-eb-garamond",
subsets: ["latin"],
style: ["normal", "italic"],
preload: false,
});
const playfair = Playfair_Display({
variable: "--font-playfair",
subsets: ["latin"],
style: ["normal", "italic"],
preload: false,
});
const openSans = Open_Sans({
variable: "--font-open-sans",
subsets: ["latin"],
style: ["normal", "italic"],
preload: false,
});
const workSans = Work_Sans({
variable: "--font-work-sans",
subsets: ["latin"],
style: ["normal", "italic"],
preload: false,
});
const atkinson = Atkinson_Hyperlegible({
variable: "--font-atkinson",
subsets: ["latin"],
weight: ["400", "700"],
style: ["normal", "italic"],
preload: false,
});
const spaceGrotesk = Space_Grotesk({
variable: "--font-space-grotesk",
subsets: ["latin"],
preload: false,
});
const fontVariables = [
geistSans.variable,
geistMono.variable,
inter.variable,
lora.variable,
merriweather.variable,
jetbrainsMono.variable,
sourceSerif.variable,
ebGaramond.variable,
playfair.variable,
openSans.variable,
workSans.variable,
atkinson.variable,
spaceGrotesk.variable,
].join(" ");
/** The chrome must render even when the DB is down; fall back to defaults. */
async function settingsOrDefaults(): Promise<Settings> {
try {
return await getSettings();
} catch {
return DEFAULT_SETTINGS;
}
}
export async function generateMetadata(): Promise<Metadata> {
const settings = await settingsOrDefaults();
return {
// Resolves every relative canonical/OG URL below to the public origin.
metadataBase: new URL(resolveSiteUrl(settings)),
title: { default: settings.siteTitle, template: `%s · ${settings.siteTitle}` },
description: settings.headerText || undefined,
alternates: {
types: { "application/rss+xml": [{ url: FEED_PATH, title: settings.siteTitle }] },
},
openGraph: {
siteName: settings.siteTitle,
type: "website",
locale: "en",
},
};
}
export async function generateViewport(): Promise<Viewport> {
const { theme } = await settingsOrDefaults();
return { themeColor: THEME_META[theme].bg };
}
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const { theme, font } = await settingsOrDefaults();
return (
<html
lang="en"
data-theme={theme}
data-font={font}
className={`${fontVariables} h-full antialiased`}
>
<body className="flex min-h-dvh flex-col font-sans">
<a
href="#main"
className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-[100] focus:rounded-md focus:bg-link focus:px-4 focus:py-2 focus:text-ink-inverse"
>
Skip to content
</a>
{children}
</body>
</html>
);
}

View file

@ -1,24 +0,0 @@
import Link from "next/link";
// Fallback 404 for URLs outside the public group (e.g. bad /admin paths).
// Public-site 404s use src/app/(public)/not-found.tsx, which keeps the
// header, sidebar, and footer around the message.
export default function RootNotFound() {
return (
<main id="main" className="container-site flex flex-1 items-center justify-center py-24">
<div className="text-center">
<p className="font-mono text-sm text-ink-muted">404</p>
<h1 className="mt-2 text-2xl font-semibold text-ink-strong">Page not found</h1>
<p className="mt-3 text-sm text-ink-muted">
The page you are looking for does not exist or has been removed.
</p>
<Link
href="/"
className="mt-6 inline-flex items-center rounded-md bg-link px-4 py-2 text-sm font-medium text-ink-inverse transition-colors hover:bg-link-hover"
>
Back to the blog
</Link>
</div>
</main>
);
}

View file

@ -1,18 +0,0 @@
import type { MetadataRoute } from "next";
import { resolveSiteUrl } from "@/lib/seo";
import { getSettings } from "@/lib/services/settings";
// The sitemap URL depends on the admin-configured site URL setting.
export const dynamic = "force-dynamic";
export default async function robots(): Promise<MetadataRoute.Robots> {
const base = resolveSiteUrl(await getSettings());
return {
rules: {
userAgent: "*",
allow: "/",
disallow: ["/admin/", "/api/"],
},
sitemap: `${base}/sitemap.xml`,
};
}

View file

@ -1,54 +0,0 @@
import type { MetadataRoute } from "next";
import { resolveSiteUrl } from "@/lib/seo";
// DB-driven like every other route: render per request, or the sitemap
// would freeze at whatever was published when the build ran.
export const dynamic = "force-dynamic";
import { listPublishedPages } from "@/lib/services/pages";
import { listPublishedPostSummaries } from "@/lib/services/posts";
import { getSettings } from "@/lib/services/settings";
import { listPublicTags } from "@/lib/services/tags";
/**
* Everything a crawler should index: the home page, the post list,
* every published post and page, and every publicly visible tag.
* Drafts never appear (the queries filter them), matching the 404s
* their URLs return.
*/
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const [settings, posts, pages, tags] = await Promise.all([
getSettings(),
listPublishedPostSummaries(),
listPublishedPages(),
listPublicTags(),
]);
const base = resolveSiteUrl(settings);
const newestPost = posts[0]?.updatedAt;
return [
{ url: base, lastModified: newestPost, changeFrequency: "daily", priority: 1 },
{
url: `${base}/posts`,
lastModified: newestPost,
changeFrequency: "daily",
priority: 0.9,
},
...posts.map((post) => ({
url: `${base}/posts/${post.slug}`,
lastModified: post.updatedAt,
changeFrequency: "monthly" as const,
priority: 0.8,
})),
...pages.map((page) => ({
url: `${base}/pages/${page.slug}`,
lastModified: page.updatedAt,
changeFrequency: "monthly" as const,
priority: 0.6,
})),
...tags.map((tag) => ({
url: `${base}/tags/${tag.slug}`,
changeFrequency: "weekly" as const,
priority: 0.4,
})),
];
}

View file

@ -1,37 +0,0 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { MIME_BY_EXTENSION, UPLOAD_NAME_PATTERN } from "@/lib/uploads";
export const dynamic = "force-dynamic";
/**
* Serves editor-uploaded images from the uploads directory. The filename
* pattern is locked to what saveUploadedImage generates (UUID + known
* extension), which rules out path traversal by construction. UUID names
* never change content, so responses are immutable-cacheable.
*/
export async function GET(
_request: Request,
{ params }: { params: Promise<{ name: string }> },
): Promise<Response> {
const { name } = await params;
if (!UPLOAD_NAME_PATTERN.test(name)) {
return new Response("Not found", { status: 404 });
}
let data: Buffer;
try {
data = await readFile(path.join(process.cwd(), "uploads", name));
} catch {
return new Response("Not found", { status: 404 });
}
const extension = path.extname(name).slice(1);
return new Response(new Uint8Array(data), {
headers: {
"Content-Type": MIME_BY_EXTENSION[extension] ?? "application/octet-stream",
"Content-Length": String(data.byteLength),
"Cache-Control": "public, max-age=31536000, immutable",
},
});
}

View file

@ -1,56 +0,0 @@
"use client";
import { useActionState, useId } from "react";
import { Flash, FormErrorBanner } from "@/components/admin/Flash";
import { SubmitButton } from "@/components/admin/SubmitButton";
import { ErrorText, HelpText, Input, Label } from "@/components/ui";
import { type FormState, firstFieldError, initialFormState } from "@/lib/forms";
type Props = {
action: (prev: FormState, formData: FormData) => Promise<FormState>;
};
export function ChangePasswordForm({ action }: Props) {
const [state, formAction] = useActionState(action, initialFormState);
const ids = useId();
const err = (field: string) => firstFieldError(state, field);
return (
<form action={formAction} className="max-w-md space-y-5">
{state.status === "success" && <Flash>Password changed.</Flash>}
<FormErrorBanner>{state.formError}</FormErrorBanner>
<div>
<Label htmlFor={`${ids}-current`}>Current password</Label>
<Input
id={`${ids}-current`}
name="currentPassword"
type="password"
required
maxLength={200}
autoComplete="current-password"
aria-invalid={err("currentPassword") ? true : undefined}
/>
<ErrorText>{err("currentPassword")}</ErrorText>
</div>
<div>
<Label htmlFor={`${ids}-new`}>New password</Label>
<Input
id={`${ids}-new`}
name="newPassword"
type="password"
required
minLength={8}
maxLength={200}
autoComplete="new-password"
aria-invalid={err("newPassword") ? true : undefined}
/>
<HelpText>At least 8 characters.</HelpText>
<ErrorText>{err("newPassword")}</ErrorText>
</div>
<SubmitButton>Change password</SubmitButton>
</form>
);
}

View file

@ -1,33 +0,0 @@
"use client";
import { Button, type ButtonVariant } from "@/components/ui";
/**
* Submit button for destructive form actions that asks for confirmation
* first. window.confirm is fully keyboard-accessible and needs no extra
* dialog plumbing the right size for this MVP.
*/
export function ConfirmButton({
confirmMessage,
children,
variant = "danger",
className,
}: {
confirmMessage: string;
children: React.ReactNode;
variant?: ButtonVariant;
className?: string;
}) {
return (
<Button
type="submit"
variant={variant}
className={className}
onClick={(event) => {
if (!window.confirm(confirmMessage)) event.preventDefault();
}}
>
{children}
</Button>
);
}

View file

@ -1,22 +0,0 @@
export function Flash({ children }: { children: React.ReactNode }) {
return (
<p
role="status"
className="mb-6 rounded-md border border-success/40 bg-success/10 px-4 py-2.5 text-sm text-success"
>
{children}
</p>
);
}
export function FormErrorBanner({ children }: { children?: React.ReactNode }) {
if (!children) return null;
return (
<p
role="alert"
className="rounded-md border border-danger/40 bg-danger/10 px-4 py-2.5 text-sm text-danger"
>
{children}
</p>
);
}

View file

@ -1,85 +0,0 @@
"use client";
import { useRef } from "react";
import { cx } from "@/components/ui";
export type FormTabDef = {
id: string;
label: string;
/** Shows a dot on the tab when its panel contains validation errors. */
hasError?: boolean;
};
/**
* ARIA tabs for the editor forms. Panels are rendered by the parent with
* `hidden` (never unmounted) so every input keeps its value and still
* submits with the form regardless of which tab is visible.
*/
export function FormTabs({
tabs,
activeId,
onSelect,
idBase,
label,
}: {
tabs: FormTabDef[];
activeId: string;
onSelect: (id: string) => void;
idBase: string;
label: string;
}) {
const buttonsRef = useRef<Map<string, HTMLButtonElement>>(new Map());
function focusAndSelect(index: number) {
const tab = tabs[(index + tabs.length) % tabs.length];
buttonsRef.current.get(tab.id)?.focus();
onSelect(tab.id);
}
function onKeyDown(event: React.KeyboardEvent, index: number) {
if (event.key === "ArrowRight") focusAndSelect(index + 1);
else if (event.key === "ArrowLeft") focusAndSelect(index - 1);
else if (event.key === "Home") focusAndSelect(0);
else if (event.key === "End") focusAndSelect(tabs.length - 1);
else return;
event.preventDefault();
}
return (
<div role="tablist" aria-label={label} className="flex items-end gap-1">
{tabs.map((tab, index) => {
const active = tab.id === activeId;
return (
<button
key={tab.id}
ref={(el) => {
if (el) buttonsRef.current.set(tab.id, el);
else buttonsRef.current.delete(tab.id);
}}
type="button"
role="tab"
id={`${idBase}-tab-${tab.id}`}
aria-selected={active}
aria-controls={`${idBase}-panel-${tab.id}`}
tabIndex={active ? 0 : -1}
onClick={() => onSelect(tab.id)}
onKeyDown={(event) => onKeyDown(event, index)}
className={cx(
"inline-flex items-center gap-1.5 rounded-t-md border-b-2 px-3.5 py-2 text-sm font-medium transition-colors",
active
? "border-link text-ink-strong"
: "border-transparent text-ink-muted hover:text-ink-strong",
)}
>
{tab.label}
{tab.hasError && (
<span className="size-1.5 rounded-full bg-danger">
<span className="sr-only">(contains errors)</span>
</span>
)}
</button>
);
})}
</div>
);
}

View file

@ -1,65 +0,0 @@
"use client";
import { useActionState, useId } from "react";
import { ConfirmButton } from "@/components/admin/ConfirmButton";
import { Flash, FormErrorBanner } from "@/components/admin/Flash";
import { buttonVariants, Input, Label } from "@/components/ui";
import { type FormState, initialFormState } from "@/lib/forms";
type Props = {
importAction: (prev: FormState, formData: FormData) => Promise<FormState>;
};
export function ImportExportSection({ importAction }: Props) {
const [state, formAction] = useActionState(importAction, initialFormState);
const ids = useId();
return (
<section aria-labelledby={`${ids}-backup`} className="max-w-3xl space-y-5 border-t border-edge pt-8">
<h2 id={`${ids}-backup`} className="border-b border-edge pb-2 text-lg font-semibold text-ink-strong">
Backup
</h2>
<div>
<h3 className="text-sm font-medium text-ink-strong">Export</h3>
<p className="mt-1 mb-3 text-sm text-ink-muted">
Download all posts, pages, tags, navigation, and settings as a single JSON file.
Uploaded images are served from the <code>uploads</code> folder and are not included.
</p>
{/* Plain <a> (not LinkButton) so client-side prefetch never hits the download. */}
<a href="/api/admin/export" download className={buttonVariants.secondary}>
Download export (JSON)
</a>
</div>
<form action={formAction} className="space-y-3">
{state.status === "success" && (
<Flash>Import complete. All content and settings were replaced.</Flash>
)}
<FormErrorBanner>{state.formError}</FormErrorBanner>
<div>
<h3 className="text-sm font-medium text-ink-strong">Import</h3>
<p className="mt-1 mb-3 text-sm text-ink-muted">
Restore a previously exported file. This{" "}
<strong className="text-danger">replaces every post, page, tag, navigation
item, and all settings</strong>{" "}
with the file&apos;s contents. Accounts and uploaded images are kept.
</p>
<Label htmlFor={`${ids}-file`}>Export file</Label>
<Input
id={`${ids}-file`}
name="file"
type="file"
accept=".json,application/json"
required
className="max-w-96 cursor-pointer file:mr-3 file:cursor-pointer file:border-0 file:bg-transparent file:p-0 file:text-sm file:font-medium file:text-link"
/>
</div>
<ConfirmButton confirmMessage="Really replace ALL posts, pages, tags, navigation, and settings with this file? This cannot be undone.">
Import and replace everything
</ConfirmButton>
</form>
</section>
);
}

View file

@ -1,44 +0,0 @@
"use client";
import { useActionState, useId } from "react";
import { loginAction } from "@/actions/auth";
import { FormErrorBanner } from "@/components/admin/Flash";
import { SubmitButton } from "@/components/admin/SubmitButton";
import { ErrorText, Input, Label } from "@/components/ui";
import { firstFieldError, initialFormState } from "@/lib/forms";
export function LoginForm() {
const [state, formAction] = useActionState(loginAction, initialFormState);
const ids = useId();
return (
<form action={formAction} className="space-y-5">
<FormErrorBanner>{state.formError}</FormErrorBanner>
<div>
<Label htmlFor={`${ids}-username`}>Username</Label>
<Input
id={`${ids}-username`}
name="username"
autoComplete="username"
required
autoFocus
/>
<ErrorText>{firstFieldError(state, "username")}</ErrorText>
</div>
<div>
<Label htmlFor={`${ids}-password`}>Password</Label>
<Input
id={`${ids}-password`}
name="password"
type="password"
autoComplete="current-password"
required
/>
<ErrorText>{firstFieldError(state, "password")}</ErrorText>
</div>
<SubmitButton pendingText="Signing in…" className="w-full">
Sign in
</SubmitButton>
</form>
);
}

View file

@ -1,150 +0,0 @@
"use client";
import Link from "next/link";
import { useActionState, useId, useState } from "react";
import { FormErrorBanner } from "@/components/admin/Flash";
import { FormTabs } from "@/components/admin/FormTabs";
import { RichTextEditor } from "@/components/admin/RichTextEditor";
import { SubmitButton } from "@/components/admin/SubmitButton";
import { ErrorText, HelpText, Input, Label, Select } from "@/components/ui";
import type { Page } from "@/db/schema";
import { type FormState, firstFieldError, initialFormState } from "@/lib/forms";
import { slugify } from "@/lib/slug";
type Props = {
page?: Page;
action: (prev: FormState, formData: FormData) => Promise<FormState>;
};
const SETTINGS_FIELDS = ["title", "slug"];
/** Same tabbed layout as PostForm: full-height canvas + settings panel. */
export function PageForm({ page, action }: Props) {
const [state, formAction] = useActionState(action, initialFormState);
const ids = useId();
// See PostForm: explicit tab clicks win until the next action result,
// which routes to the tab containing validation errors.
const [tabChoice, setTabChoice] = useState<{
tab: "content" | "settings";
forState: FormState;
}>({ tab: "content", forState: initialFormState });
const [title, setTitle] = useState(page?.title ?? "");
const [slug, setSlug] = useState(page?.slug ?? "");
const [slugTouched, setSlugTouched] = useState(page !== undefined);
const [status, setStatus] = useState<string>(page?.status ?? "draft");
const err = (field: string) => firstFieldError(state, field);
const errorKeys = Object.keys(state.fieldErrors ?? {});
const settingsHasError = errorKeys.some((key) => SETTINGS_FIELDS.includes(key));
const contentHasError = errorKeys.includes("body");
const tab =
tabChoice.forState === state
? tabChoice.tab
: settingsHasError
? "settings"
: contentHasError
? "content"
: tabChoice.tab;
const setTab = (next: "content" | "settings") =>
setTabChoice({ tab: next, forState: state });
return (
<form action={formAction}>
<div className="sticky top-0 z-20 -mb-px flex flex-wrap items-center justify-between gap-x-4 gap-y-2 border-b border-edge bg-background pt-1">
<FormTabs
idBase={ids}
label="Page editor sections"
activeId={tab}
onSelect={(id) => setTab(id as typeof tab)}
tabs={[
{ id: "content", label: "Content", hasError: contentHasError },
{ id: "settings", label: "Page settings", hasError: settingsHasError },
]}
/>
<div className="flex items-center gap-3 pb-1.5">
<Select
aria-label="Status"
name="status"
value={status}
onChange={(e) => setStatus(e.target.value)}
className="w-32"
>
<option value="draft">Draft</option>
<option value="published">Published</option>
</Select>
<SubmitButton>Save page</SubmitButton>
<Link href="/admin/pages" className="text-sm text-ink-muted hover:text-ink-strong">
Cancel
</Link>
</div>
</div>
<div className="pt-5">
<FormErrorBanner>{state.formError}</FormErrorBanner>
</div>
<div
role="tabpanel"
id={`${ids}-panel-content`}
aria-labelledby={`${ids}-tab-content`}
hidden={tab !== "content"}
>
<RichTextEditor
name="body"
label="Body"
initialHTML={page?.body ?? ""}
error={err("body")}
minHeightClassName="min-h-[max(24rem,calc(100dvh-24rem))]"
/>
</div>
<div
role="tabpanel"
id={`${ids}-panel-settings`}
aria-labelledby={`${ids}-tab-settings`}
hidden={tab !== "settings"}
className="max-w-3xl space-y-6"
>
<div>
<Label htmlFor={`${ids}-title`}>Title</Label>
<Input
id={`${ids}-title`}
name="title"
value={title}
onChange={(e) => {
setTitle(e.target.value);
if (!slugTouched) setSlug(slugify(e.target.value));
}}
required
aria-invalid={err("title") ? true : undefined}
aria-describedby={err("title") ? `${ids}-title-error` : undefined}
/>
<ErrorText id={`${ids}-title-error`}>{err("title")}</ErrorText>
</div>
<div>
<Label htmlFor={`${ids}-slug`}>Slug</Label>
<Input
id={`${ids}-slug`}
name="slug"
value={slug}
onChange={(e) => {
setSlug(e.target.value);
setSlugTouched(e.target.value !== "");
}}
aria-invalid={err("slug") ? true : undefined}
aria-describedby={`${ids}-slug-help${err("slug") ? ` ${ids}-slug-error` : ""}`}
/>
<HelpText id={`${ids}-slug-help`}>
Public URL: /pages/{slug || "…"} leave blank to generate from the title.
</HelpText>
<ErrorText id={`${ids}-slug-error`}>{err("slug")}</ErrorText>
</div>
<HelpText>Published pages can be linked from the top navigation.</HelpText>
</div>
</form>
);
}

View file

@ -1,365 +0,0 @@
"use client";
import Link from "next/link";
import { useActionState, useId, useRef, useState } from "react";
import { FormErrorBanner } from "@/components/admin/Flash";
import { FormTabs } from "@/components/admin/FormTabs";
import { RichTextEditor } from "@/components/admin/RichTextEditor";
import { SubmitButton } from "@/components/admin/SubmitButton";
import { Button, ErrorText, HelpText, Input, Label, Select } from "@/components/ui";
import type { Tag } from "@/db/schema";
import { type FormState, firstFieldError, initialFormState } from "@/lib/forms";
import type { PostWithTags } from "@/lib/services/posts";
import { slugify } from "@/lib/slug";
import { uploadImageFile } from "@/lib/upload-client";
type Props = {
post?: PostWithTags;
/** For authors this is just their granted tags, not every tag. */
allTags: Tag[];
/**
* Tags on the post the current user cannot toggle (added by the admin
* outside the author's grants). Shown checked and disabled; the server
* preserves them regardless of what the form submits.
*/
lockedTags?: Tag[];
defaultAuthor: string;
/** Whether the user may mint new tags inline. */
canCreateTags: boolean;
/**
* Statuses this user may save the post as, given its current state
* (publish and unpublish are separate permissions).
*/
statusOptions: Array<"draft" | "published">;
action: (prev: FormState, formData: FormData) => Promise<FormState>;
};
// Fields living on the settings panel — used to route validation errors
// to the tab the user needs to fix.
const SETTINGS_FIELDS = [
"title",
"slug",
"authorName",
"featuredImageUrl",
"featuredImageAlt",
"tagIds",
"newTags",
];
/**
* WordPress-style layout: the Content tab is a full-height writing canvas;
* everything descriptive (title, slug, author, featured image, tags) lives
* on the Settings tab. Both panels stay mounted so the single form submits
* all fields regardless of the visible tab, and the sticky action bar
* keeps status + save reachable from either.
*/
export function PostForm({
post,
allTags,
lockedTags = [],
defaultAuthor,
canCreateTags,
statusOptions,
action,
}: Props) {
const [state, formAction] = useActionState(action, initialFormState);
const ids = useId();
// The visible tab is derived: an explicit click wins until the next
// action result arrives; a result with field errors routes to the tab
// that contains them. No effects, no cascading renders.
const [tabChoice, setTabChoice] = useState<{
tab: "content" | "settings";
forState: FormState;
}>({ tab: "content", forState: initialFormState });
const [title, setTitle] = useState(post?.title ?? "");
const [slug, setSlug] = useState(post?.slug ?? "");
const [slugTouched, setSlugTouched] = useState(post !== undefined);
const [authorName, setAuthorName] = useState(post?.authorName ?? defaultAuthor);
const [imageUrl, setImageUrl] = useState(post?.featuredImageUrl ?? "");
const [imageAlt, setImageAlt] = useState(post?.featuredImageAlt ?? "");
const [status, setStatus] = useState<string>(post?.status ?? "draft");
const [selectedTagIds, setSelectedTagIds] = useState<Set<number>>(
() => new Set(post?.tags.map((t) => t.id) ?? []),
);
const [newTags, setNewTags] = useState("");
const [featuredUpload, setFeaturedUpload] = useState<
{ kind: "idle" } | { kind: "uploading" } | { kind: "error"; message: string }
>({ kind: "idle" });
const featuredFileRef = useRef<HTMLInputElement>(null);
const err = (field: string) => firstFieldError(state, field);
const errorKeys = Object.keys(state.fieldErrors ?? {});
const settingsHasError = errorKeys.some((key) => SETTINGS_FIELDS.includes(key));
const contentHasError = errorKeys.includes("body");
const tab =
tabChoice.forState === state
? tabChoice.tab
: settingsHasError
? "settings"
: contentHasError
? "content"
: tabChoice.tab;
const setTab = (next: "content" | "settings") =>
setTabChoice({ tab: next, forState: state });
function toggleTag(id: number) {
setSelectedTagIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
return (
<form action={formAction}>
<div className="sticky top-0 z-20 -mb-px flex flex-wrap items-center justify-between gap-x-4 gap-y-2 border-b border-edge bg-background pt-1">
<FormTabs
idBase={ids}
label="Post editor sections"
activeId={tab}
onSelect={(id) => setTab(id as typeof tab)}
tabs={[
{ id: "content", label: "Content", hasError: contentHasError },
{ id: "settings", label: "Post settings", hasError: settingsHasError },
]}
/>
<div className="flex items-center gap-3 pb-1.5">
<Select
aria-label="Status"
name="status"
value={status}
onChange={(e) => setStatus(e.target.value)}
className="w-32"
disabled={statusOptions.length < 2}
title={
statusOptions.length < 2
? "You do not have permission to change this post's status."
: undefined
}
>
{statusOptions.includes("draft") && <option value="draft">Draft</option>}
{statusOptions.includes("published") && (
<option value="published">Published</option>
)}
</Select>
{/* A disabled select submits nothing — carry the status anyway. */}
{statusOptions.length < 2 && <input type="hidden" name="status" value={status} />}
<SubmitButton>Save post</SubmitButton>
<Link href="/admin/posts" className="text-sm text-ink-muted hover:text-ink-strong">
Cancel
</Link>
</div>
</div>
<div className="pt-5">
<FormErrorBanner>{state.formError}</FormErrorBanner>
</div>
<div
role="tabpanel"
id={`${ids}-panel-content`}
aria-labelledby={`${ids}-tab-content`}
hidden={tab !== "content"}
>
<RichTextEditor
name="body"
label="Body"
initialHTML={post?.body ?? ""}
error={err("body")}
minHeightClassName="min-h-[max(24rem,calc(100dvh-24rem))]"
/>
</div>
<div
role="tabpanel"
id={`${ids}-panel-settings`}
aria-labelledby={`${ids}-tab-settings`}
hidden={tab !== "settings"}
className="max-w-3xl space-y-6"
>
<div>
<Label htmlFor={`${ids}-title`}>Title</Label>
<Input
id={`${ids}-title`}
name="title"
value={title}
onChange={(e) => {
setTitle(e.target.value);
if (!slugTouched) setSlug(slugify(e.target.value));
}}
required
aria-invalid={err("title") ? true : undefined}
aria-describedby={err("title") ? `${ids}-title-error` : undefined}
/>
<ErrorText id={`${ids}-title-error`}>{err("title")}</ErrorText>
</div>
<div>
<Label htmlFor={`${ids}-slug`}>Slug</Label>
<Input
id={`${ids}-slug`}
name="slug"
value={slug}
onChange={(e) => {
setSlug(e.target.value);
setSlugTouched(e.target.value !== "");
}}
aria-invalid={err("slug") ? true : undefined}
aria-describedby={`${ids}-slug-help${err("slug") ? ` ${ids}-slug-error` : ""}`}
/>
<HelpText id={`${ids}-slug-help`}>
Public URL: /posts/{slug || "…"} leave blank to generate from the title.
</HelpText>
<ErrorText id={`${ids}-slug-error`}>{err("slug")}</ErrorText>
</div>
<div>
<Label htmlFor={`${ids}-author`}>Author name</Label>
<Input
id={`${ids}-author`}
name="authorName"
value={authorName}
onChange={(e) => setAuthorName(e.target.value)}
required
aria-invalid={err("authorName") ? true : undefined}
aria-describedby={err("authorName") ? `${ids}-author-error` : undefined}
/>
<ErrorText id={`${ids}-author-error`}>{err("authorName")}</ErrorText>
</div>
<fieldset className="rounded-lg border border-edge p-4">
<legend className="px-1 text-sm font-medium text-ink-strong">Featured image</legend>
<div className="space-y-4">
<div>
<Label htmlFor={`${ids}-image-url`}>Image URL (optional)</Label>
<div className="flex gap-2">
<Input
id={`${ids}-image-url`}
name="featuredImageUrl"
placeholder="https://example.com/image.jpg or upload →"
value={imageUrl}
onChange={(e) => setImageUrl(e.target.value)}
aria-invalid={err("featuredImageUrl") ? true : undefined}
aria-describedby={
err("featuredImageUrl") ? `${ids}-image-url-error` : undefined
}
/>
<Button
type="button"
variant="secondary"
className="shrink-0"
disabled={featuredUpload.kind === "uploading"}
onClick={() => featuredFileRef.current?.click()}
>
{featuredUpload.kind === "uploading" ? "Uploading…" : "Upload"}
</Button>
<input
ref={featuredFileRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif,image/avif"
hidden
data-testid="featured-image-input"
onChange={async (event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (!file) return;
setFeaturedUpload({ kind: "uploading" });
const result = await uploadImageFile(file);
if ("error" in result) {
setFeaturedUpload({ kind: "error", message: result.error });
} else {
setImageUrl(result.url);
setFeaturedUpload({ kind: "idle" });
}
}}
/>
</div>
{featuredUpload.kind === "error" && (
<ErrorText>{featuredUpload.message}</ErrorText>
)}
<ErrorText id={`${ids}-image-url-error`}>{err("featuredImageUrl")}</ErrorText>
</div>
<div>
<Label htmlFor={`${ids}-image-alt`}>Alt text (optional)</Label>
<Input
id={`${ids}-image-alt`}
name="featuredImageAlt"
value={imageAlt}
onChange={(e) => setImageAlt(e.target.value)}
aria-describedby={`${ids}-image-alt-help`}
/>
<HelpText id={`${ids}-image-alt-help`}>
Describe the image for screen-reader users; leave blank if purely decorative.
</HelpText>
</div>
</div>
</fieldset>
<fieldset className="rounded-lg border border-edge p-4">
<legend className="px-1 text-sm font-medium text-ink-strong">Tags</legend>
{lockedTags.length > 0 && (
<ul className="mb-2 flex flex-wrap gap-x-5 gap-y-2">
{lockedTags.map((tag) => (
<li key={tag.id}>
<label className="inline-flex items-center gap-2 text-sm text-ink-muted">
<input type="checkbox" checked disabled className="size-4" />
{tag.name}
<span className="text-xs">(added by admin)</span>
</label>
</li>
))}
</ul>
)}
{allTags.length > 0 ? (
<ul className="flex flex-wrap gap-x-5 gap-y-2">
{allTags.map((tag) => (
<li key={tag.id}>
<label className="inline-flex cursor-pointer items-center gap-2 text-sm text-ink">
<input
type="checkbox"
name="tagIds"
value={tag.id}
checked={selectedTagIds.has(tag.id)}
onChange={() => toggleTag(tag.id)}
className="size-4 accent-(--link)"
/>
{tag.name}
</label>
</li>
))}
</ul>
) : (
<p className="text-sm text-ink-muted">
{canCreateTags
? "No tags exist yet — create some below."
: "You have not been given access to any tags yet — ask the admin."}
</p>
)}
<ErrorText>{err("tagIds")}</ErrorText>
{canCreateTags ? (
<div className="mt-4">
<Label htmlFor={`${ids}-new-tags`}>New tags (optional)</Label>
<Input
id={`${ids}-new-tags`}
name="newTags"
value={newTags}
onChange={(e) => setNewTags(e.target.value)}
placeholder="design, typescript"
aria-describedby={`${ids}-new-tags-help`}
/>
<HelpText id={`${ids}-new-tags-help`}>
Comma-separated. Created and attached to this post on save.
</HelpText>
<ErrorText>{err("newTags")}</ErrorText>
</div>
) : (
<HelpText>Posts must carry at least one of your tags.</HelpText>
)}
</fieldset>
</div>
</form>
);
}

View file

@ -1,377 +0,0 @@
"use client";
import Image from "@tiptap/extension-image";
import { Placeholder } from "@tiptap/extension-placeholder";
import { TableKit } from "@tiptap/extension-table";
import type { Editor } from "@tiptap/react";
import { EditorContent, useEditor, useEditorState } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import { useEffect, useId, useRef, useState } from "react";
import { MarkdownInputRules } from "@/components/admin/markdown-input-rules";
import { ErrorText, Label, cx } from "@/components/ui";
import { renderMarkdown } from "@/lib/markdown";
import { uploadImageFile } from "@/lib/upload-client";
/**
* WordPress-style WYSIWYG editor (Tiptap/ProseMirror).
*
* - Emits HTML into a hidden field so the surrounding server-action form
* submits it like any other input (sanitized server-side on save).
* - Images are uploaded via /api/admin/uploads from the toolbar button,
* by dropping files onto the editor, or by pasting from the clipboard
* and inserted inline as /uploads/... URLs.
* - Pasting plain text that looks like Markdown converts it through the
* same remark pipeline used everywhere else; pasting rich HTML uses
* ProseMirror's native handling.
*/
// Cheap markdown sniff: headings, lists, quotes, fences, emphasis,
// links, or inline code. Plain prose without these pastes untouched.
const MARKDOWN_PATTERN =
/(^|\n)\s{0,3}(#{1,6}\s|[-*+]\s|\d+\.\s|>\s?|```)|\*\*[^*\n]+\*\*|__[^_\n]+__|\[[^\]\n]+\]\([^)\n]+\)|`[^`\n]+`/;
function looksLikeMarkdown(text: string): boolean {
return MARKDOWN_PATTERN.test(text);
}
function ToolButton({
label,
active = false,
disabled = false,
onClick,
children,
className,
}: {
label: string;
active?: boolean;
disabled?: boolean;
onClick: () => void;
children: React.ReactNode;
className?: string;
}) {
return (
<button
type="button"
aria-label={label}
title={label}
aria-pressed={active}
disabled={disabled}
// Keep the editor focused while clicking toolbar buttons; without
// this the button grabs focus on mousedown and typed characters go
// to the button instead of the document.
onMouseDown={(event) => event.preventDefault()}
onClick={onClick}
className={cx(
"inline-flex h-8 min-w-8 items-center justify-center rounded px-1.5 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-40",
active
? "bg-link text-ink-inverse"
: "text-ink hover:bg-background hover:text-ink-strong",
className,
)}
>
{children}
</button>
);
}
function ToolDivider() {
return <span aria-hidden="true" className="mx-1 h-5 w-px self-center bg-edge-strong" />;
}
export function RichTextEditor({
name,
label,
initialHTML,
error,
minHeightClassName = "min-h-72",
}: {
name: string;
label: string;
initialHTML: string;
error?: string;
/** Tailwind min-height class for the writing canvas. */
minHeightClassName?: string;
}) {
const id = useId();
const errorId = `${id}-error`;
const [html, setHtml] = useState(initialHTML);
const [uploadState, setUploadState] = useState<
{ kind: "idle" } | { kind: "uploading" } | { kind: "error"; message: string }
>({ kind: "idle" });
const fileInputRef = useRef<HTMLInputElement>(null);
const editorRef = useRef<Editor | null>(null);
async function uploadAndInsert(files: File[]) {
const editor = editorRef.current;
const images = files.filter((f) => f.type.startsWith("image/"));
if (!editor || images.length === 0) return;
setUploadState({ kind: "uploading" });
for (const file of images) {
const result = await uploadImageFile(file);
if ("error" in result) {
setUploadState({ kind: "error", message: result.error });
return;
}
editor.chain().focus().setImage({ src: result.url, alt: "" }).run();
}
setUploadState({ kind: "idle" });
}
const editor = useEditor({
immediatelyRender: false,
extensions: [
StarterKit.configure({
heading: { levels: [2, 3, 4] },
link: { openOnClick: false, autolink: true, defaultProtocol: "https" },
}),
Image.configure({ allowBase64: false }),
TableKit.configure({ table: { resizable: false } }),
Placeholder.configure({ placeholder: "Write your story…" }),
MarkdownInputRules,
],
content: initialHTML,
editorProps: {
attributes: {
class: `markdown-body ${minHeightClassName} px-4 py-3 focus:outline-none`,
"aria-label": label,
},
handlePaste: (_view, event) => {
const clipboard = event.clipboardData;
if (!clipboard) return false;
const files = Array.from(clipboard.files ?? []);
if (files.some((f) => f.type.startsWith("image/"))) {
event.preventDefault();
void uploadAndInsert(files);
return true;
}
// Rich HTML pastes keep ProseMirror's native handling.
if (clipboard.getData("text/html")) return false;
const text = clipboard.getData("text/plain");
if (text && looksLikeMarkdown(text)) {
event.preventDefault();
editorRef.current?.chain().focus().insertContent(renderMarkdown(text)).run();
return true;
}
return false;
},
handleDrop: (_view, event) => {
const files = Array.from(event.dataTransfer?.files ?? []);
if (files.some((f) => f.type.startsWith("image/"))) {
event.preventDefault();
void uploadAndInsert(files);
return true;
}
return false;
},
},
onUpdate: ({ editor }) => {
setHtml(editor.isEmpty ? "" : editor.getHTML());
},
});
// The paste/drop handlers above are created once by useEditor and reach
// the editor through this ref; render-time assignment would trip the
// react-hooks/refs rule, so sync it in an effect instead.
useEffect(() => {
editorRef.current = editor;
}, [editor]);
const state = useEditorState({
editor,
selector: ({ editor }) =>
editor
? {
paragraph: editor.isActive("paragraph"),
h2: editor.isActive("heading", { level: 2 }),
h3: editor.isActive("heading", { level: 3 }),
h4: editor.isActive("heading", { level: 4 }),
bold: editor.isActive("bold"),
italic: editor.isActive("italic"),
underline: editor.isActive("underline"),
strike: editor.isActive("strike"),
code: editor.isActive("code"),
link: editor.isActive("link"),
bulletList: editor.isActive("bulletList"),
orderedList: editor.isActive("orderedList"),
blockquote: editor.isActive("blockquote"),
codeBlock: editor.isActive("codeBlock"),
image: editor.isActive("image"),
table: editor.isActive("table"),
canUndo: editor.can().undo(),
canRedo: editor.can().redo(),
}
: null,
});
function setLink() {
if (!editor) return;
const current = editor.getAttributes("link").href as string | undefined;
const url = window.prompt("Link URL (leave empty to remove):", current ?? "");
if (url === null) return;
if (url === "") {
editor.chain().focus().unsetLink().run();
} else {
editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run();
}
}
function editImageAlt() {
if (!editor) return;
const current = (editor.getAttributes("image").alt as string | undefined) ?? "";
const alt = window.prompt(
"Alt text for this image (leave empty if decorative):",
current,
);
if (alt === null) return;
editor.chain().focus().updateAttributes("image", { alt }).run();
}
const chain = () => editor!.chain().focus();
return (
<div>
<Label className="mb-1.5">{label}</Label>
<input type="hidden" name={name} value={html} />
<div
className={cx(
"editor-shell rounded-md border bg-background",
error ? "border-danger" : "border-edge",
)}
>
<div
role="toolbar"
aria-label={`${label} formatting`}
className="flex flex-wrap items-center gap-0.5 border-b border-edge bg-surface px-2 py-1.5"
>
<ToolButton label="Paragraph" active={state?.paragraph} disabled={!editor} onClick={() => chain().setParagraph().run()}>
</ToolButton>
<ToolButton label="Heading level 2" active={state?.h2} disabled={!editor} onClick={() => chain().toggleHeading({ level: 2 }).run()}>
H2
</ToolButton>
<ToolButton label="Heading level 3" active={state?.h3} disabled={!editor} onClick={() => chain().toggleHeading({ level: 3 }).run()}>
H3
</ToolButton>
<ToolButton label="Heading level 4" active={state?.h4} disabled={!editor} onClick={() => chain().toggleHeading({ level: 4 }).run()}>
H4
</ToolButton>
<ToolDivider />
<ToolButton label="Bold" active={state?.bold} disabled={!editor} onClick={() => chain().toggleBold().run()} className="font-bold">
B
</ToolButton>
<ToolButton label="Italic" active={state?.italic} disabled={!editor} onClick={() => chain().toggleItalic().run()} className="italic">
I
</ToolButton>
<ToolButton label="Underline" active={state?.underline} disabled={!editor} onClick={() => chain().toggleUnderline().run()} className="underline">
U
</ToolButton>
<ToolButton label="Strikethrough" active={state?.strike} disabled={!editor} onClick={() => chain().toggleStrike().run()} className="line-through">
S
</ToolButton>
<ToolButton label="Inline code" active={state?.code} disabled={!editor} onClick={() => chain().toggleCode().run()} className="font-mono text-xs">
{"</>"}
</ToolButton>
<ToolButton label="Link" active={state?.link} disabled={!editor} onClick={setLink}>
🔗
</ToolButton>
<ToolDivider />
<ToolButton label="Bullet list" active={state?.bulletList} disabled={!editor} onClick={() => chain().toggleBulletList().run()}>
</ToolButton>
<ToolButton label="Numbered list" active={state?.orderedList} disabled={!editor} onClick={() => chain().toggleOrderedList().run()}>
1.
</ToolButton>
<ToolButton label="Blockquote" active={state?.blockquote} disabled={!editor} onClick={() => chain().toggleBlockquote().run()}>
</ToolButton>
<ToolButton label="Code block" active={state?.codeBlock} disabled={!editor} onClick={() => chain().toggleCodeBlock().run()} className="font-mono text-xs">
{"{ }"}
</ToolButton>
<ToolButton label="Horizontal rule" disabled={!editor} onClick={() => chain().setHorizontalRule().run()}>
</ToolButton>
<ToolDivider />
<ToolButton label="Upload and insert image" disabled={!editor} onClick={() => fileInputRef.current?.click()}>
🖼
</ToolButton>
{state?.image && (
<ToolButton label="Edit image alt text" disabled={!editor} onClick={editImageAlt}>
Alt
</ToolButton>
)}
<ToolButton
label="Insert table"
active={state?.table}
disabled={!editor}
onClick={() => chain().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()}
>
</ToolButton>
<ToolDivider />
<ToolButton label="Undo" disabled={!editor || !state?.canUndo} onClick={() => chain().undo().run()}>
</ToolButton>
<ToolButton label="Redo" disabled={!editor || !state?.canRedo} onClick={() => chain().redo().run()}>
</ToolButton>
</div>
{state?.table && (
<div
role="toolbar"
aria-label="Table editing"
className="flex flex-wrap items-center gap-0.5 border-b border-edge bg-surface px-2 py-1"
>
<ToolButton label="Add column after" disabled={!editor} onClick={() => chain().addColumnAfter().run()}>
+Col
</ToolButton>
<ToolButton label="Delete column" disabled={!editor} onClick={() => chain().deleteColumn().run()}>
Col
</ToolButton>
<ToolButton label="Add row after" disabled={!editor} onClick={() => chain().addRowAfter().run()}>
+Row
</ToolButton>
<ToolButton label="Delete row" disabled={!editor} onClick={() => chain().deleteRow().run()}>
Row
</ToolButton>
<ToolButton label="Toggle header row" disabled={!editor} onClick={() => chain().toggleHeaderRow().run()}>
Header
</ToolButton>
<ToolButton label="Delete table" disabled={!editor} onClick={() => chain().deleteTable().run()}>
Table
</ToolButton>
</div>
)}
<EditorContent editor={editor} />
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif,image/avif"
multiple
hidden
data-testid="editor-image-input"
onChange={(event) => {
const files = Array.from(event.target.files ?? []);
event.target.value = "";
void uploadAndInsert(files);
}}
/>
</div>
<p className="mt-1.5 text-xs text-ink-muted" role="status" aria-live="polite">
{uploadState.kind === "uploading" && "Uploading image…"}
{uploadState.kind === "error" && (
<span className="text-danger">{uploadState.message}</span>
)}
{uploadState.kind === "idle" &&
"Drop or paste images to upload them. Pasted Markdown is converted automatically."}
</p>
<ErrorText id={errorId}>{error}</ErrorText>
</div>
);
}

View file

@ -1,465 +0,0 @@
"use client";
import { useActionState, useId, useState } from "react";
import { Flash, FormErrorBanner } from "@/components/admin/Flash";
import { SubmitButton } from "@/components/admin/SubmitButton";
import { Button, ErrorText, HelpText, Input, Label, Select } from "@/components/ui";
import type { NavItem, Page, Settings, Tag } from "@/db/schema";
import { type FormState, firstFieldError, initialFormState } from "@/lib/forms";
import { FONT_META, FONT_ORDER, THEME_META, THEME_ORDER } from "@/lib/themes";
type NavRow = {
key: number;
label: string;
kind: "page" | "url";
url: string;
pageId: string;
};
type Props = {
settings: Settings;
navItems: NavItem[];
allTags: Tag[];
publishedPages: Page[];
action: (prev: FormState, formData: FormData) => Promise<FormState>;
};
let nextKey = 1;
export function SettingsForm({ settings, navItems, allTags, publishedPages, action }: Props) {
const [state, formAction] = useActionState(action, initialFormState);
const ids = useId();
const [siteTitle, setSiteTitle] = useState(settings.siteTitle);
const [siteUrl, setSiteUrl] = useState(settings.siteUrl);
const [headerText, setHeaderText] = useState(settings.headerText);
const [footerText, setFooterText] = useState(settings.footerText);
const [postsPerPage, setPostsPerPage] = useState(String(settings.postsPerPage));
const [excerptWords, setExcerptWords] = useState(String(settings.excerptWords));
const [homeMode, setHomeMode] = useState<string>(settings.homeMode);
const [homeTagId, setHomeTagId] = useState(settings.homeTagId?.toString() ?? "");
const [homePageId, setHomePageId] = useState(settings.homePageId?.toString() ?? "");
const [theme, setTheme] = useState<string>(settings.theme);
const [font, setFont] = useState<string>(settings.font);
const [navRows, setNavRows] = useState<NavRow[]>(() =>
navItems.map((item) => ({
key: nextKey++,
label: item.label,
kind: item.url !== null ? "url" : "page",
url: item.url ?? "",
pageId: item.pageId?.toString() ?? "",
})),
);
const err = (field: string) => firstFieldError(state, field);
const navItemsJson = JSON.stringify(
navRows.map((row) => ({
label: row.label,
url: row.kind === "url" ? row.url : null,
pageId: row.kind === "page" && row.pageId !== "" ? Number(row.pageId) : null,
})),
);
function updateRow(key: number, patch: Partial<NavRow>) {
setNavRows((rows) => rows.map((row) => (row.key === key ? { ...row, ...patch } : row)));
}
function moveRow(index: number, delta: -1 | 1) {
setNavRows((rows) => {
const target = index + delta;
if (target < 0 || target >= rows.length) return rows;
const next = [...rows];
[next[index], next[target]] = [next[target], next[index]];
return next;
});
}
return (
<form action={formAction} className="max-w-3xl space-y-8">
{state.status === "success" && <Flash>Settings saved.</Flash>}
<FormErrorBanner>{state.formError}</FormErrorBanner>
<section aria-labelledby={`${ids}-general`} className="space-y-5">
<h2 id={`${ids}-general`} className="border-b border-edge pb-2 text-lg font-semibold text-ink-strong">
General
</h2>
<div>
<Label htmlFor={`${ids}-site-title`}>Site title</Label>
<Input
id={`${ids}-site-title`}
name="siteTitle"
value={siteTitle}
onChange={(e) => setSiteTitle(e.target.value)}
required
aria-invalid={err("siteTitle") ? true : undefined}
/>
<ErrorText>{err("siteTitle")}</ErrorText>
</div>
<div>
<Label htmlFor={`${ids}-site-url`}>Site URL</Label>
<Input
id={`${ids}-site-url`}
name="siteUrl"
type="url"
placeholder="https://example.com"
value={siteUrl}
onChange={(e) => setSiteUrl(e.target.value)}
aria-invalid={err("siteUrl") ? true : undefined}
/>
<HelpText>
The site&apos;s public address used for the RSS feed, sitemap, and
search-engine metadata.
</HelpText>
<ErrorText>{err("siteUrl")}</ErrorText>
</div>
<div>
<Label htmlFor={`${ids}-header-text`}>Header text</Label>
<Input
id={`${ids}-header-text`}
name="headerText"
value={headerText}
onChange={(e) => setHeaderText(e.target.value)}
/>
<HelpText>Shown as the tagline under the site title.</HelpText>
<ErrorText>{err("headerText")}</ErrorText>
</div>
<div>
<Label htmlFor={`${ids}-footer-text`}>Footer text</Label>
<Input
id={`${ids}-footer-text`}
name="footerText"
value={footerText}
onChange={(e) => setFooterText(e.target.value)}
/>
<ErrorText>{err("footerText")}</ErrorText>
</div>
<div className="grid gap-5 sm:grid-cols-2">
<div>
<Label htmlFor={`${ids}-per-page`}>Posts per page</Label>
<Input
id={`${ids}-per-page`}
name="postsPerPage"
type="number"
min={1}
max={50}
value={postsPerPage}
onChange={(e) => setPostsPerPage(e.target.value)}
required
aria-invalid={err("postsPerPage") ? true : undefined}
/>
<ErrorText>{err("postsPerPage")}</ErrorText>
</div>
<div>
<Label htmlFor={`${ids}-excerpt-words`}>Excerpt word limit</Label>
<Input
id={`${ids}-excerpt-words`}
name="excerptWords"
type="number"
min={5}
max={200}
value={excerptWords}
onChange={(e) => setExcerptWords(e.target.value)}
required
aria-invalid={err("excerptWords") ? true : undefined}
/>
<ErrorText>{err("excerptWords")}</ErrorText>
</div>
</div>
</section>
<section aria-labelledby={`${ids}-appearance`} className="space-y-4">
<h2
id={`${ids}-appearance`}
className="border-b border-edge pb-2 text-lg font-semibold text-ink-strong"
>
Appearance
</h2>
<fieldset>
<legend className="mb-2 text-sm font-medium text-ink-strong">Theme</legend>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{THEME_ORDER.map((value) => {
const meta = THEME_META[value];
return (
<label
key={value}
className={`flex cursor-pointer items-center gap-3 rounded-lg border px-4 py-3 text-sm transition-colors ${
theme === value
? "border-link bg-background text-ink-strong"
: "border-edge text-ink hover:border-edge-strong"
}`}
>
<input
type="radio"
name="theme"
value={value}
checked={theme === value}
onChange={() => setTheme(value)}
className="size-4 shrink-0 accent-(--link)"
/>
{/* Miniature palette swatch */}
<span
aria-hidden="true"
className="flex h-6 w-10 shrink-0 items-center justify-center gap-1 rounded border border-edge-strong"
style={{ background: meta.bg }}
>
<span className="h-2.5 w-2.5 rounded-full" style={{ background: meta.fg }} />
<span
className="h-2.5 w-2.5 rounded-full"
style={{ background: meta.accent }}
/>
</span>
{meta.label}
</label>
);
})}
</div>
<HelpText>Applies to the public site and the admin area after saving.</HelpText>
<ErrorText>{err("theme")}</ErrorText>
</fieldset>
<fieldset>
<legend className="mb-2 text-sm font-medium text-ink-strong">Font</legend>
<div className="grid gap-3 lg:grid-cols-2">
{FONT_ORDER.map((value) => {
const meta = FONT_META[value];
return (
<label
key={value}
className={`flex cursor-pointer items-start gap-3 rounded-lg border px-4 py-3 transition-colors ${
font === value
? "border-link bg-background"
: "border-edge hover:border-edge-strong"
}`}
>
<input
type="radio"
name="font"
value={value}
checked={font === value}
onChange={() => setFont(value)}
className="mt-1 size-4 shrink-0 accent-(--link)"
/>
<span className="min-w-0">
<span className="block text-sm font-medium text-ink-strong">
{meta.label}
</span>
<span className="mt-0.5 block text-xs text-ink-muted">
{meta.description}
</span>
{/* Live sample in the actual font (loaded on demand). */}
<span
aria-hidden="true"
className="mt-1.5 block truncate text-lg leading-snug text-ink"
style={{ fontFamily: `var(${meta.cssVar})` }}
>
Grumpy wizards make toxic brew. 0123
</span>
</span>
</label>
);
})}
</div>
<HelpText>Body font for the whole site; code blocks always use Geist Mono.</HelpText>
<ErrorText>{err("font")}</ErrorText>
</fieldset>
</section>
<section aria-labelledby={`${ids}-home`} className="space-y-4">
<h2 id={`${ids}-home`} className="border-b border-edge pb-2 text-lg font-semibold text-ink-strong">
Home page
</h2>
<fieldset>
<legend className="mb-2 text-sm font-medium text-ink-strong">
What should the front page show?
</legend>
<div className="space-y-2 text-sm">
{(
[
["posts", "All published posts"],
["tag", "Posts with a selected tag"],
["page", "A selected static page"],
] as const
).map(([value, label]) => (
<label key={value} className="flex cursor-pointer items-center gap-2">
<input
type="radio"
name="homeMode"
value={value}
checked={homeMode === value}
onChange={() => setHomeMode(value)}
className="size-4 accent-(--link)"
/>
{label}
</label>
))}
</div>
</fieldset>
{homeMode === "tag" && (
<div>
<Label htmlFor={`${ids}-home-tag`}>Tag to feature</Label>
<Select
id={`${ids}-home-tag`}
name="homeTagId"
value={homeTagId}
onChange={(e) => setHomeTagId(e.target.value)}
className="max-w-72"
aria-invalid={err("homeTagId") ? true : undefined}
>
<option value="">Choose a tag</option>
{allTags.map((tag) => (
<option key={tag.id} value={tag.id}>
{tag.name}
</option>
))}
</Select>
<ErrorText>{err("homeTagId")}</ErrorText>
</div>
)}
{homeMode === "page" && (
<div>
<Label htmlFor={`${ids}-home-page`}>Page to show</Label>
<Select
id={`${ids}-home-page`}
name="homePageId"
value={homePageId}
onChange={(e) => setHomePageId(e.target.value)}
className="max-w-72"
aria-invalid={err("homePageId") ? true : undefined}
>
<option value="">Choose a page</option>
{publishedPages.map((page) => (
<option key={page.id} value={page.id}>
{page.title}
</option>
))}
</Select>
<HelpText>Only published pages are listed.</HelpText>
<ErrorText>{err("homePageId")}</ErrorText>
</div>
)}
</section>
<section aria-labelledby={`${ids}-nav`} className="space-y-4">
<h2 id={`${ids}-nav`} className="border-b border-edge pb-2 text-lg font-semibold text-ink-strong">
Top navigation
</h2>
<p className="text-sm text-ink-muted">
Items link to a published page or to an internal (<code>/path</code>) or external
(<code>https://…</code>) URL. Items pointing at unpublished pages are hidden until
the page is published again.
</p>
{navRows.length === 0 && (
<p className="rounded-md border border-dashed border-edge-strong px-4 py-3 text-sm text-ink-muted">
No navigation items the top navigation is hidden.
</p>
)}
<ul className="space-y-3">
{navRows.map((row, index) => (
<li key={row.key} className="rounded-lg border border-edge bg-surface p-4">
<div className="grid gap-3 sm:grid-cols-[1fr_auto]">
<div className="grid gap-3 sm:grid-cols-2">
<div>
<Label htmlFor={`${ids}-nav-label-${row.key}`}>Label</Label>
<Input
id={`${ids}-nav-label-${row.key}`}
value={row.label}
onChange={(e) => updateRow(row.key, { label: e.target.value })}
/>
</div>
<div>
<Label htmlFor={`${ids}-nav-kind-${row.key}`}>Links to</Label>
<div className="flex gap-2">
<Select
id={`${ids}-nav-kind-${row.key}`}
value={row.kind}
onChange={(e) =>
updateRow(row.key, { kind: e.target.value as NavRow["kind"] })
}
className="w-28 shrink-0"
>
<option value="page">Page</option>
<option value="url">URL</option>
</Select>
{row.kind === "page" ? (
<Select
aria-label="Page"
value={row.pageId}
onChange={(e) => updateRow(row.key, { pageId: e.target.value })}
>
<option value="">Choose a page</option>
{publishedPages.map((page) => (
<option key={page.id} value={page.id}>
{page.title}
</option>
))}
</Select>
) : (
<Input
aria-label="URL"
placeholder="/posts or https://example.com"
value={row.url}
onChange={(e) => updateRow(row.key, { url: e.target.value })}
/>
)}
</div>
</div>
</div>
<div className="flex items-end gap-1">
<Button
type="button"
variant="ghost"
aria-label={`Move “${row.label || "item"}” up`}
disabled={index === 0}
onClick={() => moveRow(index, -1)}
>
</Button>
<Button
type="button"
variant="ghost"
aria-label={`Move “${row.label || "item"}” down`}
disabled={index === navRows.length - 1}
onClick={() => moveRow(index, 1)}
>
</Button>
<Button
type="button"
variant="danger"
className="px-2.5 py-1 text-xs"
onClick={() => setNavRows((rows) => rows.filter((r) => r.key !== row.key))}
>
Remove
</Button>
</div>
</div>
</li>
))}
</ul>
<Button
type="button"
variant="secondary"
onClick={() =>
setNavRows((rows) => [
...rows,
{ key: nextKey++, label: "", kind: "url", url: "", pageId: "" },
])
}
>
Add navigation item
</Button>
<input type="hidden" name="navItemsJson" value={navItemsJson} />
</section>
<div className="flex items-center gap-3 border-t border-edge pt-6">
<SubmitButton>Save settings</SubmitButton>
</div>
</form>
);
}

View file

@ -1,16 +0,0 @@
import type { ContentStatus } from "@/db/schema";
export function StatusBadge({ status }: { status: ContentStatus }) {
if (status === "published") {
return (
<span className="inline-flex items-center rounded-full border border-success/40 bg-success/10 px-2 py-0.5 text-xs font-medium text-success">
Published
</span>
);
}
return (
<span className="inline-flex items-center rounded-full border border-warning/40 bg-warning/10 px-2 py-0.5 text-xs font-medium text-warning">
Draft
</span>
);
}

View file

@ -1,30 +0,0 @@
"use client";
import { useFormStatus } from "react-dom";
import { Button, type ButtonVariant } from "@/components/ui";
/** Submit button with a pending state; must be rendered inside the form. */
export function SubmitButton({
children,
pendingText = "Saving…",
variant = "primary",
className,
}: {
children: React.ReactNode;
pendingText?: string;
variant?: ButtonVariant;
className?: string;
}) {
const { pending } = useFormStatus();
return (
<Button
type="submit"
variant={variant}
className={className}
disabled={pending}
aria-busy={pending}
>
{pending ? pendingText : children}
</Button>
);
}

View file

@ -1,179 +0,0 @@
"use client";
import { useActionState, useId, useState } from "react";
import { Flash, FormErrorBanner } from "@/components/admin/Flash";
import { SubmitButton } from "@/components/admin/SubmitButton";
import { ErrorText, HelpText, Input, Label } from "@/components/ui";
import type { Tag } from "@/db/schema";
import { type FormState, firstFieldError, initialFormState } from "@/lib/forms";
import type { UserWithTags } from "@/lib/services/users";
type Props = {
/** Absent when creating a new account. */
user?: UserWithTags;
allTags: Tag[];
action: (prev: FormState, formData: FormData) => Promise<FormState>;
};
const PERMISSION_OPTIONS = [
{
name: "canPublishPosts",
label: "Publish posts",
help: "Move their own posts from draft to published.",
},
{
name: "canUnpublishPosts",
label: "Unpublish posts",
help: "Move their own published posts back to draft.",
},
{
name: "canDeletePosts",
label: "Delete posts",
help: "Permanently delete their own posts.",
},
{
name: "canCreateTags",
label: "Create tags",
help: "Mint new tags from the post editor; created tags are granted to them.",
},
{
name: "canApproveComments",
label: "Approve comments",
help: "Moderate comments left on their own posts.",
},
] as const;
export function UserForm({ user, allTags, action }: Props) {
const [state, formAction] = useActionState(action, initialFormState);
const ids = useId();
const isNew = user === undefined;
const isAdminAccount = user?.role === "admin";
const [selectedTagIds, setSelectedTagIds] = useState<Set<number>>(
() => new Set(user?.tags.map((t) => t.id) ?? []),
);
const err = (field: string) => firstFieldError(state, field);
function toggleTag(id: number) {
setSelectedTagIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
return (
<form action={formAction} className="max-w-xl space-y-6">
{state.status === "success" && <Flash>Account saved.</Flash>}
<FormErrorBanner>{state.formError}</FormErrorBanner>
{isNew ? (
<div>
<Label htmlFor={`${ids}-username`}>Username</Label>
<Input
id={`${ids}-username`}
name="username"
required
maxLength={120}
autoComplete="off"
aria-invalid={err("username") ? true : undefined}
/>
<HelpText>Letters, numbers, dots, dashes, and underscores.</HelpText>
<ErrorText>{err("username")}</ErrorText>
</div>
) : (
<p className="text-sm text-ink">
<span className="font-semibold text-ink-strong">{user.username}</span>{" "}
<span className="text-ink-muted">
· {isAdminAccount ? "administrator" : "author"}
</span>
</p>
)}
<div>
<Label htmlFor={`${ids}-password`}>
{isNew ? "Password" : "New password (optional)"}
</Label>
<Input
id={`${ids}-password`}
name="password"
type="password"
required={isNew}
maxLength={200}
autoComplete="new-password"
aria-invalid={err("password") ? true : undefined}
/>
<HelpText>
{isNew
? "At least 8 characters. Share it with the author out of band."
: "Leave blank to keep the current password."}
</HelpText>
<ErrorText>{err("password")}</ErrorText>
</div>
{!isAdminAccount && (
<fieldset className="rounded-lg border border-edge p-4">
<legend className="px-1 text-sm font-medium text-ink-strong">Permissions</legend>
<p className="mb-3 text-sm text-ink-muted">
Without these, the author can only write and edit their own drafts.
</p>
<ul className="space-y-2.5">
{PERMISSION_OPTIONS.map((perm) => (
<li key={perm.name}>
<label className="flex cursor-pointer items-start gap-2 text-sm text-ink">
<input
type="checkbox"
name={perm.name}
defaultChecked={user?.[perm.name] ?? false}
className="mt-0.5 size-4 accent-(--link)"
/>
<span>
{perm.label}
<span className="block text-xs text-ink-muted">{perm.help}</span>
</span>
</label>
</li>
))}
</ul>
</fieldset>
)}
{!isAdminAccount && (
<fieldset className="rounded-lg border border-edge p-4">
<legend className="px-1 text-sm font-medium text-ink-strong">Tag access</legend>
<p className="mb-3 text-sm text-ink-muted">
The author can write posts only under the tags checked here.
</p>
{allTags.length > 0 ? (
<ul className="flex flex-wrap gap-x-5 gap-y-2">
{allTags.map((tag) => (
<li key={tag.id}>
<label className="inline-flex cursor-pointer items-center gap-2 text-sm text-ink">
<input
type="checkbox"
name="tagIds"
value={tag.id}
checked={selectedTagIds.has(tag.id)}
onChange={() => toggleTag(tag.id)}
className="size-4 accent-(--link)"
/>
{tag.name}
</label>
</li>
))}
</ul>
) : (
<p className="text-sm text-ink-muted">
No tags exist yet create some from a post first.
</p>
)}
<ErrorText>{err("tagIds")}</ErrorText>
</fieldset>
)}
<div className="flex items-center gap-3 border-t border-edge pt-6">
<SubmitButton>{isNew ? "Create account" : "Save account"}</SubmitButton>
</div>
</form>
);
}

View file

@ -1,69 +0,0 @@
import { Extension, InputRule, markInputRule } from "@tiptap/core";
import { isValidLinkUrl } from "@/lib/validation";
/**
* Extra Markdown typing shortcuts on top of tiptap's built-ins.
*
* Tiptap's own bold/italic input rules only fire when the opening
* marker sits at the start of a line or after whitespace, so typing
* `dash—**bold**` or `(**bold**)` never converts. These variants also
* accept punctuation before the marker. The lookbehind (instead of a
* consumed prefix) matters: `markInputRule` deletes everything in the
* match around the content group, so a consumed prefix character would
* be swallowed with the asterisks.
*
* `[text](url)` has no built-in rule at all; the custom rule below
* inserts a link mark when the closing parenthesis is typed. URLs are
* checked with the same validator used for navigation links, so
* `javascript:` and friends never become clickable (the text is simply
* left as typed).
*
* The word-ish guard `[^\w*_\x60]` (not a word character, backtick, or
* marker) keeps rules from firing mid-word: `2**3**` stays math.
*/
const BOLD_STAR = /(?<=^|[^\w*_`])(?:\*\*(?![\s*])([^*]+)\*\*)$/;
const BOLD_UNDERSCORE = /(?<=^|[^\w*_`])(?:__(?![\s_])([^_]+)__)$/;
const ITALIC_STAR = /(?<=^|[^\w*_`])(?:\*(?![\s*])([^*]+?)\*)$/;
const ITALIC_UNDERSCORE = /(?<=^|[^\w*_`])(?:_(?![\s_])([^_]+?)_)$/;
const LINK = /\[([^\]]+)\]\(([^()\s]+)\)$/;
export const MarkdownInputRules = Extension.create({
name: "markdownInputRules",
addInputRules() {
const { bold, italic, link } = this.editor.schema.marks;
const rules: InputRule[] = [];
if (bold) {
rules.push(
markInputRule({ find: BOLD_STAR, type: bold }),
markInputRule({ find: BOLD_UNDERSCORE, type: bold }),
);
}
if (italic) {
rules.push(
markInputRule({ find: ITALIC_STAR, type: italic }),
markInputRule({ find: ITALIC_UNDERSCORE, type: italic }),
);
}
if (link) {
rules.push(
new InputRule({
find: LINK,
handler: ({ state, range, match }) => {
const [, text, href] = match;
if (!isValidLinkUrl(href)) return;
state.tr.replaceWith(
range.from,
range.to,
state.schema.text(text, [link.create({ href })]),
);
// Don't keep writing inside the link after it's inserted.
state.tr.removeStoredMark(link);
},
}),
);
}
return rules;
},
});

View file

@ -1,240 +0,0 @@
"use client";
import { useActionState, useId, useState } from "react";
import { Button, ErrorText, HelpText, Input, Label, Textarea } from "@/components/ui";
import { type FormState, firstFieldError, initialFormState } from "@/lib/forms";
import { formatDate, isoDate } from "@/lib/format";
import type { PublicComment } from "@/lib/services/comments";
type CommentAction = (prev: FormState, formData: FormData) => Promise<FormState>;
function countComments(list: PublicComment[]): number {
return list.reduce((sum, c) => sum + 1 + countComments(c.replies), 0);
}
function CommentForm({
postId,
parentId,
action,
onCancel,
}: {
postId: number;
parentId: number | null;
action: CommentAction;
onCancel?: () => void;
}) {
const [state, formAction] = useActionState(action, initialFormState);
const ids = useId();
const err = (field: string) => firstFieldError(state, field);
if (state.status === "success") {
return (
<p
role="status"
className="rounded-md border border-success/40 bg-success/10 px-4 py-3 text-sm text-success"
>
Thanks! Your comment is awaiting moderation and will appear once approved.
</p>
);
}
return (
<form action={formAction} className="space-y-4">
{state.formError && (
<p
role="alert"
className="rounded-md border border-danger/40 bg-danger/10 px-4 py-2.5 text-sm text-danger"
>
{state.formError}
</p>
)}
<input type="hidden" name="postId" value={postId} />
<input type="hidden" name="parentId" value={parentId ?? ""} />
{/* Honeypot — hidden from people, tempting to bots. */}
<div className="hidden" aria-hidden="true">
<label htmlFor={`${ids}-website`}>Website</label>
<input id={`${ids}-website`} name="website" type="text" tabIndex={-1} autoComplete="off" />
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<Label htmlFor={`${ids}-name`}>Name</Label>
<Input
id={`${ids}-name`}
name="authorName"
required
maxLength={120}
autoComplete="name"
aria-invalid={err("authorName") ? true : undefined}
/>
<ErrorText>{err("authorName")}</ErrorText>
</div>
<div>
<Label htmlFor={`${ids}-email`}>Email</Label>
<Input
id={`${ids}-email`}
name="authorEmail"
type="email"
required
maxLength={254}
autoComplete="email"
aria-invalid={err("authorEmail") ? true : undefined}
/>
<ErrorText>{err("authorEmail")}</ErrorText>
</div>
</div>
<label className="flex cursor-pointer items-start gap-2 text-sm text-ink">
<input type="checkbox" name="emailPublic" className="mt-0.5 size-4 accent-(--link)" />
<span>
Show my email publicly
<span className="block text-xs text-ink-muted">
Leave unchecked to keep your email visible to the site owner only.
</span>
</span>
</label>
<div>
<Label htmlFor={`${ids}-body`}>Comment</Label>
<Textarea
id={`${ids}-body`}
name="body"
required
rows={parentId === null ? 5 : 3}
maxLength={5000}
aria-invalid={err("body") ? true : undefined}
/>
<ErrorText>{err("body")}</ErrorText>
</div>
<div className="flex items-center gap-3">
<Button type="submit">{parentId === null ? "Post comment" : "Post reply"}</Button>
{onCancel && (
<Button type="button" variant="ghost" onClick={onCancel}>
Cancel
</Button>
)}
<HelpText>Comments are reviewed before they appear.</HelpText>
</div>
</form>
);
}
function CommentItem({
comment,
postId,
action,
replyTo,
setReplyTo,
depth,
}: {
comment: PublicComment;
postId: number;
action: CommentAction;
replyTo: number | null;
setReplyTo: (id: number | null) => void;
depth: number;
}) {
return (
<li>
<article className="rounded-lg border border-edge bg-surface p-4">
<header className="flex flex-wrap items-baseline gap-x-2 text-sm">
<span className="font-semibold text-ink-strong">{comment.authorName}</span>
{comment.authorEmail && (
<a
href={`mailto:${comment.authorEmail}`}
className="text-xs text-link hover:underline"
>
{comment.authorEmail}
</a>
)}
<time dateTime={isoDate(comment.createdAt)} className="text-xs text-ink-muted">
{formatDate(comment.createdAt)}
</time>
</header>
<p className="mt-2 whitespace-pre-wrap text-sm leading-relaxed text-ink">
{comment.body}
</p>
<footer className="mt-2">
<button
type="button"
className="text-xs font-medium text-link hover:underline"
onClick={() => setReplyTo(replyTo === comment.id ? null : comment.id)}
>
{replyTo === comment.id ? "Close reply form" : "Reply"}
</button>
</footer>
</article>
{replyTo === comment.id && (
<div className="mt-3 border-l-2 border-edge-strong pl-4">
<CommentForm
postId={postId}
parentId={comment.id}
action={action}
onCancel={() => setReplyTo(null)}
/>
</div>
)}
{comment.replies.length > 0 && (
// Cap the visual indent so deep threads stay readable on phones.
<ul className={`mt-3 space-y-3 ${depth < 4 ? "border-l-2 border-edge pl-4 sm:pl-6" : ""}`}>
{comment.replies.map((reply) => (
<CommentItem
key={reply.id}
comment={reply}
postId={postId}
action={action}
replyTo={replyTo}
setReplyTo={setReplyTo}
depth={depth + 1}
/>
))}
</ul>
)}
</li>
);
}
export function CommentsSection({
postId,
comments,
action,
}: {
postId: number;
comments: PublicComment[];
action: CommentAction;
}) {
const [replyTo, setReplyTo] = useState<number | null>(null);
const total = countComments(comments);
const ids = useId();
return (
<section aria-labelledby={`${ids}-comments`} className="mx-auto mt-12 max-w-[46rem] border-t border-edge pt-8">
<h2 id={`${ids}-comments`} className="text-xl font-bold tracking-tight text-ink-bright">
{total === 0 ? "Comments" : total === 1 ? "1 comment" : `${total} comments`}
</h2>
{total === 0 ? (
<p className="mt-4 text-sm text-ink-muted">No comments yet. Start the conversation!</p>
) : (
<ul className="mt-6 space-y-4">
{comments.map((comment) => (
<CommentItem
key={comment.id}
comment={comment}
postId={postId}
action={action}
replyTo={replyTo}
setReplyTo={setReplyTo}
depth={0}
/>
))}
</ul>
)}
<div className="mt-10">
<h3 className="mb-4 text-lg font-semibold text-ink-strong">Leave a comment</h3>
<CommentForm postId={postId} parentId={null} action={action} />
</div>
</section>
);
}

View file

@ -1,13 +0,0 @@
import { sanitizeHtml } from "@/lib/html";
/**
* Server component rendering a stored post/page body. Bodies are HTML
* produced by the admin editor (or converted from Markdown at seed time)
* and are sanitized both on save and here on render, so the injected
* HTML can never contain scripts, event handlers, or javascript: URLs.
*/
export function ContentBody({ html }: { html: string }) {
return (
<div className="markdown-body" dangerouslySetInnerHTML={{ __html: sanitizeHtml(html) }} />
);
}

View file

@ -1,7 +0,0 @@
export function EmptyState({ children }: { children: React.ReactNode }) {
return (
<div className="rounded-lg border border-dashed border-edge-strong bg-surface/50 px-6 py-14 text-center text-ink-muted">
{children}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show more