├── .gitignore
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── create.js
├── docs
├── CNAME
├── assets
│ ├── favicon.ico
│ ├── logo.svg
│ ├── site.webmanifest
│ └── symbol.svg
├── index.html
└── styles.css
├── package-lock.json
├── package.json
└── template
├── .env.example
├── app
├── api
│ └── publish
│ │ └── route.ts
├── components
│ ├── CreateFolderModal.tsx
│ ├── FileList.tsx
│ ├── FileViewModal.tsx
│ ├── ImageModal.tsx
│ ├── MarkdownEditor.tsx
│ ├── PublishButton.tsx
│ └── markdown
│ │ └── MarkdownComponents.tsx
├── edit
│ ├── [...filename]
│ │ └── page.tsx
│ └── layout.tsx
├── globals.css
├── layout.tsx
├── layouts
│ └── AppLayout.tsx
├── lib
│ └── publish.ts
├── page.tsx
├── styles
│ ├── components
│ │ ├── CreateFolderModal.css
│ │ ├── FileList.css
│ │ ├── FileViewModal.css
│ │ ├── ImageModal.css
│ │ ├── MarkdownEditor.css
│ │ └── RevalidateButton.css
│ ├── editor.css
│ └── utilities.css
├── types
│ └── markdown.ts
└── utils
│ └── markdown.ts
├── eslint.config.mjs
├── lib
└── supabase.ts
├── middleware.ts
├── next.config.js
├── package-lock.json
├── package.json
├── postcss.config.js
├── postcss.config.mjs
├── public
├── SupawaldBanner.png
├── favicon.ico
├── images
│ ├── screenshot.png
│ ├── viewbucket.png
│ ├── viewimage.png
│ └── viewmarkdown.png
└── logo.png
├── src
└── app
│ ├── globals.css
│ ├── layout.tsx
│ └── page.tsx
├── tailwind.config.js
├── tsconfig.json
└── types
└── supabase.ts
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /template/node_modules/
5 | /node_modules
6 | /.pnp
7 | .pnp.js
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /template/.next/
14 | /.next/
15 | /out/
16 |
17 | # production
18 | /build
19 |
20 | # misc
21 | .DS_Store
22 | *.pem
23 |
24 | # debug
25 | npm-debug.log*
26 | yarn-debug.log*
27 | yarn-error.log*
28 |
29 | # local env files
30 | .env*.local
31 |
32 | # vercel
33 | .vercel
34 |
35 | # typescript
36 | *.tsbuildinfo
37 | next-env.d.ts
38 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing to Supawald
2 |
3 | Thank you for your interest in contributing to Supawald! This document provides guidelines and steps for contributing to the project.
4 |
5 | ## Code of Conduct
6 |
7 | By participating in this project, you agree to abide by our Code of Conduct. Please report unacceptable behavior to the project maintainers.
8 |
9 | ## How to Contribute
10 |
11 | ### Reporting Bugs
12 |
13 | 1. Check if the bug has already been reported in the issues section
14 | 2. If not, create a new issue with:
15 | - A clear description of the bug
16 | - Steps to reproduce
17 | - Expected behavior
18 | - Actual behavior
19 | - Screenshots (if applicable)
20 | - Your environment details (OS, Node.js version, etc.)
21 |
22 | ### Suggesting Enhancements
23 |
24 | 1. Check if the enhancement has already been suggested
25 | 2. Create a new issue with:
26 | - A clear description of the enhancement
27 | - Why this enhancement would be useful
28 | - Any specific implementation ideas you have
29 |
30 | ### Pull Requests
31 |
32 | 1. Fork the repository
33 | 2. Create a new branch for your feature/fix:
34 | ```bash
35 | git checkout -b feature/your-feature-name
36 | # or
37 | git checkout -b fix/your-fix-name
38 | ```
39 | 3. Make your changes
40 | 4. Write/update tests if applicable
41 | 5. Update documentation if needed
42 | 6. Commit your changes with clear commit messages
43 | 7. Push to your fork
44 | 8. Create a Pull Request
45 |
46 | ### Development Setup
47 |
48 | 1. Clone your fork:
49 | ```bash
50 | git clone https://github.com/your-username/supawald.git
51 | cd supawald
52 | ```
53 |
54 | 2. Install dependencies:
55 | ```bash
56 | npm install
57 | ```
58 |
59 | 3. Copy the environment file:
60 | ```bash
61 | cp .env.example .env.local
62 | ```
63 |
64 | 4. Update `.env.local` with your Supabase credentials
65 |
66 | 5. Start the development server:
67 | ```bash
68 | npm run dev
69 | ```
70 |
71 | ### Code Style
72 |
73 | - Follow the existing code style
74 | - Use TypeScript for all new code
75 | - Use meaningful variable and function names
76 | - Add comments for complex logic
77 | - Keep functions focused and small
78 | - Use proper error handling
79 |
80 | ### Testing
81 |
82 | - Write tests for new features
83 | - Ensure all tests pass before submitting PR
84 | - Update tests when modifying existing features
85 |
86 | ### Documentation
87 |
88 | - Update README.md if needed
89 | - Add comments for complex code
90 | - Update API documentation if applicable
91 |
92 | ## Questions?
93 |
94 | Feel free to open an issue for any questions or concerns about contributing.
95 |
96 | Thank you for contributing to Supawald!
97 |
98 | ## 📦 Publishing to npm
99 |
100 | To publish updates to the `create-supawald` package and tie it to the GitHub repo, follow these steps:
101 |
102 | ### 🔗 1. Add GitHub Metadata to `package.json`
103 |
104 | Make sure the root-level `package.json` includes this:
105 |
106 | ```json
107 | "repository": {
108 | "type": "git",
109 | "url": "https://github.com/StructuredLabs/supawald.git"
110 | },
111 | "bugs": {
112 | "url": "https://github.com/StructuredLabs/supawald/issues"
113 | },
114 | "homepage": "https://github.com/StructuredLabs/supawald#readme"
115 | ```
116 |
117 | > This links the npm package back to the official GitHub repo.
118 |
119 | ---
120 |
121 | ### 🏷️ 2. Tag the Release in Git
122 |
123 | After bumping the version in `package.json`, tag and push the release:
124 |
125 | ```bash
126 | git add .
127 | git commit -m "v1.0.0 release"
128 | git tag v1.0.0
129 | git push origin main --tags
130 | ```
131 |
132 | Use the same version number as in `package.json`.
133 |
134 | ---
135 |
136 | ### 🚀 3. Publish to npm
137 |
138 | If you're not already logged in:
139 |
140 | ```bash
141 | npm login
142 | ```
143 |
144 | Then publish the package:
145 |
146 | ```bash
147 | npm publish --access public
148 | ```
149 |
150 | > The CLI will then be available via:
151 |
152 | ```bash
153 | npx create-supawald my-app
154 | ```
155 |
156 | ---
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | Copyright 2024 Supawald
179 |
180 | Licensed under the Apache License, Version 2.0 (the "License");
181 | you may not use this file except in compliance with the License.
182 | You may obtain a copy of the License at
183 |
184 | http://www.apache.org/licenses/LICENSE-2.0
185 |
186 | Unless required by applicable law or agreed to in writing, software
187 | distributed under the License is distributed on an "AS IS" BASIS,
188 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
189 | See the License for the specific language governing permissions and
190 | limitations under the License.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | A headless CMS for Supabase Storage. Built with Next.js 14, TypeScript, and Tailwind CSS.
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | ## 🚀 Quick Start with Template
25 |
26 | 1. **Create a new project**
27 | ```bash
28 | npx create-supawald my-app
29 | cd my-app
30 | ```
31 |
32 | 2. **Set up your Supabase project**
33 | - Create a new bucket in Supabase Storage
34 | - Get your project URL and anon key from Settings -> API
35 | - Copy `.env.example` to `.env.local` and fill in your credentials:
36 | ```env
37 | NEXT_PUBLIC_SUPABASE_URL=your-project-url
38 | NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
39 | NEXT_PUBLIC_BUCKET_NAME=your-bucket-name
40 | AUTH_USERNAME=admin
41 | AUTH_PASSWORD=your-secure-password
42 | ```
43 |
44 | 3. **Start the development server**
45 | ```bash
46 | npm install
47 | npm run dev
48 | ```
49 |
50 | 4. **Visit http://localhost:3000** and log in with your credentials
51 |
52 | > **Note**: The template includes a complete Next.js application with file management, authentication, and static site generation support.
53 |
54 | # Supawald
55 |
56 | A headless CMS for Supabase Storage. Built with Next.js 14, TypeScript, and Tailwind CSS. Provides a clean interface for managing files in Supabase Storage buckets with real-time updates and static site generation support.
57 |
58 | 
59 |
60 | ## What is Supawald?
61 |
62 | Supawald is a file management system that turns Supabase Storage into a full-featured CMS. It's designed for developers who need a simple way to manage assets for their Next.js applications, blogs, or any project using Supabase Storage.
63 |
64 | ### Key Features
65 |
66 | - **File Management**
67 | - Drag & drop file uploads
68 | - Folder navigation
69 | - File deletion
70 | - In-place file editing
71 | - Real-time updates via Supabase subscriptions
72 |
73 | - **Developer Experience**
74 | - TypeScript for type safety
75 | - Next.js 14 App Router
76 | - Tailwind CSS for styling
77 | - Basic auth protection
78 | - Publish API for static site generation
79 |
80 | - **Storage Features**
81 | - Public/private bucket support
82 | - File type detection
83 | - File size tracking
84 | - Last modified timestamps
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 | ## Use Cases
94 |
95 | 1. **Blog Asset Management**
96 | - Store and manage images, documents, and other media
97 | - Organize content by date, category, or project
98 | - Quick access to frequently used assets
99 |
100 | 2. **Document Management**
101 | - Store and organize PDFs, spreadsheets, and other documents
102 | - Version control through Supabase's built-in features
103 | - Secure access control via bucket policies
104 |
105 | 3. **Application Assets**
106 | - Manage static assets for web applications
107 | - Store user uploads and generated content
108 | - Handle media files for user profiles or content
109 |
110 | ## Technical Requirements
111 |
112 | - Node.js 18+
113 | - Supabase account (free tier works)
114 | - npm or yarn
115 |
116 | ## ▶️ Quick Start
117 |
118 | ### Use the CLI
119 |
120 | ```bash
121 | npx create-supawald my-app
122 | cd my-app
123 | npm install
124 | npm run dev
125 | ```
126 |
127 | ### Or clone manually
128 |
129 | ```bash
130 | git clone https://github.com/yourusername/supawald.git
131 | cd supawald/template
132 | npm install
133 | npm run dev
134 | ```
135 |
136 | 2. **Set Up Supabase**
137 | ```sql
138 | -- Create a new bucket in Supabase Storage
139 | -- Name it something like 'blog-content' or 'assets'
140 | -- Set appropriate privacy settings (public/private)
141 | ```
142 |
143 | 3. **Configure Environment**
144 | ```bash
145 | cp .env.example .env.local
146 | ```
147 | ```env
148 | # Supabase
149 | NEXT_PUBLIC_SUPABASE_URL=your-project-url
150 | NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
151 | NEXT_PUBLIC_BUCKET_NAME=your-bucket-name
152 |
153 | # Auth (for admin access)
154 | AUTH_USERNAME=admin
155 | AUTH_PASSWORD=your-secure-password
156 |
157 | # Publish API (for static site generation)
158 | PUBLISH_URL=https://your-site.com/api/publish
159 | PUBLISH_TOKEN=your-secure-token
160 | ```
161 |
162 | 4. **Run Development Server**
163 | ```bash
164 | npm run dev
165 | ```
166 |
167 | ## Static Site Generation Integration
168 |
169 | Supawald includes a publish API that triggers regeneration of static pages that depend on Supabase Storage data. This is useful for:
170 | - Blog posts that display uploaded images
171 | - Product pages with product images
172 | - Any static page that needs to reflect changes in your storage bucket
173 |
174 | ### Setting Up Your Static Site
175 |
176 | 1. **Create a Publish API Route**
177 | Create a new API route in your Next.js application at `pages/api/publish.ts`:
178 |
179 | ```typescript
180 | import { NextApiRequest, NextApiResponse } from 'next'
181 |
182 | export default async function handler(
183 | req: NextApiRequest,
184 | res: NextApiResponse
185 | ) {
186 | // Check for secret to confirm this is a valid request
187 | if (req.headers.authorization !== `Bearer ${process.env.PUBLISH_TOKEN}`) {
188 | return res.status(401).json({ message: 'Invalid token' })
189 | }
190 |
191 | try {
192 | // Revalidate your static pages
193 | await res.revalidate('/') // Revalidate homepage
194 | await res.revalidate('/blog') // Revalidate blog pages
195 | await res.revalidate('/products') // Revalidate product pages
196 |
197 | return res.json({ revalidated: true })
198 | } catch (err) {
199 | // If there was an error, Next.js will continue
200 | // to show the last successfully generated page
201 | return res.status(500).send('Error revalidating')
202 | }
203 | }
204 | ```
205 |
206 | 2. **Configure Environment Variables**
207 | In your Supawald instance:
208 | ```env
209 | PUBLISH_URL=https://your-static-site.com/api/publish
210 | PUBLISH_TOKEN=your-secure-token
211 | ```
212 |
213 | In your static site:
214 | ```env
215 | PUBLISH_TOKEN=your-secure-token # Same token as above
216 | ```
217 |
218 | 3. **Using the Publish Button**
219 | When you click the publish button in Supawald:
220 | - It sends a POST request to your static site's publish API
221 | - Your static site regenerates the specified pages
222 | - The new content becomes available on your static site
223 |
224 | 
225 |
226 |
227 | ### Example Usage
228 |
229 | ```typescript
230 | // In your static site's page component
231 | export async function getStaticProps() {
232 | // Fetch data from Supabase Storage
233 | const { data: images } = await supabase.storage
234 | .from('your-bucket')
235 | .list('blog-images')
236 |
237 | return {
238 | props: {
239 | images,
240 | // ... other props
241 | },
242 | // Revalidate every hour
243 | revalidate: 3600
244 | }
245 | }
246 | ```
247 |
248 | When you update an image in Supawald and click publish:
249 | 1. The image is updated in Supabase Storage
250 | 2. The publish API is called
251 | 3. Your static pages are regenerated with the new image
252 | 4. The changes are live on your static site
253 |
254 | ## API Integration
255 |
256 | ### File Operations
257 |
258 | ```typescript
259 | // Example: Upload a file
260 | const { data, error } = await supabase.storage
261 | .from('your-bucket')
262 | .upload('path/to/file.jpg', file)
263 |
264 | // Example: Get file URL
265 | const { data: { publicUrl } } = supabase.storage
266 | .from('your-bucket')
267 | .getPublicUrl('path/to/file.jpg')
268 | ```
269 |
270 | ### Static Site Generation
271 |
272 | ```typescript
273 | // Trigger static page regeneration
274 | await fetch('/api/publish', {
275 | method: 'POST',
276 | headers: {
277 | 'Authorization': `Bearer ${process.env.PUBLISH_TOKEN}`
278 | }
279 | })
280 | ```
281 |
282 | ## 🔒 Security Best Practices
283 |
284 | 1. **Supabase Storage**
285 | - Use private buckets for sensitive content
286 | - Implement RLS policies for bucket access
287 | - Set up CORS rules for your domain
288 | - Use signed URLs for temporary access
289 |
290 | 2. **Authentication**
291 | - Use strong passwords for admin access
292 | - Implement rate limiting
293 | - Set up proper CORS headers
294 | - Use HTTPS in production
295 |
296 | 3. **Environment Variables**
297 | - Never commit `.env.local`
298 | - Rotate credentials regularly
299 | - Use different keys for development/production
300 | - Consider using a secrets manager
301 | - Keep your `PUBLISH_TOKEN` secure and only share with trusted services
302 |
303 | ## Development
304 |
305 | ```bash
306 | # Install dependencies
307 | npm install
308 |
309 | # Run development server
310 | npm run dev
311 |
312 | # Build for production
313 | npm run build
314 |
315 | # Start production server
316 | npm start
317 | ```
318 |
319 | ## **🤝 Contributing**
320 |
321 | See [CONTRIBUTING.md](CONTRIBUTING.md) for development guidelines.
322 |
323 | ## **📄 License**
324 |
325 | Apache 2.0 - See [LICENSE](LICENSE) for details.
326 |
327 | ## **🎉 Join the Community**
328 |
329 | - **GitHub Issues**: Found a bug? Let us know [here](https://github.com/StructuredLabs/supawald/issues).
330 | - **Community Forum**: Reach out [here](https://join.slack.com/t/structuredlabs-users/shared_invite/zt-31vvfitfm-_vG1HR9hYysR_56u_PfI8Q)
331 | - **Discussions**: Share your ideas and ask questions in our [discussion forum](https://github.com/StructuredLabs/supawald/discussions).
332 |
333 | ## **📢 Stay Connected**
334 |
335 |
336 |
337 |
338 |
339 |
340 |
341 |
342 |
--------------------------------------------------------------------------------
/create.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | const path = require("path");
3 | const fs = require("fs-extra");
4 |
5 | const targetDir = process.argv[2] || "supawald-app";
6 | const cwd = process.cwd();
7 | const dest = path.join(cwd, targetDir);
8 | const template = path.join(__dirname, "template");
9 |
10 | if (fs.existsSync(dest)) {
11 | console.error(`❌ Directory "${targetDir}" already exists.`);
12 | process.exit(1);
13 | }
14 |
15 | fs.copySync(template, dest);
16 | console.log(`✅ Supawald project created in "${targetDir}"`);
17 | console.log(`👉 cd ${targetDir}`);
18 | console.log("📦 Run `npm install` to get started!");
--------------------------------------------------------------------------------
/docs/CNAME:
--------------------------------------------------------------------------------
1 | supawald.com
--------------------------------------------------------------------------------
/docs/assets/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/StructuredLabs/supawald/3ee2414e577435dc4b7b938250a8d92018c58593/docs/assets/favicon.ico
--------------------------------------------------------------------------------
/docs/assets/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/docs/assets/site.webmanifest:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Supawald",
3 | "short_name": "Supawald",
4 | "icons": [
5 | {
6 | "src": "/assets/android-chrome-192x192.png",
7 | "sizes": "192x192",
8 | "type": "image/png"
9 | },
10 | {
11 | "src": "/assets/android-chrome-512x512.png",
12 | "sizes": "512x512",
13 | "type": "image/png"
14 | }
15 | ],
16 | "theme_color": "#ffffff",
17 | "background_color": "#ffffff",
18 | "display": "standalone"
19 | }
--------------------------------------------------------------------------------
/docs/assets/symbol.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/docs/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Supawald - Transform Your Supabase Storage into a Powerful CMS
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
41 |
42 |
43 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
Transform your Supabase Storage into a powerful CMS
73 |
Beautiful, intuitive interface for managing your content. Built for developers who want a simple, powerful way to handle their assets.
74 |
75 |
76 |
npx create-supawald my-app
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
102 |
103 |
125 |
126 |
140 |
141 |
142 |
143 |
144 |
145 |
homepage.json
146 |
Modified 2h ago
147 |
148 |
149 |
150 |
151 |
152 |
hero-banner.png
153 |
Modified 1d ago
154 |
155 |
156 |
157 |
158 |
159 |
blog-posts
160 |
12 items
161 |
162 |
163 |
164 |
165 |
166 |
team-photos
167 |
8 items
168 |
169 |
170 |
171 |
172 |
173 |
navigation.yaml
174 |
Modified 3d ago
175 |
176 |
177 |
178 |
179 |
180 |
products
181 |
24 items
182 |
183 |
184 |
185 |
186 |
187 |
188 |
189 |
190 |
191 |
192 |
193 |
194 | Everything You Need to Manage Content with Supabase
195 | Supawald turns your Supabase Storage into a modern, file-based CMS. Perfect for developers building with Next.js, Tailwind, and TypeScript.
196 |
197 |
198 |
199 |
200 |
Visual File Explorer
201 |
Navigate and manage folders and files inside your Supabase Storage with an intuitive, drag-and-drop interface.
202 |
203 |
204 |
205 |
206 |
In-Browser File Editing
207 |
Edit Markdown, text, and other plain files directly inside your browser — no need to clone or pull from anywhere.
208 |
209 |
210 |
211 |
212 |
Markdown Previews
213 |
Preview Markdown content in real-time with styled rendering. Perfect for managing blog posts, docs, and rich content.
214 |
215 |
216 |
217 |
218 |
Secure Access Control
219 |
Protect your storage with optional auth credentials. Supports public/private buckets and integrates with Supabase RLS.
220 |
221 |
222 |
223 |
224 |
Real-Time Updates
225 |
Powered by Supabase subscriptions, changes are reflected instantly — no refresh required.
226 |
227 |
228 |
229 |
230 |
Static Site Integration
231 |
Includes a built-in publish API for regenerating your static site pages. Perfect for blogs, product pages, and media-heavy content.
232 |
233 |
234 |
235 |
236 |
Developer First
237 |
Built with Next.js 14, Tailwind CSS, and TypeScript. Easily extend, self-host, or integrate with your existing stack.
238 |
239 |
240 |
241 |
242 |
Supabase Native
243 |
Built on top of Supabase Storage, using its APIs directly. No proprietary vendor lock-in or abstraction layer in the way.
244 |
245 |
246 |
247 |
248 |
Open Source
249 |
Apache 2.0-licensed and fully open source. Use it, fork it, modify it — no limitations, no paywalls, ever.
250 |
251 |
252 |
253 |
254 |
255 |
256 | Start building in three simple steps
257 |
258 |
259 |
1
260 |
Initialize Your Project
261 |
Kickstart a production-ready CMS with a single command. Supawald sets up everything you need to manage content via Supabase Storage.
262 |
npx create-supawald my-app
263 |
264 |
265 |
266 |
2
267 |
Connect to Supabase
268 |
Link your project to Supabase by configuring your credentials. You'll get real-time updates, secure access, and SSG support out of the box.
269 |
cp .env.example .env.local
270 |
271 |
272 |
273 |
3
274 |
Launch Your CMS
275 |
Install dependencies and start building. You'll have a modern, file-based CMS ready in seconds — perfect for blogs, products, or anything in between.
276 |
npm install && npm run dev
277 |
278 |
279 |
280 |
281 |
282 |
283 |
284 |
Open-source at its core. Enterprise when you need it.
285 |
Use it your way. Start for free or book a demo for enterprise support.
286 |
290 |
291 |
292 |
293 |
294 |
295 |
318 |
319 |
320 |
377 |
378 |
379 |
--------------------------------------------------------------------------------
/docs/styles.css:
--------------------------------------------------------------------------------
1 | :root {
2 | /* Light theme variables */
3 | --background: #ffffff;
4 | --foreground: #020817;
5 | --card: #ffffff;
6 | --card-foreground: #020817;
7 | --popover: #ffffff;
8 | --popover-foreground: #020817;
9 | --primary: #0f172a;
10 | --primary-foreground: #f8fafc;
11 | --secondary: #f1f5f9;
12 | --secondary-foreground: #0f172a;
13 | --muted: #f1f5f9;
14 | --muted-foreground: #64748b;
15 | --accent: #f1f5f9;
16 | --accent-foreground: #0f172a;
17 | --destructive: #ef4444;
18 | --destructive-foreground: #f8fafc;
19 | --border: #e2e8f0;
20 | --input: #e2e8f0;
21 | --ring: #020817;
22 | --radius: 0.5rem;
23 | --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
24 | --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
25 | --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
26 | --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
27 | }
28 |
29 | /* Dark theme variables */
30 | [data-theme="dark"] {
31 | --background: #020817;
32 | --foreground: #f8fafc;
33 | --card: #020817;
34 | --card-foreground: #f8fafc;
35 | --popover: #020817;
36 | --popover-foreground: #f8fafc;
37 | --primary: #f8fafc;
38 | --primary-foreground: #020817;
39 | --secondary: #1e293b;
40 | --secondary-foreground: #f8fafc;
41 | --muted: #1e293b;
42 | --muted-foreground: #94a3b8;
43 | --accent: #1e293b;
44 | --accent-foreground: #f8fafc;
45 | --destructive: #7f1d1d;
46 | --destructive-foreground: #f8fafc;
47 | --border: #1e293b;
48 | --input: #1e293b;
49 | --ring: #cbd5e1;
50 | }
51 |
52 | * {
53 | margin: 0;
54 | padding: 0;
55 | box-sizing: border-box;
56 | font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
57 | }
58 |
59 | body {
60 | font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
61 | font-size: 14px;
62 | line-height: 1.5;
63 | color: var(--foreground);
64 | background-color: var(--background);
65 | transition: background-color 0.3s ease, color 0.3s ease;
66 | }
67 |
68 | .app-container {
69 | min-height: 100vh;
70 | display: flex;
71 | flex-direction: column;
72 | }
73 |
74 | /* Header & Navigation */
75 | .header {
76 | position: fixed;
77 | top: 0;
78 | left: 0;
79 | right: 0;
80 | background-color: rgba(var(--background), 0.8);
81 | border-bottom: 1px solid var(--border);
82 | z-index: 1000;
83 | backdrop-filter: blur(10px);
84 | box-shadow: var(--shadow-sm);
85 | }
86 |
87 | .nav-container {
88 | max-width: 1200px;
89 | margin: 0 auto;
90 | padding: 0.75rem 1rem;
91 | display: flex;
92 | justify-content: space-between;
93 | align-items: center;
94 | height: 4rem;
95 | }
96 |
97 | .logo {
98 | display: flex;
99 | align-items: center;
100 | gap: 0.5rem;
101 | font-size: 1.25rem;
102 | font-weight: 600;
103 | color: var(--foreground);
104 | }
105 |
106 | .logo img {
107 | height: 1.75rem;
108 | }
109 |
110 | .nav-links {
111 | display: flex;
112 | gap: 1rem;
113 | align-items: center;
114 | font-size: 0.8rem;
115 | }
116 |
117 | .nav-link {
118 | text-decoration: none;
119 | color: black;
120 | font-weight: 500;
121 | transition: all 0.2s;
122 | position: relative;
123 | font-size: 0.8rem;
124 | padding: 0.5rem;
125 | border-radius: var(--radius);
126 | }
127 |
128 | .nav-link:hover {
129 | color: var(--foreground);
130 | background-color: var(--muted);
131 | }
132 |
133 | .theme-toggle {
134 | background: none;
135 | border: none;
136 | color: var(--foreground);
137 | cursor: pointer;
138 | padding: 0.5rem;
139 | border-radius: var(--radius);
140 | transition: background-color 0.2s;
141 | }
142 |
143 | .theme-toggle:hover {
144 | background-color: var(--secondary);
145 | }
146 |
147 | .github-link {
148 | display: inline-flex;
149 | align-items: center;
150 | gap: 0.5rem;
151 | padding: 0.5rem 1rem;
152 | background-color: var(--muted);
153 | border-radius: var(--radius);
154 | text-decoration: none;
155 | color: var(--foreground);
156 | font-weight: 500;
157 | transition: all 0.2s;
158 | font-size: 0.875rem;
159 | }
160 |
161 | .github-link:hover {
162 | background-color: var(--accent);
163 | transform: translateY(-1px);
164 | }
165 |
166 | .demo-link {
167 | display: inline-flex;
168 | align-items: center;
169 | gap: 0.5rem;
170 | padding: 0.5rem 1rem;
171 | background-color: var(--primary);
172 | border-radius: var(--radius);
173 | text-decoration: none;
174 | color: #ffffff;
175 | font-weight: 500;
176 | transition: all 0.2s;
177 | font-size: 0.875rem;
178 | }
179 |
180 | .demo-link:hover {
181 | background-color: #333333;
182 | transform: translateY(-1px);
183 | }
184 |
185 | /* Main Content */
186 | .main-content {
187 | flex: 1;
188 | padding-top: 4rem;
189 | }
190 |
191 | /* Hero Section */
192 | .hero {
193 | padding: 6rem 2rem 6rem;
194 | background-color: var(--background);
195 | color: var(--foreground);
196 | text-align: center;
197 | position: relative;
198 | overflow: hidden;
199 | display: flex;
200 | flex-direction: column;
201 | align-items: center;
202 | gap: 2.5rem;
203 | max-width: 1400px;
204 | margin: 0 auto;
205 | }
206 |
207 | .hero-content {
208 | max-width: 600px;
209 | position: relative;
210 | z-index: 1;
211 | display: flex;
212 | flex-direction: column;
213 | align-items: center;
214 | }
215 |
216 | .hero h1 {
217 | font-size: 3rem;
218 | font-weight: 600;
219 | margin-bottom: 1.5rem;
220 | line-height: 1.1;
221 | letter-spacing: -0.02em;
222 | }
223 |
224 | .hero .subtitle {
225 | font-size: 1rem;
226 | color: var(--muted-foreground);
227 | margin-bottom: 1.5rem;
228 | line-height: 1.5;
229 | }
230 |
231 | /* Terminal Code Block */
232 | .terminal {
233 | background: var(--primary);
234 | border-radius: 8px;
235 | box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
236 | margin: 0.5rem auto;
237 | max-width: 600px;
238 | overflow: hidden;
239 | position: relative;
240 | }
241 |
242 | .terminal-header {
243 | background: #2d2d2d;
244 | padding: 8px 16px;
245 | display: flex;
246 | align-items: center;
247 | }
248 |
249 | .terminal-controls {
250 | display: flex;
251 | gap: 8px;
252 | }
253 |
254 | .control {
255 | width: 12px;
256 | height: 12px;
257 | border-radius: 50%;
258 | display: inline-block;
259 | }
260 |
261 | .close { background: #ff5f56; }
262 | .minimize { background: #ffbd2e; }
263 | .maximize { background: #27c93f; }
264 |
265 | .terminal-content {
266 | padding: 16px 24px;
267 | position: relative;
268 | }
269 |
270 | .terminal-content pre {
271 | margin: 0;
272 | font-family: 'SF Mono', 'Menlo', 'Monaco', 'Courier New', monospace;
273 | font-size: 14px;
274 | line-height: 1.5;
275 | color: var(--primary-foreground);
276 | padding-right: 40px;
277 | }
278 |
279 | .terminal-content code {
280 | font-family: 'SF Mono', 'Menlo', 'Monaco', 'Courier New', monospace;
281 | }
282 |
283 | .copy-button {
284 | position: absolute;
285 | top: 50%;
286 | right: 8px;
287 | transform: translateY(-50%);
288 | background: transparent;
289 | border: none;
290 | color: var(--primary-foreground);
291 | opacity: 0.7;
292 | cursor: pointer;
293 | padding: 4px 8px;
294 | border-radius: 4px;
295 | transition: all 0.2s ease;
296 | }
297 |
298 | .copy-button:hover {
299 | background: rgba(255, 255, 255, 0.1);
300 | opacity: 1;
301 | }
302 |
303 | .copy-button.copied {
304 | color: #27c93f;
305 | }
306 |
307 | .copy-button.copied i {
308 | animation: fadeOut 1s ease-in-out;
309 | }
310 |
311 | @keyframes fadeOut {
312 | 0% { opacity: 1; }
313 | 50% { opacity: 0; }
314 | 100% { opacity: 1; }
315 | }
316 |
317 | .cta-buttons {
318 | display: flex;
319 | gap: 1rem;
320 | justify-content: center;
321 | }
322 |
323 | .button {
324 | padding: 0.75rem 1.25rem;
325 | border-radius: var(--radius);
326 | text-decoration: none;
327 | font-weight: 600;
328 | transition: all 0.15s ease;
329 | display: inline-flex;
330 | align-items: center;
331 | gap: 0.5rem;
332 | font-size: 0.875rem;
333 | line-height: 1;
334 | min-width: 160px;
335 | justify-content: center;
336 | height: 40px;
337 | cursor: pointer;
338 | box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
339 | }
340 |
341 | .button.primary {
342 | background-color: var(--primary);
343 | color: var(--primary-foreground);
344 | border: 1px solid var(--primary);
345 | }
346 |
347 | .button.primary:hover {
348 | opacity: 0.9;
349 | transform: translateY(-1px);
350 | box-shadow: var(--shadow-md);
351 | }
352 |
353 | .button.secondary {
354 | background-color: var(--secondary);
355 | color: var(--secondary-foreground);
356 | border: 1px solid var(--border);
357 | }
358 |
359 | .button.secondary:hover {
360 | background-color: var(--muted);
361 | transform: translateY(-1px);
362 | box-shadow: var(--shadow-md);
363 | }
364 |
365 | .hero-visual {
366 | width: 100%;
367 | max-width: 1000px;
368 | position: relative;
369 | z-index: 1;
370 | }
371 |
372 | /* Animation Keyframes */
373 | @keyframes float {
374 | 0% { transform: translateY(0px); }
375 | 50% { transform: translateY(-10px); }
376 | 100% { transform: translateY(0px); }
377 | }
378 |
379 | @keyframes pulse {
380 | 0% { opacity: 0.6; }
381 | 50% { opacity: 1; }
382 | 100% { opacity: 0.6; }
383 | }
384 |
385 | @keyframes slideIn {
386 | from { transform: translateX(-20px); opacity: 0; }
387 | to { transform: translateX(0); opacity: 1; }
388 | }
389 |
390 | @keyframes fadeInUp {
391 | from { transform: translateY(20px); opacity: 0; }
392 | to { transform: translateY(0); opacity: 1; }
393 | }
394 |
395 | /* Updated CMS Preview Styles */
396 | .cms-preview {
397 | background: var(--card);
398 | border-radius: var(--radius);
399 | border: 1px solid var(--border);
400 | box-shadow: var(--shadow-lg);
401 | overflow: hidden;
402 | margin: 0 auto;
403 | width: 100%;
404 | animation: float 6s ease-in-out infinite;
405 | }
406 |
407 | .cms-header {
408 | background: var(--background);
409 | padding: 0.75rem 1rem;
410 | display: flex;
411 | justify-content: space-between;
412 | align-items: center;
413 | border-bottom: 1px solid var(--border);
414 | animation: slideIn 0.5s ease-out;
415 | }
416 |
417 | .cms-breadcrumb {
418 | font-size: 0.875rem;
419 | color: var(--muted-foreground);
420 | display: flex;
421 | align-items: center;
422 | gap: 0.75rem;
423 | font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
424 | }
425 |
426 | .cms-path {
427 | display: flex;
428 | align-items: center;
429 | gap: 0.5rem;
430 | }
431 |
432 | .path-segment {
433 | color: var(--foreground);
434 | font-weight: 500;
435 | }
436 |
437 | .path-segment:last-child {
438 | color: var(--muted-foreground);
439 | font-weight: 400;
440 | }
441 |
442 | .path-separator {
443 | color: var(--muted-foreground);
444 | opacity: 0.5;
445 | }
446 |
447 | .cms-nav-item {
448 | display: flex;
449 | align-items: center;
450 | gap: 0.75rem;
451 | padding: 0.75rem 1rem;
452 | border-radius: var(--radius);
453 | transition: all 0.2s;
454 | animation: fadeInUp 0.5s ease-out;
455 | animation-fill-mode: both;
456 | font-size: 0.875rem;
457 | color: var(--muted-foreground);
458 | cursor: pointer;
459 | }
460 |
461 | .cms-nav-item.active {
462 | background: var(--muted);
463 | color: var(--foreground);
464 | }
465 |
466 | .cms-nav-item:hover {
467 | background: var(--muted);
468 | transform: translateX(5px);
469 | }
470 |
471 | .nav-icon {
472 | font-size: 1rem;
473 | color: inherit;
474 | opacity: 0.8;
475 | width: 1.25rem;
476 | text-align: center;
477 | }
478 |
479 | .nav-label {
480 | font-weight: 500;
481 | color: inherit;
482 | }
483 |
484 | .search-bar {
485 | flex: 1;
486 | display: flex;
487 | align-items: center;
488 | gap: 0.75rem;
489 | padding: 0.5rem 0.75rem;
490 | background: var(--muted);
491 | border-radius: var(--radius);
492 | height: 2.5rem;
493 | animation: slideIn 0.5s ease-out;
494 | }
495 |
496 | .search-icon {
497 | font-size: 0.875rem;
498 | color: var(--muted-foreground);
499 | opacity: 0.7;
500 | }
501 |
502 | .search-input {
503 | flex: 1;
504 | font-size: 0.875rem;
505 | color: var(--muted-foreground);
506 | background: transparent;
507 | border: none;
508 | outline: none;
509 | }
510 |
511 | .search-input::placeholder {
512 | color: var(--muted-foreground);
513 | opacity: 0.7;
514 | }
515 |
516 | .toolbar-actions {
517 | display: flex;
518 | gap: 0.5rem;
519 | }
520 |
521 | .action-button {
522 | width: 2.5rem;
523 | height: 2.5rem;
524 | background: var(--muted);
525 | border-radius: var(--radius);
526 | border: none;
527 | transition: all 0.2s;
528 | animation: fadeInUp 0.5s ease-out;
529 | cursor: pointer;
530 | display: flex;
531 | align-items: center;
532 | justify-content: center;
533 | color: var(--muted-foreground);
534 | }
535 |
536 | .action-button:hover {
537 | transform: translateY(-2px);
538 | background: var(--secondary);
539 | }
540 |
541 | .action-button.primary {
542 | background: var(--primary);
543 | color: var(--primary-foreground);
544 | animation-delay: 0.2s;
545 | }
546 |
547 | .action-button.primary:hover {
548 | opacity: 0.9;
549 | }
550 |
551 | .action-button i {
552 | font-size: 0.875rem;
553 | }
554 |
555 | .cms-file {
556 | background: var(--background);
557 | border: 1px solid var(--border);
558 | border-radius: var(--radius);
559 | padding: 1rem;
560 | display: flex;
561 | gap: 1rem;
562 | align-items: center;
563 | transition: all 0.3s ease;
564 | animation: fadeInUp 0.5s ease-out;
565 | animation-fill-mode: both;
566 | cursor: pointer;
567 | }
568 |
569 | .cms-file:hover {
570 | background: var(--muted);
571 | transform: translateY(-2px);
572 | box-shadow: var(--shadow-md);
573 | }
574 |
575 | .file-icon {
576 | font-size: 1.25rem;
577 | color: var(--muted-foreground);
578 | opacity: 0.8;
579 | width: 1.5rem;
580 | text-align: center;
581 | }
582 |
583 | .file-info {
584 | flex: 1;
585 | display: flex;
586 | flex-direction: column;
587 | gap: 0.25rem;
588 | }
589 |
590 | .file-name {
591 | font-size: 0.875rem;
592 | font-weight: 500;
593 | color: var(--foreground);
594 | }
595 |
596 | .file-meta {
597 | font-size: 0.75rem;
598 | color: var(--muted-foreground);
599 | }
600 |
601 | .cms-layout {
602 | display: flex;
603 | height: 500px;
604 | }
605 |
606 | .cms-sidebar {
607 | background: var(--background);
608 | width: 240px;
609 | padding: 1rem 0.75rem;
610 | border-right: 1px solid var(--border);
611 | display: flex;
612 | flex-direction: column;
613 | gap: 0.25rem;
614 | }
615 |
616 | .cms-main {
617 | flex: 1;
618 | display: flex;
619 | flex-direction: column;
620 | background: var(--background);
621 | }
622 |
623 | .cms-toolbar {
624 | padding: 1rem;
625 | border-bottom: 1px solid var(--border);
626 | display: flex;
627 | gap: 1rem;
628 | align-items: center;
629 | background: var(--background);
630 | }
631 |
632 | .cms-content {
633 | flex: 1;
634 | padding: 1.5rem;
635 | overflow-y: auto;
636 | background: var(--muted);
637 | }
638 |
639 | .cms-grid {
640 | display: grid;
641 | grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
642 | gap: 1rem;
643 | }
644 |
645 | .features {
646 | padding: 4rem 1.5rem;
647 | max-width: 1000px;
648 | margin: 0 auto;
649 | text-align: left;
650 | }
651 |
652 | .features h2 {
653 | font-size: 2rem;
654 | font-weight: 600;
655 | color: var(--foreground);
656 | margin-bottom: 0.75rem;
657 | letter-spacing: -0.015em;
658 | }
659 |
660 | .features p {
661 | font-size: 1rem;
662 | color: var(--muted-foreground);
663 | margin-bottom: 2.5rem;
664 | max-width: 600px;
665 | }
666 |
667 | .feature-grid {
668 | display: grid;
669 | grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
670 | gap: 2rem;
671 | }
672 |
673 | .feature-card {
674 | background: var(--card);
675 | border: 1px solid var(--border);
676 | border-radius: 0.75rem;
677 | padding: 1.75rem;
678 | transition: all 0.2s ease;
679 | display: flex;
680 | flex-direction: column;
681 | gap: 0.75rem;
682 | cursor: default;
683 | }
684 |
685 | .feature-card:hover {
686 | background: var(--secondary);
687 | border-color: var(--ring);
688 | }
689 |
690 | .feature-card i {
691 | font-size: 1rem;
692 | color: var(--primary);
693 | }
694 |
695 | .feature-card h3 {
696 | font-size: 1rem;
697 | font-weight: 600;
698 | color: black;
699 | margin: 0;
700 | letter-spacing: -0.015em;
701 | }
702 |
703 | .feature-card p {
704 | font-size: 0.75rem;
705 | color: var(--muted-foreground);
706 | margin: 0;
707 | line-height: 1.5;
708 | }
709 |
710 | .feature-card:hover i {
711 | color: var(--foreground);
712 | }
713 |
714 |
715 | /* Getting Started Section */
716 | .getting-started {
717 | padding: 6rem 2rem;
718 | background-color: var(--background);
719 | max-width: 1168px;
720 | margin: 0 auto;
721 | }
722 |
723 | .getting-started h2 {
724 | font-size: 2.25rem;
725 | font-weight: 600;
726 | margin-bottom: 3rem;
727 | letter-spacing: -0.025em;
728 | line-height: 1.2;
729 | background: linear-gradient(to right, var(--foreground) 0%, var(--foreground) 100%);
730 | -webkit-background-clip: text;
731 | -webkit-text-fill-color: transparent;
732 | background-clip: text;
733 | }
734 |
735 | .steps {
736 | display: grid;
737 | grid-template-columns: repeat(3, 1fr);
738 | gap: 0; /* or try gap: 1rem if you want space */
739 | }
740 |
741 | .step {
742 | padding: 1.75rem;
743 | background: var(--card);
744 | border: 1px solid var(--border);
745 | border-radius: 0; /* optional: can tweak later for rounding edges */
746 | text-align: left;
747 | display: flex;
748 | flex-direction: column;
749 | justify-content: space-between;
750 | }
751 |
752 | .step:first-child {
753 | border-top-left-radius: 0.75rem;
754 | border-bottom-left-radius: 0.75rem;
755 | }
756 |
757 | .step:last-child {
758 | border-top-right-radius: 0.75rem;
759 | border-bottom-right-radius: 0.75rem;
760 | }
761 |
762 | .step:hover {
763 | background: var(--secondary);
764 | border-color: var(--ring);
765 | }
766 |
767 | .step-number {
768 | display: inline-flex;
769 | align-items: center;
770 | justify-content: center;
771 | width: 1.75rem;
772 | height: 1.75rem;
773 | background-color: var(--muted);
774 | color: var(--muted-foreground);
775 | border-radius: 0.375rem;
776 | font-size: 0.875rem;
777 | font-weight: 500;
778 | margin-bottom: 1rem;
779 | transition: all 0.2s ease;
780 | }
781 |
782 | .step:hover .step-number {
783 | background-color: var(--primary);
784 | color: var(--primary-foreground);
785 | }
786 |
787 | .step h3 {
788 | font-size: 1.125rem;
789 | font-weight: 600;
790 | margin-bottom: 0.75rem;
791 | color: var(--foreground);
792 | letter-spacing: -0.015em;
793 | }
794 |
795 | .step pre {
796 | background: var(--background);
797 | border: 1px solid var(--border);
798 | border-radius: 0.5rem;
799 | padding: 1rem;
800 | overflow-x: auto;
801 | position: relative;
802 | margin-top: 0.75rem;
803 | }
804 |
805 | .step code {
806 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
807 | font-size: 0.875rem;
808 | color: var(--foreground);
809 | line-height: 1.5;
810 | }
811 |
812 | @media (max-width: 768px) {
813 | .getting-started {
814 | padding: 4rem 1rem;
815 | }
816 |
817 | .getting-started h2 {
818 | font-size: 1.875rem;
819 | margin-bottom: 2rem;
820 | }
821 |
822 | .step {
823 | padding: 1.5rem;
824 | }
825 | }
826 |
827 | /* Documentation Section */
828 | .documentation {
829 | padding: 4rem 2rem;
830 | background-color: var(--background);
831 | }
832 |
833 | .documentation h2 {
834 | text-align: center;
835 | font-size: 1.75rem;
836 | margin-bottom: 2rem;
837 | font-weight: 600;
838 | }
839 |
840 | .doc-grid {
841 | display: grid;
842 | grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
843 | gap: 2rem;
844 | max-width: 1200px;
845 | margin: 0 auto;
846 | }
847 |
848 | .doc-card {
849 | background-color: var(--card);
850 | padding: 1.5rem;
851 | border-radius: var(--radius);
852 | border: 1px solid var(--border);
853 | transition: all 0.2s;
854 | text-decoration: none;
855 | color: var(--foreground);
856 | box-shadow: var(--shadow-sm);
857 | }
858 |
859 | .doc-card:hover {
860 | transform: translateY(-2px);
861 | box-shadow: var(--shadow-md);
862 | }
863 |
864 | .doc-card i {
865 | font-size: 2rem;
866 | color: var(--primary);
867 | margin-bottom: 1rem;
868 | }
869 |
870 | .doc-card h3 {
871 | font-size: 1.1rem;
872 | font-weight: 600;
873 | margin: 1rem 0 0.5rem;
874 | }
875 |
876 | .doc-card p {
877 | font-size: 0.875rem;
878 | color: var(--muted-foreground);
879 | }
880 |
881 | /* Community Section */
882 | .community {
883 | padding: 4rem 2rem;
884 | background-color: var(--secondary);
885 | }
886 |
887 | .community h2 {
888 | text-align: center;
889 | font-size: 1.75rem;
890 | margin-bottom: 2rem;
891 | font-weight: 600;
892 | }
893 |
894 | .community-grid {
895 | display: grid;
896 | grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
897 | gap: 2rem;
898 | max-width: 1200px;
899 | margin: 0 auto;
900 | }
901 |
902 | .community-card {
903 | background-color: var(--card);
904 | padding: 1.5rem;
905 | border-radius: var(--radius);
906 | border: 1px solid var(--border);
907 | transition: all 0.2s;
908 | text-decoration: none;
909 | color: var(--foreground);
910 | box-shadow: var(--shadow-sm);
911 | }
912 |
913 | .community-card:hover {
914 | transform: translateY(-2px);
915 | box-shadow: var(--shadow-md);
916 | }
917 |
918 | .community-card i {
919 | font-size: 2rem;
920 | color: var(--primary);
921 | margin-bottom: 1rem;
922 | }
923 |
924 | .community-card h3 {
925 | font-size: 1.1rem;
926 | font-weight: 600;
927 | margin: 1rem 0 0.5rem;
928 | }
929 |
930 | .community-card p {
931 | font-size: 0.875rem;
932 | color: var(--muted-foreground);
933 | }
934 |
935 | /* Footer */
936 | .footer {
937 | background-color: var(--background);
938 | color: var(--foreground);
939 | padding: 1rem 2rem;
940 | border-top: 1px solid var(--border);
941 | }
942 |
943 | .footer-content {
944 | display: flex;
945 | justify-content: space-between;
946 | align-items: center;
947 | max-width: 1200px;
948 | margin: 0 auto;
949 | }
950 |
951 | .footer-left {
952 | display: flex;
953 | align-items: center;
954 | gap: 0.5rem;
955 | font-size: 0.75rem;
956 | color: var(--muted-foreground);
957 | }
958 |
959 | .footer-right {
960 | display: flex;
961 | align-items: center;
962 | gap: 1rem;
963 | }
964 |
965 | .social-link {
966 | color: var(--muted-foreground);
967 | text-decoration: none;
968 | transition: color 0.2s;
969 | display: flex;
970 | align-items: center;
971 | }
972 |
973 | .social-link:hover {
974 | color: var(--foreground);
975 | }
976 |
977 | .social-link i {
978 | font-size: 1rem;
979 | }
980 |
981 | .footer-link {
982 | color: var(--muted-foreground);
983 | text-decoration: none;
984 | transition: color 0.2s;
985 | }
986 |
987 | .footer-link:hover {
988 | color: var(--foreground);
989 | }
990 |
991 | /* Animations */
992 | .fade-in {
993 | opacity: 0;
994 | transform: translateY(20px);
995 | animation: fadeIn 0.6s ease forwards;
996 | }
997 |
998 | @keyframes fadeIn {
999 | to {
1000 | opacity: 1;
1001 | transform: translateY(0);
1002 | }
1003 | }
1004 |
1005 | /* Responsive Design */
1006 | @media (max-width: 1024px) {
1007 | .hero {
1008 | flex-direction: column;
1009 | text-align: center;
1010 | gap: 2rem;
1011 | padding: 4rem 1rem;
1012 | }
1013 |
1014 | .hero-content {
1015 | max-width: 800px;
1016 | }
1017 |
1018 | .hero h1 {
1019 | font-size: 2.5rem;
1020 | }
1021 |
1022 | .hero-visual {
1023 | max-width: 100%;
1024 | width: 100%;
1025 | }
1026 |
1027 | .cms-preview {
1028 | transform: none;
1029 | }
1030 |
1031 | .feature-grid {
1032 | grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
1033 | gap: 1.5rem;
1034 | }
1035 |
1036 | .steps {
1037 | grid-template-columns: 1fr;
1038 | gap: 1rem;
1039 | }
1040 |
1041 | .step {
1042 | border-radius: 0.75rem !important;
1043 | }
1044 | }
1045 |
1046 | @media (max-width: 768px) {
1047 | .nav-container {
1048 | padding: 0.5rem;
1049 | }
1050 |
1051 | .nav-links {
1052 | display: none;
1053 | }
1054 |
1055 | .hero {
1056 | padding: 5rem 1rem 3rem;
1057 | gap: 3rem;
1058 | }
1059 |
1060 | .hero h1 {
1061 | font-size: 2rem;
1062 | margin-bottom: 1rem;
1063 | }
1064 |
1065 | .hero .subtitle {
1066 | font-size: 0.95rem;
1067 | margin-bottom: 2rem;
1068 | padding: 0 1rem;
1069 | }
1070 |
1071 | .cta-buttons {
1072 | flex-direction: column;
1073 | align-items: stretch;
1074 | gap: 0.75rem;
1075 | width: 100%;
1076 | max-width: 320px;
1077 | margin: 0 auto;
1078 | padding: 0 1rem;
1079 | }
1080 |
1081 | .button {
1082 | width: 100%;
1083 | min-width: unset;
1084 | }
1085 |
1086 | .cms-preview {
1087 | transform: none;
1088 | margin: 0 1rem;
1089 | }
1090 |
1091 | .cms-layout {
1092 | height: 400px;
1093 | }
1094 |
1095 | .cms-sidebar {
1096 | width: 60px;
1097 | padding: 0.5rem;
1098 | }
1099 |
1100 | .nav-label {
1101 | display: none;
1102 | }
1103 |
1104 | .cms-nav-item {
1105 | padding: 0.5rem;
1106 | justify-content: center;
1107 | }
1108 |
1109 | .file-info {
1110 | display: none;
1111 | }
1112 |
1113 | .cms-file {
1114 | padding: 0.75rem;
1115 | justify-content: center;
1116 | }
1117 |
1118 | .cms-grid {
1119 | grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
1120 | gap: 0.75rem;
1121 | }
1122 |
1123 | .cms-toolbar {
1124 | flex-direction: column;
1125 | gap: 0.75rem;
1126 | padding: 0.75rem;
1127 | }
1128 |
1129 | .search-bar {
1130 | width: 100%;
1131 | }
1132 |
1133 | .toolbar-actions {
1134 | width: 100%;
1135 | justify-content: flex-end;
1136 | }
1137 |
1138 | .features {
1139 | padding: 3rem 1rem;
1140 | }
1141 |
1142 | .features h2 {
1143 | font-size: 1.75rem;
1144 | margin-bottom: 1rem;
1145 | }
1146 |
1147 | .features p {
1148 | font-size: 0.95rem;
1149 | margin-bottom: 2rem;
1150 | }
1151 |
1152 | .feature-card {
1153 | padding: 1.25rem;
1154 | }
1155 |
1156 | .feature-card h3 {
1157 | font-size: 0.95rem;
1158 | }
1159 |
1160 | .feature-card p {
1161 | font-size: 0.8rem;
1162 | }
1163 |
1164 | .getting-started {
1165 | padding: 3rem 1rem;
1166 | }
1167 |
1168 | .getting-started h2 {
1169 | font-size: 1.75rem;
1170 | margin-bottom: 2rem;
1171 | }
1172 |
1173 | .step {
1174 | padding: 1.25rem;
1175 | }
1176 |
1177 | .step h3 {
1178 | font-size: 1rem;
1179 | }
1180 |
1181 | .step pre {
1182 | padding: 0.75rem;
1183 | }
1184 |
1185 | .step code {
1186 | font-size: 0.8rem;
1187 | }
1188 |
1189 | .cta-section {
1190 | margin: 1rem;
1191 | padding: 2rem 1rem;
1192 | }
1193 |
1194 | .cta-section h2 {
1195 | font-size: 1.5rem;
1196 | }
1197 |
1198 | .cta-section p {
1199 | font-size: 0.875rem;
1200 | }
1201 |
1202 | .footer-content {
1203 | flex-direction: column;
1204 | gap: 1rem;
1205 | text-align: center;
1206 | }
1207 |
1208 | .footer-right {
1209 | justify-content: center;
1210 | }
1211 | }
1212 |
1213 | @media (max-width: 480px) {
1214 | .hero h1 {
1215 | font-size: 1.75rem;
1216 | }
1217 |
1218 | .hero .subtitle {
1219 | font-size: 0.9rem;
1220 | }
1221 |
1222 | .cms-layout {
1223 | height: 350px;
1224 | }
1225 |
1226 | .cms-sidebar {
1227 | width: 50px;
1228 | }
1229 |
1230 | .cms-grid {
1231 | grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
1232 | gap: 0.5rem;
1233 | }
1234 |
1235 | .feature-grid {
1236 | grid-template-columns: 1fr;
1237 | }
1238 |
1239 | .features h2 {
1240 | font-size: 1.5rem;
1241 | }
1242 |
1243 | .getting-started h2 {
1244 | font-size: 1.5rem;
1245 | }
1246 | }
1247 |
1248 | /* CTA Section (container card) */
1249 | .cta-section {
1250 | background-color: #fff;
1251 | border-radius: 0.75rem; /* slightly smaller: ~rounded-xl */
1252 | box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
1253 | border: 1px solid rgba(0, 0, 0, 0.05);
1254 | max-width: 880px;
1255 | margin: 2rem auto;
1256 | padding: 2.5rem 1.25rem;
1257 | text-align: center;
1258 | }
1259 |
1260 | /* Inner wrapper */
1261 | .cta-content {
1262 | max-width: 720px;
1263 | margin: 0 auto;
1264 | display: flex;
1265 | flex-direction: column;
1266 | gap: 1.25rem;
1267 | }
1268 |
1269 | /* Heading */
1270 | .cta-section h2 {
1271 | font-size: 2rem; /* smaller than before */
1272 | font-weight: 700;
1273 | line-height: 1.3;
1274 | color: #000;
1275 | }
1276 |
1277 | /* Subtext */
1278 | .cta-section p {
1279 | font-size: 0.95rem;
1280 | color: #6b7280;
1281 | max-width: 600px;
1282 | margin: 0 auto;
1283 | }
1284 |
1285 | /* Button group */
1286 | .cta-buttons {
1287 | display: flex;
1288 | justify-content: center;
1289 | gap: 0.75rem;
1290 | flex-wrap: wrap;
1291 | }
1292 |
1293 | /* Primary Button */
1294 | .button-primary {
1295 | background-color: #000;
1296 | color: #fff;
1297 | padding: 0.625rem 1.25rem;
1298 | font-size: 0.95rem;
1299 | font-weight: 500;
1300 | border: none;
1301 | border-radius: 0.625rem;
1302 | cursor: pointer;
1303 | transition: background-color 0.2s ease;
1304 | }
1305 |
1306 | .button-primary:hover {
1307 | background-color: #111;
1308 | }
1309 |
1310 | /* Secondary Button */
1311 | .button-secondary {
1312 | background-color: #fff;
1313 | color: #000;
1314 | padding: 0.625rem 1.25rem;
1315 | font-size: 0.95rem;
1316 | font-weight: 500;
1317 | border-radius: 0.625rem;
1318 | border: 1px solid rgba(0, 0, 0, 0.1);
1319 | box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
1320 | cursor: pointer;
1321 | transition: background-color 0.2s ease, box-shadow 0.2s ease;
1322 | }
1323 |
1324 | .button-secondary:hover {
1325 | background-color: rgba(0, 0, 0, 0.02);
1326 | }
1327 |
1328 | /* Responsive */
1329 | @media (max-width: 768px) {
1330 | .cta-section {
1331 | padding: 2rem 1rem;
1332 | }
1333 |
1334 | .cta-section h2 {
1335 | font-size: 1.5rem;
1336 | }
1337 |
1338 | .cta-section p {
1339 | font-size: 0.875rem;
1340 | }
1341 |
1342 | .cta-buttons {
1343 | flex-direction: column;
1344 | }
1345 |
1346 | .button-primary,
1347 | .button-secondary {
1348 | width: 100%;
1349 | }
1350 | }
1351 |
1352 | /* Mobile Menu Styles */
1353 | .mobile-menu-button {
1354 | display: none;
1355 | background: none;
1356 | border: none;
1357 | color: var(--foreground);
1358 | font-size: 1.5rem;
1359 | cursor: pointer;
1360 | padding: 0.5rem;
1361 | z-index: 1001;
1362 | }
1363 |
1364 | .mobile-menu {
1365 | display: none;
1366 | position: fixed;
1367 | top: 0;
1368 | right: 0;
1369 | bottom: 0;
1370 | width: 100%;
1371 | background-color: var(--background);
1372 | z-index: 1000;
1373 | padding: 5rem 2rem 2rem;
1374 | transform: translateX(100%);
1375 | transition: transform 0.3s ease-in-out;
1376 | }
1377 |
1378 | .mobile-menu.active {
1379 | transform: translateX(0);
1380 | }
1381 |
1382 | .mobile-menu-close {
1383 | position: absolute;
1384 | top: 1rem;
1385 | right: 1rem;
1386 | background: none;
1387 | border: none;
1388 | color: var(--foreground);
1389 | font-size: 1.5rem;
1390 | cursor: pointer;
1391 | padding: 0.5rem;
1392 | }
1393 |
1394 | .mobile-nav-links {
1395 | display: flex;
1396 | flex-direction: column;
1397 | gap: 1rem;
1398 | }
1399 |
1400 | .mobile-nav-link {
1401 | display: flex;
1402 | align-items: center;
1403 | gap: 0.75rem;
1404 | padding: 0.75rem;
1405 | color: var(--foreground);
1406 | text-decoration: none;
1407 | font-weight: 500;
1408 | border-radius: var(--radius);
1409 | transition: all 0.2s;
1410 | }
1411 |
1412 | .mobile-nav-link:hover {
1413 | background-color: var(--muted);
1414 | }
1415 |
1416 | .mobile-nav-link i {
1417 | font-size: 1.25rem;
1418 | width: 1.5rem;
1419 | text-align: center;
1420 | }
1421 |
1422 | .mobile-nav-link.github-link {
1423 | background-color: var(--muted);
1424 | }
1425 |
1426 | .mobile-nav-link.demo-link {
1427 | background-color: var(--primary);
1428 | color: var(--primary-foreground);
1429 | }
1430 |
1431 | .mobile-nav-link.demo-link:hover {
1432 | opacity: 0.9;
1433 | }
1434 |
1435 | @media (max-width: 768px) {
1436 | .mobile-menu-button {
1437 | display: block;
1438 | }
1439 |
1440 | .nav-links {
1441 | display: none;
1442 | }
1443 |
1444 | .mobile-menu {
1445 | display: block;
1446 | }
1447 |
1448 | .nav-container {
1449 | padding: 0.75rem 1rem;
1450 | }
1451 |
1452 | .logo img {
1453 | height: 1.5rem;
1454 | }
1455 | }
1456 |
--------------------------------------------------------------------------------
/package-lock.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "create-supawald",
3 | "version": "1.0.0",
4 | "lockfileVersion": 3,
5 | "requires": true,
6 | "packages": {
7 | "": {
8 | "name": "create-supawald",
9 | "version": "1.0.0",
10 | "license": "MIT",
11 | "dependencies": {
12 | "fs-extra": "^11.1.1"
13 | },
14 | "bin": {
15 | "create-supawald": "create.js"
16 | }
17 | },
18 | "node_modules/fs-extra": {
19 | "version": "11.3.0",
20 | "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz",
21 | "integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==",
22 | "license": "MIT",
23 | "dependencies": {
24 | "graceful-fs": "^4.2.0",
25 | "jsonfile": "^6.0.1",
26 | "universalify": "^2.0.0"
27 | },
28 | "engines": {
29 | "node": ">=14.14"
30 | }
31 | },
32 | "node_modules/graceful-fs": {
33 | "version": "4.2.11",
34 | "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
35 | "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="
36 | },
37 | "node_modules/jsonfile": {
38 | "version": "6.1.0",
39 | "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz",
40 | "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==",
41 | "license": "MIT",
42 | "dependencies": {
43 | "universalify": "^2.0.0"
44 | },
45 | "optionalDependencies": {
46 | "graceful-fs": "^4.1.6"
47 | }
48 | },
49 | "node_modules/universalify": {
50 | "version": "2.0.1",
51 | "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
52 | "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
53 | "license": "MIT",
54 | "engines": {
55 | "node": ">= 10.0.0"
56 | }
57 | }
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "create-supawald",
3 | "version": "1.0.2",
4 | "description": "Create a Supawald project – a headless CMS for Supabase Storage",
5 | "repository": {
6 | "type": "git",
7 | "url": "https://github.com/StructuredLabs/supawald.git"
8 | },
9 | "bugs": {
10 | "url": "https://github.com/StructuredLabs/supawald/issues"
11 | },
12 | "homepage": "https://github.com/StructuredLabs/supawald#readme",
13 | "bin": {
14 | "create-supawald": "./create.js"
15 | },
16 | "files": [
17 | "create.js",
18 | "template/**"
19 | ],
20 | "dependencies": {
21 | "fs-extra": "^11.1.1"
22 | },
23 | "license": "MIT",
24 | "keywords": ["supabase", "cms", "nextjs", "tailwind", "headless", "template"]
25 | }
26 |
--------------------------------------------------------------------------------
/template/.env.example:
--------------------------------------------------------------------------------
1 | # Supabase Configuration
2 | # Replace these values with your own from your Supabase project settings
3 |
4 | # Your Supabase project URL (Settings -> API)
5 | NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
6 |
7 | # Your Supabase anon/public key (Settings -> API -> Project API keys -> anon/public)
8 | NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
9 |
10 | # The name of your Supabase Storage bucket
11 | NEXT_PUBLIC_BUCKET_NAME=your-bucket-name
12 |
13 | # Basic Authentication
14 | # Set these values to protect your blog with password authentication
15 | AUTH_USERNAME=admin
16 | AUTH_PASSWORD=your-secure-password
17 |
18 | # Publish API (for static site generation)
19 | PUBLISH_URL=https://your-site.com/api/publish
20 | PUBLISH_TOKEN=your-secure-token
--------------------------------------------------------------------------------
/template/app/api/publish/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server'
2 |
3 | export async function POST() {
4 | try {
5 | if (!process.env.PUBLISH_URL) {
6 | throw new Error('PUBLISH_URL is not configured')
7 | }
8 |
9 | if (!process.env.PUBLISH_TOKEN) {
10 | throw new Error('PUBLISH_TOKEN is not configured')
11 | }
12 |
13 | console.log('Making publish request to:', process.env.PUBLISH_URL)
14 |
15 | const response = await fetch(process.env.PUBLISH_URL, {
16 | method: 'POST',
17 | headers: {
18 | 'Content-Type': 'application/json',
19 | 'Authorization': `Bearer ${process.env.PUBLISH_TOKEN}`,
20 | },
21 | })
22 |
23 | const responseData = await response.text()
24 |
25 | if (!response.ok) {
26 | throw new Error(`Failed to publish: ${response.status} ${typeof responseData === 'string' ? responseData : JSON.stringify(responseData)}`)
27 | }
28 |
29 | return NextResponse.json({
30 | success: true,
31 | message: 'Successfully published changes'
32 | })
33 | } catch (error) {
34 | console.error('Publish error:', error)
35 | return NextResponse.json(
36 | {
37 | success: false,
38 | message: error instanceof Error ? error.message : 'Failed to publish'
39 | },
40 | { status: 500 }
41 | )
42 | }
43 | }
--------------------------------------------------------------------------------
/template/app/components/CreateFolderModal.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useState, useRef, useEffect } from 'react'
4 | import { FolderIcon, XMarkIcon } from '@heroicons/react/24/outline'
5 |
6 | interface CreateFolderModalProps {
7 | isOpen: boolean
8 | onClose: () => void
9 | onCreateFolder: (name: string) => Promise
10 | currentPath: string
11 | }
12 |
13 | export default function CreateFolderModal({ isOpen, onClose, onCreateFolder, currentPath }: CreateFolderModalProps) {
14 | const [folderName, setFolderName] = useState('')
15 | const [error, setError] = useState(null)
16 | const modalRef = useRef(null)
17 |
18 | const handleClickOutside = (event: React.MouseEvent) => {
19 | if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
20 | onClose()
21 | }
22 | }
23 |
24 | useEffect(() => {
25 | const handleDocumentClick = (event: MouseEvent) => {
26 | if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
27 | onClose()
28 | }
29 | }
30 |
31 | if (isOpen) {
32 | document.addEventListener('mousedown', handleDocumentClick)
33 | }
34 |
35 | return () => {
36 | document.removeEventListener('mousedown', handleDocumentClick)
37 | }
38 | }, [isOpen, onClose])
39 |
40 | const handleSubmit = async (e: React.FormEvent) => {
41 | e.preventDefault()
42 | setError(null)
43 |
44 | if (!folderName.trim()) {
45 | setError('Folder name cannot be empty')
46 | return
47 | }
48 |
49 | try {
50 | await onCreateFolder(folderName.trim())
51 | onClose()
52 | } catch (err) {
53 | setError(err instanceof Error ? err.message : 'Failed to create folder')
54 | }
55 | }
56 |
57 | if (!isOpen) return null
58 |
59 | return (
60 |
61 | {/* Backdrop with blur */}
62 |
63 |
64 | {/* Modal container */}
65 |
66 |
70 | {/* Header */}
71 |
72 |
73 |
74 |
75 | Create New Folder
76 |
77 |
78 |
83 | Close
84 |
85 |
86 |
87 |
88 | {/* Content */}
89 |
131 |
132 |
133 |
134 | )
135 | }
--------------------------------------------------------------------------------
/template/app/components/FileList.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react'
2 | import { FolderIcon, DocumentIcon, ArrowPathIcon, ViewColumnsIcon } from '@heroicons/react/24/outline'
3 | import { useRouter } from 'next/router'
4 | import { formatDistanceToNow } from 'date-fns'
5 |
6 | interface FileListProps {
7 | files: Array<{
8 | name: string
9 | id: string
10 | updated_at: string
11 | metadata: {
12 | isDir?: boolean
13 | size?: number
14 | }
15 | }>
16 | currentPath: string
17 | onFileClick: (file: any) => void
18 | onRefresh?: () => void
19 | }
20 |
21 | export default function FileList({ files, currentPath, onFileClick, onRefresh }: FileListProps) {
22 | const [viewMode, setViewMode] = useState<'list' | 'grid'>('list')
23 | const router = useRouter()
24 |
25 | const handleFileClick = (file: any) => {
26 | if (file.metadata?.isDir) {
27 | const newPath = currentPath ? `${currentPath}/${file.name}` : file.name
28 | router.push(`/files/${newPath}`)
29 | } else {
30 | onFileClick(file)
31 | }
32 | }
33 |
34 | const ListView = () => (
35 |
36 |
37 | {files.map((file) => (
38 | handleFileClick(file)}
42 | >
43 |
44 | {file.metadata?.isDir ? (
45 |
46 | ) : (
47 |
48 | )}
49 |
50 |
51 | {file.name}
52 |
53 |
54 | {formatDistanceToNow(new Date(file.updated_at), { addSuffix: true })}
55 |
56 |
57 |
58 |
59 | ))}
60 |
61 |
62 | )
63 |
64 | const GridView = () => (
65 |
66 | {files.map((file) => (
67 |
handleFileClick(file)}
70 | className="group relative flex flex-col overflow-hidden rounded-lg border border-gray-200 bg-white hover:border-blue-500 dark:border-gray-700 dark:bg-gray-800 dark:hover:border-blue-500 cursor-pointer p-4"
71 | >
72 |
73 | {file.metadata?.isDir ? (
74 |
75 | ) : (
76 |
77 | )}
78 |
79 |
80 |
81 |
82 | {file.name}
83 |
84 |
85 | {formatDistanceToNow(new Date(file.updated_at), { addSuffix: true })}
86 |
87 |
88 |
89 |
90 | ))}
91 |
92 | )
93 |
94 | return (
95 |
96 |
97 |
98 |
setViewMode(viewMode === 'list' ? 'grid' : 'list')}
100 | className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
101 | >
102 |
103 |
104 | {onRefresh && (
105 |
109 |
110 |
111 | )}
112 |
113 |
114 |
115 | Create folder
116 |
117 |
118 | Upload file
119 |
120 |
121 |
122 | {viewMode === 'list' ?
:
}
123 |
124 | )
125 | }
--------------------------------------------------------------------------------
/template/app/components/FileViewModal.tsx:
--------------------------------------------------------------------------------
1 | import { XMarkIcon, DocumentIcon } from '@heroicons/react/24/outline'
2 | import { useState, useEffect, useRef } from 'react'
3 | import { supabase, BUCKET_NAME } from '@/lib/supabase'
4 | import ReactMarkdown from 'react-markdown'
5 | import remarkGfm from 'remark-gfm'
6 | import remarkFrontmatter from 'remark-frontmatter'
7 | import remarkParse from 'remark-parse'
8 | import { Frontmatter } from '../types/markdown'
9 | import { processFrontmatter, processImagePaths, cleanMarkdown } from '../utils/markdown'
10 | import { markdownComponents } from './markdown/MarkdownComponents'
11 | import '../styles/components/FileViewModal.css'
12 |
13 | interface FileViewModalProps {
14 | isOpen: boolean
15 | onClose: () => void
16 | fileUrl: string
17 | filename: string
18 | }
19 |
20 | export default function FileViewModal({ isOpen, onClose, fileUrl, filename }: FileViewModalProps) {
21 | const [content, setContent] = useState('')
22 | const [frontmatter, setFrontmatter] = useState(null)
23 | const [isLoading, setIsLoading] = useState(true)
24 | const [error, setError] = useState(null)
25 | const modalRef = useRef(null)
26 |
27 | useEffect(() => {
28 | if (isOpen && fileUrl) {
29 | setIsLoading(true)
30 | setError(null)
31 |
32 | const loadFile = async () => {
33 | try {
34 | // First try to get the public URL
35 | const { data: { publicUrl } } = supabase.storage
36 | .from(BUCKET_NAME)
37 | .getPublicUrl(fileUrl)
38 |
39 | if (publicUrl) {
40 | // Try fetching via public URL first
41 | const response = await fetch(publicUrl)
42 | if (response.ok) {
43 | const text = await response.text()
44 | const { frontmatter, content } = processFrontmatter(text)
45 | setFrontmatter(frontmatter)
46 | setContent(content)
47 | setIsLoading(false)
48 | return
49 | }
50 | }
51 |
52 | // If public URL fails, try direct download
53 | const { data, error: downloadError } = await supabase.storage
54 | .from(BUCKET_NAME)
55 | .download(fileUrl)
56 |
57 | if (downloadError) throw downloadError
58 | if (!data) throw new Error('No data received')
59 |
60 | const text = await data.text()
61 | const { frontmatter, content } = processFrontmatter(text)
62 | setFrontmatter(frontmatter)
63 | setContent(content)
64 | } catch (err) {
65 | console.error('Error loading file:', err)
66 | setError(err instanceof Error ? err.message : 'Failed to load file')
67 | } finally {
68 | setIsLoading(false)
69 | }
70 | }
71 |
72 | loadFile()
73 | }
74 | }, [isOpen, fileUrl])
75 |
76 | const handleClickOutside = (event: React.MouseEvent) => {
77 | if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
78 | onClose()
79 | }
80 | }
81 |
82 | if (!isOpen) return null
83 |
84 | return (
85 |
86 | {/* Backdrop with blur */}
87 |
88 |
89 | {/* Modal container */}
90 |
91 |
95 | {/* Header */}
96 |
97 |
98 |
99 |
100 | {filename}
101 |
102 |
103 |
108 | Close
109 |
110 |
111 |
112 |
113 | {/* Content */}
114 |
115 | {isLoading ? (
116 |
117 |
118 |
119 |
Loading file...
120 |
121 |
122 | ) : error ? (
123 |
124 |
125 |
126 |
Failed to load file
127 |
{error}
128 |
129 |
130 | ) : (
131 |
132 | {frontmatter && (
133 |
134 |
{frontmatter.title}
135 |
136 | {frontmatter.date && {frontmatter.date} }
137 | {frontmatter.author && By {frontmatter.author} }
138 | {frontmatter.readingTime && {frontmatter.readingTime} }
139 |
140 | {frontmatter.description && (
141 |
{frontmatter.description}
142 | )}
143 | {frontmatter.tags && frontmatter.tags.length > 0 && (
144 |
145 | {frontmatter.tags.map((tag: string) => (
146 |
147 | {tag}
148 |
149 | ))}
150 |
151 | )}
152 |
153 | )}
154 |
158 | {cleanMarkdown(processImagePaths(content, fileUrl))}
159 |
160 |
161 | )}
162 |
163 |
164 |
165 |
166 | )
167 | }
--------------------------------------------------------------------------------
/template/app/components/ImageModal.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useState, useRef, useEffect } from 'react'
4 | import { XMarkIcon, PhotoIcon } from '@heroicons/react/24/outline'
5 | import { supabase, BUCKET_NAME } from '@/lib/supabase'
6 |
7 | interface ImageModalProps {
8 | isOpen: boolean
9 | onClose: () => void
10 | imageUrl: string
11 | filename: string
12 | }
13 |
14 | export default function ImageModal({ isOpen, onClose, imageUrl, filename }: ImageModalProps) {
15 | const [imageError, setImageError] = useState(false)
16 | const [isLoading, setIsLoading] = useState(true)
17 | const [imageData, setImageData] = useState(null)
18 | const modalRef = useRef(null)
19 |
20 | const handleClickOutside = (event: React.MouseEvent) => {
21 | if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
22 | onClose()
23 | }
24 | }
25 |
26 | useEffect(() => {
27 | const handleDocumentClick = (event: MouseEvent) => {
28 | if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
29 | onClose()
30 | }
31 | }
32 |
33 | if (isOpen) {
34 | document.addEventListener('mousedown', handleDocumentClick)
35 | }
36 |
37 | return () => {
38 | document.removeEventListener('mousedown', handleDocumentClick)
39 | }
40 | }, [isOpen, onClose])
41 |
42 | useEffect(() => {
43 | if (isOpen && imageUrl) {
44 | setIsLoading(true)
45 | setImageError(false)
46 |
47 | const loadImage = async () => {
48 | try {
49 | // First try to get the public URL
50 | const { data: { publicUrl } } = supabase.storage
51 | .from(BUCKET_NAME)
52 | .getPublicUrl(imageUrl)
53 |
54 | if (publicUrl) {
55 | // Try loading via public URL first
56 | const img = new Image()
57 | img.onload = () => {
58 | setImageData(publicUrl)
59 | setIsLoading(false)
60 | }
61 | img.onerror = () => {
62 | // If public URL fails, try direct download
63 | loadImageDirectly()
64 | }
65 | img.src = publicUrl
66 | } else {
67 | // If no public URL, try direct download
68 | loadImageDirectly()
69 | }
70 | } catch (err) {
71 | console.error('Error loading image:', err)
72 | setImageError(true)
73 | setIsLoading(false)
74 | }
75 | }
76 |
77 | const loadImageDirectly = async () => {
78 | try {
79 | const { data, error: downloadError } = await supabase.storage
80 | .from(BUCKET_NAME)
81 | .download(imageUrl)
82 |
83 | if (downloadError) throw downloadError
84 | if (!data) throw new Error('No data received')
85 |
86 | const url = URL.createObjectURL(data)
87 | setImageData(url)
88 | } catch (err) {
89 | console.error('Error downloading image:', err)
90 | setImageError(true)
91 | } finally {
92 | setIsLoading(false)
93 | }
94 | }
95 |
96 | loadImage()
97 | }
98 |
99 | // Cleanup function to revoke the object URL when component unmounts or image changes
100 | return () => {
101 | if (imageData) {
102 | URL.revokeObjectURL(imageData)
103 | }
104 | }
105 | }, [isOpen, imageUrl])
106 |
107 | if (!isOpen) return null
108 |
109 | return (
110 |
111 | {/* Backdrop with blur */}
112 |
113 |
114 | {/* Modal container */}
115 |
116 |
120 | {/* Header */}
121 |
122 |
123 | {filename}
124 |
125 |
130 | Close
131 |
132 |
133 |
134 |
135 | {/* Content */}
136 |
137 | {isLoading ? (
138 |
139 |
140 |
141 |
Loading image...
142 |
143 |
144 | ) : imageError ? (
145 |
146 |
147 |
148 |
Failed to load image
149 |
Please try again later
150 |
151 |
152 | ) : (
153 |
154 |
setIsLoading(false)}
159 | onError={(e) => {
160 | console.error('Image failed to load:', e)
161 | setImageError(true)
162 | setIsLoading(false)
163 | }}
164 | />
165 |
166 | )}
167 |
168 |
169 |
170 |
171 | )
172 | }
--------------------------------------------------------------------------------
/template/app/components/MarkdownEditor.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useState, useEffect } from 'react'
4 | import { CheckIcon } from '@heroicons/react/24/outline'
5 |
6 | interface MarkdownEditorProps {
7 | content: string
8 | onSave: (content: string) => Promise
9 | isSaving: boolean
10 | }
11 |
12 | export default function MarkdownEditor({ content, onSave, isSaving }: MarkdownEditorProps) {
13 | const [editorContent, setEditorContent] = useState(content)
14 | const [timeUntilNextSave, setTimeUntilNextSave] = useState(0)
15 | const [isMinDuration, setIsMinDuration] = useState(false)
16 |
17 | useEffect(() => {
18 | let interval: NodeJS.Timeout
19 | if (timeUntilNextSave > 0) {
20 | interval = setInterval(() => {
21 | setTimeUntilNextSave(prev => {
22 | if (prev <= 1) {
23 | clearInterval(interval)
24 | return 0
25 | }
26 | return prev - 1
27 | })
28 | }, 1000)
29 | }
30 | return () => clearInterval(interval)
31 | }, [timeUntilNextSave])
32 |
33 | const handleSave = async () => {
34 | if (isSaving || timeUntilNextSave > 0) return
35 |
36 | setIsMinDuration(true)
37 | try {
38 | await onSave(editorContent)
39 | setTimeUntilNextSave(30) // 30 second cooldown
40 | } finally {
41 | // Ensure minimum duration of 2 seconds
42 | setTimeout(() => setIsMinDuration(false), 2000)
43 | }
44 | }
45 |
46 | const getSaveButtonText = () => {
47 | if (timeUntilNextSave > 0) {
48 | return `Wait ${timeUntilNextSave}s`
49 | }
50 | return 'Save'
51 | }
52 |
53 | return (
54 |
55 |
56 |
57 |
0 || isMinDuration}
60 | className={`btn ${isSaving || timeUntilNextSave > 0 || isMinDuration ? 'btn-secondary' : 'btn-primary'}`}
61 | >
62 | {(isSaving || isMinDuration) ? (
63 | <>
64 |
65 |
66 |
67 |
68 | Saving...
69 | >
70 | ) : (
71 | <>
72 |
73 | {getSaveButtonText()}
74 | >
75 | )}
76 |
77 |
78 |
79 |
80 |
88 |
89 | )
90 | }
--------------------------------------------------------------------------------
/template/app/components/PublishButton.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useState, useEffect } from 'react'
4 | import { CloudArrowUpIcon } from '@heroicons/react/24/outline'
5 |
6 | interface PublishButtonProps {
7 | isSidebarCollapsed?: boolean;
8 | variant?: 'sidebar' | 'inline';
9 | }
10 |
11 | const baseButtonStyles = 'btn btn-primary'
12 | const variantStyles = {
13 | sidebar: 'w-full',
14 | inline: 'w-auto'
15 | }
16 |
17 | export default function PublishButton({
18 | isSidebarCollapsed = false,
19 | variant = 'sidebar'
20 | }: PublishButtonProps) {
21 | const [isLoading, setIsLoading] = useState(false)
22 | const [success, setSuccess] = useState(false)
23 | const [error, setError] = useState(null)
24 | const [cooldownProgress, setCooldownProgress] = useState(0)
25 | const [isMinDuration, setIsMinDuration] = useState(false)
26 |
27 | useEffect(() => {
28 | let cooldownInterval: NodeJS.Timeout
29 | if (cooldownProgress > 0) {
30 | cooldownInterval = setInterval(() => {
31 | setCooldownProgress(prev => {
32 | if (prev <= 1) {
33 | clearInterval(cooldownInterval)
34 | return 0
35 | }
36 | return prev - 1
37 | })
38 | }, 1000)
39 | }
40 | return () => clearInterval(cooldownInterval)
41 | }, [cooldownProgress])
42 |
43 | const handlePublish = async () => {
44 | if (isLoading || cooldownProgress > 0) return
45 |
46 | setIsLoading(true)
47 | setError(null)
48 | setSuccess(false)
49 | setIsMinDuration(true)
50 |
51 | try {
52 | const response = await fetch('/api/publish', {
53 | method: 'POST',
54 | headers: {
55 | 'Content-Type': 'application/json',
56 | },
57 | })
58 |
59 | if (!response.ok) {
60 | throw new Error('Failed to publish')
61 | }
62 |
63 | setSuccess(true)
64 | setCooldownProgress(30) // 30 second cooldown
65 | } catch (err) {
66 | setError(err instanceof Error ? err.message : 'Failed to publish')
67 | } finally {
68 | setIsLoading(false)
69 | // Ensure minimum duration of 2 seconds
70 | setTimeout(() => setIsMinDuration(false), 2000)
71 | }
72 | }
73 |
74 | const getSaveButtonText = () => {
75 | if (cooldownProgress > 0) {
76 | return `Wait ${cooldownProgress}s`
77 | }
78 | return 'Publish'
79 | }
80 |
81 | return (
82 |
83 |
0}
86 | className={`${baseButtonStyles} ${variantStyles[variant]} flex items-center justify-center gap-2 px-2 py-1.5 text-sm`}
87 | title="Publish changes"
88 | >
89 | 0) ? 'animate-spin' : ''}`}
91 | aria-hidden="true"
92 | />
93 | {!isSidebarCollapsed && (
94 |
95 | {isLoading ? 'Publishing...' : getSaveButtonText()}
96 |
97 | )}
98 |
99 |
100 | {/* Only show success message after both cooldown and API call are complete */}
101 | {success && (
102 |
109 | Changes published
110 |
111 | )}
112 |
113 | {/* Show error message immediately if there's an error */}
114 | {error && (
115 |
122 | {error}
123 |
124 | )}
125 |
126 | )
127 | }
--------------------------------------------------------------------------------
/template/app/components/markdown/MarkdownComponents.tsx:
--------------------------------------------------------------------------------
1 | import { Components } from 'react-markdown'
2 |
3 | export const markdownComponents: Components = {
4 | h1: ({node, ...props}) => ,
5 | h2: ({node, ...props}) => ,
6 | h3: ({node, ...props}) => ,
7 | p: ({node, ...props}) =>
,
8 | ul: ({node, ...props}) => ,
9 | ol: ({node, ...props}) => ,
10 | li: ({node, ...props}) => ,
11 | blockquote: ({node, ...props}) => ,
12 | code: ({node, inline, className, ...props}: {node?: any; inline?: boolean; className?: string}) =>
13 | inline ?
14 |
:
15 |
,
16 | pre: ({node, ...props}) => ,
17 | img: ({node, ...props}) => ,
18 | a: ({node, ...props}) => ,
19 | table: ({node, ...props}) => ,
20 | th: ({node, ...props}) => ,
21 | td: ({node, ...props}) => ,
22 | hr: ({node, ...props}) => ,
23 | strong: ({node, ...props}) => ,
24 | em: ({node, ...props}) =>
25 | }
--------------------------------------------------------------------------------
/template/app/edit/[...filename]/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useEffect, useState } from 'react'
4 | import { useParams } from 'next/navigation'
5 | import { supabase, BUCKET_NAME } from '@/lib/supabase'
6 | import MarkdownEditor from '@/app/components/MarkdownEditor'
7 |
8 | export default function EditFile() {
9 | const params = useParams()
10 | const filename = Array.isArray(params.filename) ? params.filename.join('/') : params.filename
11 | const [content, setContent] = useState('')
12 | const [isLoading, setIsLoading] = useState(true)
13 | const [error, setError] = useState(null)
14 | const [isSaving, setIsSaving] = useState(false)
15 |
16 | useEffect(() => {
17 | fetchFile()
18 | }, [filename])
19 |
20 | const fetchFile = async () => {
21 | try {
22 | setIsLoading(true)
23 | setError(null)
24 |
25 | const { data, error: downloadError } = await supabase.storage
26 | .from(BUCKET_NAME)
27 | .download(filename)
28 |
29 | if (downloadError) throw downloadError
30 | if (!data) throw new Error('No data received')
31 |
32 | const text = await data.text()
33 | setContent(text)
34 | } catch (err) {
35 | console.error('Error fetching file:', err)
36 | setError(err instanceof Error ? err.message : 'Failed to load file')
37 | } finally {
38 | setIsLoading(false)
39 | }
40 | }
41 |
42 | const handleSave = async (newContent: string) => {
43 | try {
44 | setIsSaving(true)
45 | setError(null)
46 |
47 | // Save to Supabase
48 | const { error: uploadError } = await supabase.storage
49 | .from(BUCKET_NAME)
50 | .upload(filename, newContent, {
51 | upsert: true,
52 | })
53 |
54 | if (uploadError) {
55 | console.error('Upload error:', uploadError)
56 | throw uploadError
57 | }
58 |
59 | setContent(newContent)
60 |
61 | // Show success message
62 | const successMessage = document.createElement('div')
63 | successMessage.className = 'fixed bottom-4 right-4 bg-green-500 text-white px-4 py-2 rounded-md shadow-lg z-50'
64 | successMessage.textContent = 'File saved successfully'
65 | document.body.appendChild(successMessage)
66 | setTimeout(() => successMessage.remove(), 3000)
67 |
68 | // Optionally trigger revalidation after successful save
69 | try {
70 | const revalidateResponse = await fetch('/api/revalidate', {
71 | method: 'POST',
72 | headers: {
73 | 'Content-Type': 'application/json',
74 | },
75 | body: JSON.stringify({ path: filename }),
76 | })
77 |
78 | if (revalidateResponse.ok) {
79 | console.log('Revalidation successful')
80 | }
81 | } catch (revalidateError) {
82 | // Don't fail the save if revalidation fails
83 | console.warn('Revalidation failed:', revalidateError)
84 | }
85 |
86 | // Save file changes
87 | const saveResponse = await fetch('/api/save', {
88 | method: 'POST',
89 | headers: {
90 | 'Content-Type': 'application/json'
91 | },
92 | body: JSON.stringify({
93 | path: Array.isArray(params.filename) ? params.filename.join('/') : params.filename,
94 | content: content
95 | })
96 | })
97 |
98 | if (!saveResponse.ok) {
99 | throw new Error('Failed to save file')
100 | }
101 |
102 | // Publish to external service
103 | const publishResponse = await fetch('/api/publish', {
104 | method: 'POST',
105 | headers: {
106 | 'Content-Type': 'application/json'
107 | }
108 | })
109 |
110 | if (!publishResponse.ok) {
111 | console.warn('Publish failed:', await publishResponse.text())
112 | }
113 | } catch (err) {
114 | console.error('Save failed:', err)
115 | setError(err instanceof Error ? err.message : 'Failed to save file')
116 |
117 | // Show error message
118 | const errorMessage = document.createElement('div')
119 | errorMessage.className = 'fixed bottom-4 right-4 bg-red-500 text-white px-4 py-2 rounded-md shadow-lg z-50'
120 | errorMessage.textContent = err instanceof Error ? err.message : 'Failed to save file'
121 | document.body.appendChild(errorMessage)
122 | setTimeout(() => errorMessage.remove(), 3000)
123 | } finally {
124 | setIsSaving(false)
125 | }
126 | }
127 |
128 | if (isLoading) {
129 | return (
130 |
133 | )
134 | }
135 |
136 | if (error) {
137 | return (
138 |
141 | )
142 | }
143 |
144 | return (
145 |
146 |
151 |
152 | )
153 | }
--------------------------------------------------------------------------------
/template/app/edit/layout.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useRouter, useParams } from 'next/navigation'
4 | import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/24/outline'
5 |
6 | export default function EditLayout({
7 | children,
8 | }: {
9 | children: React.ReactNode
10 | }) {
11 | const router = useRouter()
12 | const params = useParams()
13 | const filename = Array.isArray(params.filename) ? params.filename.join('/') : params.filename
14 | const pathSegments = filename.split('/')
15 |
16 | const navigateToPath = (index: number) => {
17 | const path = pathSegments.slice(0, index + 1).join('/')
18 | // If we're not at the last segment (which would be the file), navigate to the file list
19 | if (index < pathSegments.length - 1) {
20 | router.push(path ? `/?path=${encodeURIComponent(path)}` : '/')
21 | }
22 | }
23 |
24 | const handleBack = () => {
25 | // If we're in a subdirectory, go up one level
26 | if (pathSegments.length > 1) {
27 | const parentPath = pathSegments.slice(0, -1).join('/')
28 | router.push(parentPath ? `/?path=${encodeURIComponent(parentPath)}` : '/')
29 | } else {
30 | // If we're at the root, go back to the previous page
31 | router.back()
32 | }
33 | }
34 |
35 | return (
36 |
37 | {/* Top Navigation */}
38 |
39 |
40 |
44 |
45 |
46 |
47 |
router.push('/')}
49 | className="hover:text-gray-900"
50 | >
51 | Files
52 |
53 | {pathSegments.map((segment, index) => (
54 |
55 |
56 | navigateToPath(index)}
58 | className="ml-2 hover:text-gray-900"
59 | >
60 | {segment}
61 |
62 |
63 | ))}
64 |
65 |
66 |
67 |
68 | {/* Content Area */}
69 |
70 | {children}
71 |
72 |
73 | )
74 | }
--------------------------------------------------------------------------------
/template/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @import './styles/utilities.css';
6 |
7 | :root {
8 | --foreground-rgb: 0, 0, 0;
9 | --background-rgb: 255, 255, 255;
10 | }
11 |
12 | body {
13 | color: rgb(var(--foreground-rgb));
14 | background: rgb(var(--background-rgb));
15 | }
--------------------------------------------------------------------------------
/template/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import './globals.css'
2 | import type { Metadata } from 'next'
3 | import { Inter } from 'next/font/google'
4 | import AppLayout from './layouts/AppLayout'
5 |
6 | const inter = Inter({ subsets: ['latin'] })
7 |
8 | export const metadata: Metadata = {
9 | title: 'Supawald',
10 | description: 'A minimal, clean file management system built with Next.js and Supabase Storage',
11 | icons: {
12 | icon: '/favicon.ico',
13 | },
14 | }
15 |
16 | export default function RootLayout({
17 | children,
18 | }: {
19 | children: React.ReactNode
20 | }) {
21 | return (
22 |
23 |
24 | {children}
25 |
26 |
27 | )
28 | }
29 |
--------------------------------------------------------------------------------
/template/app/layouts/AppLayout.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { useState } from 'react'
4 | import { Bars3Icon } from '@heroicons/react/24/outline'
5 | import PublishButton from '../components/PublishButton'
6 | import Link from 'next/link'
7 |
8 | interface AppLayoutProps {
9 | children: React.ReactNode
10 | }
11 |
12 | export default function AppLayout({ children }: AppLayoutProps) {
13 | const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false)
14 |
15 | return (
16 |
17 | {/* Sidebar */}
18 |
23 |
24 | {!isSidebarCollapsed && (
25 |
29 | supawald
30 |
31 | )}
32 | setIsSidebarCollapsed(!isSidebarCollapsed)}
34 | className="btn-icon rounded-md hover:bg-gray-100/80 transition-colors duration-200"
35 | >
36 |
37 |
38 |
39 |
40 |
41 |
42 |
68 |
69 |
70 | {/* Main Content */}
71 |
72 | {children}
73 |
74 |
75 | )
76 | }
--------------------------------------------------------------------------------
/template/app/lib/publish.ts:
--------------------------------------------------------------------------------
1 | export interface PublishConfig {
2 | url: string;
3 | token: string;
4 | }
5 |
6 | export function getPublishConfig(): PublishConfig {
7 | const url = process.env.PUBLISH_URL
8 | const token = process.env.PUBLISH_TOKEN
9 |
10 | if (!url) {
11 | throw new Error('PUBLISH_URL environment variable is not set')
12 | }
13 |
14 | if (!token) {
15 | throw new Error('PUBLISH_TOKEN environment variable is not set')
16 | }
17 |
18 | return { url, token }
19 | }
20 |
21 | export async function publish() {
22 | const config = getPublishConfig()
23 |
24 | const response = await fetch(config.url, {
25 | method: 'POST',
26 | headers: {
27 | 'Content-Type': 'application/json',
28 | 'Authorization': `Bearer ${config.token}`,
29 | },
30 | })
31 |
32 | if (!response.ok) {
33 | throw new Error(`Failed to publish: ${response.status}`)
34 | }
35 |
36 | return response.json()
37 | }
--------------------------------------------------------------------------------
/template/app/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client'
2 |
3 | import { Suspense } from 'react'
4 | import { useEffect, useState } from 'react'
5 | import { supabase, BUCKET_NAME } from '@/lib/supabase'
6 | import Link from 'next/link'
7 | import { useRouter, useSearchParams } from 'next/navigation'
8 | import {
9 | ArrowUpTrayIcon,
10 | ArrowPathIcon,
11 | ChevronLeftIcon,
12 | FolderIcon,
13 | DocumentIcon,
14 | PhotoIcon,
15 | TrashIcon,
16 | PencilIcon,
17 | Bars3Icon,
18 | ChevronDoubleLeftIcon,
19 | EyeIcon,
20 | ViewColumnsIcon,
21 | } from '@heroicons/react/24/outline'
22 | import ImageModal from './components/ImageModal'
23 | import FileViewModal from './components/FileViewModal'
24 | import PublishButton from './components/PublishButton'
25 | import CreateFolderModal from './components/CreateFolderModal'
26 |
27 | interface FileObject {
28 | name: string
29 | id: string
30 | updated_at: string
31 | created_at: string
32 | last_accessed_at: string
33 | metadata: Record
34 | }
35 |
36 | function FileExplorer() {
37 | const [files, setFiles] = useState([])
38 | const [folders, setFolders] = useState([])
39 | const [currentPath, setCurrentPath] = useState('')
40 | const [folderContents, setFolderContents] = useState>({})
41 | const [uploading, setUploading] = useState(false)
42 | const [error, setError] = useState(null)
43 | const [selectedImage, setSelectedImage] = useState<{ url: string; name: string } | null>(null)
44 | const [selectedFile, setSelectedFile] = useState<{ url: string; name: string } | null>(null)
45 | const [viewMode, setViewMode] = useState<'list' | 'columns'>('list')
46 | const [isDragging, setIsDragging] = useState(false)
47 | const [isCreateFolderModalOpen, setIsCreateFolderModalOpen] = useState(false)
48 | const router = useRouter()
49 | const searchParams = useSearchParams()
50 |
51 | const handleDragOver = (e: React.DragEvent) => {
52 | e.preventDefault()
53 | e.stopPropagation()
54 | setIsDragging(true)
55 | }
56 |
57 | const handleDragLeave = (e: React.DragEvent) => {
58 | e.preventDefault()
59 | e.stopPropagation()
60 | setIsDragging(false)
61 | }
62 |
63 | const handleDrop = async (e: React.DragEvent) => {
64 | e.preventDefault()
65 | e.stopPropagation()
66 | setIsDragging(false)
67 |
68 | const droppedFiles = Array.from(e.dataTransfer.files)
69 | if (droppedFiles.length === 0) return
70 |
71 | setUploading(true)
72 | try {
73 | for (const file of droppedFiles) {
74 | const filePath = currentPath ? `${currentPath}/${file.name}` : file.name
75 | const { error } = await supabase.storage.from(BUCKET_NAME).upload(filePath, file, {
76 | upsert: true,
77 | cacheControl: '3600',
78 | })
79 |
80 | if (error) throw error
81 | }
82 | fetchFiles(currentPath)
83 | } catch (err) {
84 | console.error('Error in handleDrop:', err)
85 | setError('Failed to upload files')
86 | } finally {
87 | setUploading(false)
88 | }
89 | }
90 |
91 | const fetchFolderContents = async (path: string) => {
92 | try {
93 | const { data, error } = await supabase.storage.from(BUCKET_NAME).list(path, {
94 | limit: 100,
95 | sortBy: { column: 'name', order: 'asc' }
96 | })
97 |
98 | if (error) throw error
99 |
100 | const fileItems = data.filter(item =>
101 | item.metadata &&
102 | typeof item.metadata === 'object' &&
103 | 'size' in item.metadata &&
104 | item.name !== '.empty'
105 | )
106 | const folderItems = data.filter(item =>
107 | (!item.metadata || typeof item.metadata !== 'object' || !('size' in item.metadata)) &&
108 | item.name !== '.empty'
109 | )
110 |
111 | setFolderContents(prev => ({
112 | ...prev,
113 | [path]: { files: fileItems, folders: folderItems }
114 | }))
115 | } catch (err) {
116 | console.error('Error in fetchFolderContents:', err)
117 | }
118 | }
119 |
120 | useEffect(() => {
121 | // Get path and view from URL if they exist
122 | const pathParam = searchParams.get('path')
123 | const viewParam = searchParams.get('view') as 'list' | 'columns'
124 |
125 | // Set view mode from URL or default to list
126 | if (viewParam && ['list', 'columns'].includes(viewParam)) {
127 | setViewMode(viewParam)
128 | } else {
129 | // If no view param in URL, set it to default (list)
130 | const params = new URLSearchParams(searchParams.toString())
131 | params.set('view', 'list')
132 | if (pathParam) {
133 | params.set('path', pathParam)
134 | }
135 | router.replace(`/?${params.toString()}`)
136 | }
137 |
138 | // Always fetch root contents first
139 | fetchFolderContents('')
140 |
141 | if (pathParam) {
142 | const path = decodeURIComponent(pathParam)
143 | setCurrentPath(path)
144 |
145 | // Fetch contents for each level of the path
146 | const segments = path.split('/')
147 | let accPath = ''
148 |
149 | // Fetch each parent folder's contents sequentially
150 | segments.forEach((segment) => {
151 | accPath = accPath ? `${accPath}/${segment}` : segment
152 | fetchFolderContents(accPath)
153 | })
154 |
155 | // Also fetch the current path's contents using fetchFiles
156 | fetchFiles(path)
157 | } else {
158 | setCurrentPath('')
159 | fetchFiles('')
160 | }
161 | }, [searchParams])
162 |
163 | const fetchFiles = async (path: string) => {
164 | try {
165 | const { data, error } = await supabase.storage.from(BUCKET_NAME).list(path, {
166 | limit: 100,
167 | sortBy: { column: 'name', order: 'asc' }
168 | })
169 |
170 | if (error) throw error
171 |
172 | const fileItems = data.filter(item =>
173 | item.metadata &&
174 | typeof item.metadata === 'object' &&
175 | 'size' in item.metadata &&
176 | item.name !== '.empty'
177 | )
178 | const folderItems = data.filter(item =>
179 | (!item.metadata || typeof item.metadata !== 'object' || !('size' in item.metadata)) &&
180 | item.name !== '.empty'
181 | )
182 |
183 | setFiles(fileItems)
184 | setFolders(folderItems)
185 |
186 | // Update folder contents for the current path
187 | setFolderContents(prev => ({
188 | ...prev,
189 | [path]: { files: fileItems, folders: folderItems }
190 | }))
191 |
192 | setError(null)
193 | } catch (err) {
194 | console.error('Error in fetchFiles:', err)
195 | setError('Failed to fetch files')
196 | }
197 | }
198 |
199 | const handleUpload = async (e: React.ChangeEvent) => {
200 | const file = e.target.files?.[0]
201 | if (!file) return
202 |
203 | // Check file size (max 50MB)
204 | if (file.size > 50 * 1024 * 1024) {
205 | setError('File size must be less than 50MB')
206 | return
207 | }
208 |
209 | // Validate file name
210 | if (!/^[a-zA-Z0-9-_. ]+$/.test(file.name)) {
211 | setError('File name can only contain letters, numbers, spaces, and basic punctuation')
212 | return
213 | }
214 |
215 | setUploading(true)
216 |
217 | try {
218 | const filePath = currentPath ? `${currentPath}/${file.name}` : file.name
219 | const { error } = await supabase.storage.from(BUCKET_NAME).upload(filePath, file, {
220 | upsert: true,
221 | cacheControl: '3600',
222 | })
223 |
224 | if (error) {
225 | if (error.message.includes('permission denied')) {
226 | throw new Error('You do not have permission to upload files')
227 | }
228 | throw error
229 | }
230 | fetchFiles(currentPath)
231 | } catch (err) {
232 | console.error('Error in handleUpload:', err)
233 | setError(err instanceof Error ? err.message : 'Failed to upload file')
234 | } finally {
235 | setUploading(false)
236 | }
237 | }
238 |
239 | const handleDelete = async (name: string) => {
240 | if (!confirm('Are you sure you want to delete this file?')) return
241 |
242 | try {
243 | // Validate file name
244 | if (!/^[a-zA-Z0-9-_. ]+$/.test(name)) {
245 | setError('Invalid file name')
246 | return
247 | }
248 |
249 | const filePath = currentPath ? `${currentPath}/${name}` : name
250 | const { error } = await supabase.storage.from(BUCKET_NAME).remove([filePath])
251 |
252 | if (error) {
253 | if (error.message.includes('permission denied')) {
254 | throw new Error('You do not have permission to delete files')
255 | }
256 | throw error
257 | }
258 | fetchFiles(currentPath)
259 | } catch (err) {
260 | console.error('Error in handleDelete:', err)
261 | setError(err instanceof Error ? err.message : 'Failed to delete file')
262 | }
263 | }
264 |
265 | const handleCreateFolder = async (folderName: string) => {
266 | try {
267 | const folderPath = currentPath ? `${currentPath}/${folderName}` : folderName
268 | const { error } = await supabase.storage.from(BUCKET_NAME).upload(`${folderPath}/.empty`, '', {
269 | upsert: true,
270 | cacheControl: '3600',
271 | })
272 |
273 | if (error) {
274 | if (error.message.includes('permission denied')) {
275 | throw new Error('You do not have permission to create folders')
276 | }
277 | throw error
278 | }
279 |
280 | fetchFiles(currentPath)
281 | fetchFolderContents(currentPath)
282 | } catch (err) {
283 | console.error('Error creating folder:', err)
284 | throw err
285 | }
286 | }
287 |
288 | const handleDeleteFolder = async (name: string) => {
289 | if (!confirm('Are you sure you want to delete this folder and all its contents?')) return
290 |
291 | // Validate folder name
292 | if (!/^[a-zA-Z0-9-_. ]+$/.test(name)) {
293 | setError('Invalid folder name')
294 | return
295 | }
296 |
297 | try {
298 | const folderPath = currentPath ? `${currentPath}/${name}` : name
299 |
300 | // First, list all contents of the folder recursively
301 | const listFolderContents = async (path: string): Promise => {
302 | const { data: contents, error: listError } = await supabase.storage
303 | .from(BUCKET_NAME)
304 | .list(path)
305 |
306 | if (listError) {
307 | if (listError.message.includes('permission denied')) {
308 | throw new Error('You do not have permission to list folder contents')
309 | }
310 | throw listError
311 | }
312 |
313 | let allPaths: string[] = []
314 |
315 | // Process each item
316 | for (const item of contents) {
317 | const itemPath = `${path}/${item.name}`
318 | if (!item.metadata || typeof item.metadata !== 'object' || !('size' in item.metadata)) {
319 | // If it's a folder, recursively get its contents
320 | allPaths = [...allPaths, ...await listFolderContents(itemPath)]
321 | } else {
322 | // If it's a file, add it to the list
323 | allPaths.push(itemPath)
324 | }
325 | }
326 |
327 | return allPaths
328 | }
329 |
330 | // Get all paths to delete
331 | const pathsToDelete = await listFolderContents(folderPath)
332 |
333 | // Delete all contents
334 | const { error: deleteError } = await supabase.storage
335 | .from(BUCKET_NAME)
336 | .remove(pathsToDelete)
337 |
338 | if (deleteError) {
339 | if (deleteError.message.includes('permission denied')) {
340 | throw new Error('You do not have permission to delete folders')
341 | }
342 | throw deleteError
343 | }
344 |
345 | // If we deleted the current folder, navigate up one level
346 | if (currentPath === name) {
347 | const parentPath = currentPath.split('/').slice(0, -1).join('/')
348 | setCurrentPath(parentPath)
349 | router.push(parentPath ? `/?path=${encodeURIComponent(parentPath)}` : '/')
350 | }
351 |
352 | // Refresh the current directory
353 | fetchFiles(currentPath)
354 | fetchFolderContents(currentPath)
355 | } catch (err) {
356 | console.error('Error in handleDeleteFolder:', err)
357 | setError(err instanceof Error ? err.message : 'Failed to delete folder')
358 | }
359 | }
360 |
361 | const handleBack = () => {
362 | if (currentPath) {
363 | const parentPath = currentPath.split('/').slice(0, -1).join('/')
364 | setCurrentPath(parentPath)
365 | router.push(parentPath ? `/?path=${encodeURIComponent(parentPath)}` : '/')
366 | } else {
367 | router.push('/')
368 | }
369 | }
370 |
371 | const navigateToFolder = (folder: FileObject) => {
372 | const newPath = currentPath ? `${currentPath}/${folder.name}` : folder.name
373 | setCurrentPath(newPath)
374 | const params = new URLSearchParams()
375 | params.set('path', newPath)
376 | params.set('view', viewMode)
377 | router.push(`/?${params.toString()}`)
378 | }
379 |
380 | const navigateUp = () => {
381 | const parentPath = currentPath.split('/').slice(0, -1).join('/')
382 | setCurrentPath(parentPath)
383 | const params = new URLSearchParams()
384 | if (parentPath) {
385 | params.set('path', parentPath)
386 | }
387 | params.set('view', viewMode)
388 | router.push(`/?${params.toString()}`)
389 | }
390 |
391 | const navigateToRoot = () => {
392 | setCurrentPath('')
393 | const params = new URLSearchParams()
394 | params.set('view', viewMode)
395 | router.push(`/?${params.toString()}`)
396 | }
397 |
398 | const formatFileSize = (bytes: number) => {
399 | if (bytes === 0) return '0 Bytes'
400 | const k = 1024
401 | const sizes = ['Bytes', 'KB', 'MB', 'GB']
402 | const i = Math.floor(Math.log(bytes) / Math.log(k))
403 | return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
404 | }
405 |
406 | const getFileIcon = (filename: string) => {
407 | const extension = filename.split('.').pop()?.toLowerCase()
408 | if (['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(extension || '')) {
409 | return
410 | }
411 | return
412 | }
413 |
414 | const updateUrlWithView = (newViewMode: 'list' | 'columns') => {
415 | const currentPath = searchParams.get('path')
416 | const params = new URLSearchParams()
417 | params.set('view', newViewMode)
418 | if (currentPath) {
419 | params.set('path', currentPath)
420 | }
421 | router.push(`/?${params.toString()}`)
422 | }
423 |
424 | return (
425 |
426 | {/* Top Navigation */}
427 |
428 |
429 |
433 |
434 |
435 |
436 |
440 | Files
441 |
442 | {currentPath && currentPath.split('/').map((segment, index, array) => (
443 |
444 | /
445 | {
447 | const path = array.slice(0, index + 1).join('/')
448 | setCurrentPath(path)
449 | router.push(`/?path=${encodeURIComponent(path)}`)
450 | }}
451 | className="hover:text-gray-900"
452 | >
453 | {segment}
454 |
455 |
456 | ))}
457 |
458 |
459 |
460 |
461 | {/* Main Content Area */}
462 |
468 | {/* Actions Bar */}
469 |
470 |
471 |
{
473 | const newViewMode = viewMode === 'list' ? 'columns' : 'list'
474 | setViewMode(newViewMode)
475 | updateUrlWithView(newViewMode)
476 | }}
477 | className="p-2 text-gray-500 hover:text-gray-700 rounded-md hover:bg-gray-100"
478 | >
479 |
480 |
481 |
fetchFiles(currentPath)}
483 | className="p-2 text-gray-500 hover:text-gray-700 rounded-md hover:bg-gray-100"
484 | >
485 |
486 |
487 |
488 |
489 |
490 |
setIsCreateFolderModalOpen(true)}
492 | className="px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 rounded-md"
493 | >
494 | Create folder
495 |
496 |
497 |
498 | Upload to {currentPath ? `"${currentPath}"` : 'root'}
499 |
506 |
507 |
508 |
509 |
510 | {/* Error Message */}
511 | {error && (
512 |
513 | {error}
514 |
515 | )}
516 |
517 | {/* File List */}
518 | {viewMode === 'list' ? (
519 |
520 |
521 |
522 |
523 |
524 | Name
525 |
526 |
527 | Size
528 |
529 |
530 | Updated
531 |
532 |
533 | Actions
534 |
535 |
536 |
537 |
538 | {/* Folders */}
539 | {folders.map(folder => (
540 |
541 |
542 |
543 |
544 | navigateToFolder(folder)}
546 | className="ml-3 text-sm text-gray-900 hover:text-gray-600"
547 | >
548 | {folder.name}
549 |
550 |
551 |
552 | --
553 | --
554 |
555 | {currentPath === '' && (
556 | {
558 | e.stopPropagation();
559 | handleDeleteFolder(folder.name);
560 | }}
561 | className="text-gray-400 hover:text-red-500"
562 | >
563 |
564 |
565 | )}
566 |
567 |
568 | ))}
569 |
570 | {/* Files */}
571 | {files.map(file => (
572 |
573 |
574 |
575 | {getFileIcon(file.name)}
576 | {
578 | const filePath = currentPath ? `${currentPath}/${file.name}` : file.name;
579 | const extension = file.name.split('.').pop()?.toLowerCase();
580 | if (['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(extension || '')) {
581 | setSelectedImage({
582 | url: filePath,
583 | name: file.name
584 | });
585 | } else {
586 | setSelectedFile({
587 | url: filePath,
588 | name: file.name
589 | });
590 | }
591 | }}
592 | className="ml-3 text-sm text-gray-900 hover:text-gray-600"
593 | >
594 | {file.name}
595 |
596 |
597 |
598 |
599 | {formatFileSize(file.metadata.size)}
600 |
601 |
602 | {new Date(file.updated_at).toLocaleDateString()}
603 |
604 |
605 |
606 | {['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(file.name.split('.').pop()?.toLowerCase() || '') ? (
607 |
{
609 | e.stopPropagation();
610 | const filePath = currentPath ? `${currentPath}/${file.name}` : file.name;
611 | setSelectedImage({
612 | url: filePath,
613 | name: file.name
614 | });
615 | }}
616 | className="text-gray-400 hover:text-blue-500"
617 | >
618 |
619 |
620 | ) : (
621 |
625 |
626 |
627 | )}
628 |
{
630 | e.stopPropagation();
631 | handleDelete(file.name);
632 | }}
633 | className="text-gray-400 hover:text-red-500"
634 | >
635 |
636 |
637 |
638 |
639 |
640 | ))}
641 |
642 | {/* Empty State */}
643 | {files.length === 0 && folders.length === 0 && (
644 |
645 |
646 | No files or folders found in this directory
647 |
648 |
649 | )}
650 |
651 |
652 |
653 | ) : (
654 |
655 |
656 | {/* Root Column */}
657 |
658 |
659 |
660 | {/* Use root folder contents */}
661 | {(() => {
662 | const rootContents = folderContents[''] || { files: [], folders: [] }
663 | return [...rootContents.folders, ...rootContents.files].map(item => (
664 |
{
668 | if (!item.metadata?.size) {
669 | const newPath = item.name
670 | setCurrentPath(newPath)
671 | router.push(`/?path=${encodeURIComponent(newPath)}`)
672 | fetchFolderContents(newPath)
673 | } else {
674 | const filePath = item.name;
675 | const extension = item.name.split('.').pop()?.toLowerCase();
676 | if (['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(extension || '')) {
677 | setSelectedImage({
678 | url: filePath,
679 | name: item.name
680 | });
681 | } else {
682 | setSelectedFile({
683 | url: filePath,
684 | name: item.name
685 | });
686 | }
687 | }
688 | }}
689 | >
690 | {!item.metadata?.size ? (
691 |
692 | ) : (
693 | getFileIcon(item.name)
694 | )}
695 |
696 | {item.name}
697 |
698 |
699 | {!item.metadata?.size ? (
700 | currentPath === '' && (
701 |
{
703 | e.stopPropagation();
704 | handleDeleteFolder(item.name);
705 | }}
706 | className="text-gray-400 hover:text-red-500"
707 | >
708 |
709 |
710 | )
711 | ) : (
712 | <>
713 | {['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(item.name.split('.').pop()?.toLowerCase() || '') ? (
714 |
{
716 | e.stopPropagation();
717 | const filePath = currentPath ? `${currentPath}/${item.name}` : item.name;
718 | setSelectedImage({
719 | url: filePath,
720 | name: item.name
721 | });
722 | }}
723 | className="text-gray-400 hover:text-blue-500"
724 | >
725 |
726 |
727 | ) : (
728 |
e.stopPropagation()}
732 | >
733 |
734 |
735 | )}
736 |
{
738 | e.stopPropagation();
739 | handleDelete(item.name);
740 | }}
741 | className="text-gray-400 hover:text-red-500"
742 | >
743 |
744 |
745 | >
746 | )}
747 |
748 |
749 | ))
750 | })()}
751 | {/* Use root folder empty state */}
752 | {(() => {
753 | const rootContents = folderContents[''] || { files: [], folders: [] }
754 | return rootContents.files.length === 0 && rootContents.folders.length === 0 && (
755 |
756 | No files or folders
757 |
758 | )
759 | })()}
760 |
761 |
762 |
763 |
764 | {/* Nested Path Columns */}
765 | {currentPath && currentPath.split('/').map((segment, index, array) => {
766 | const currentSegmentPath = array.slice(0, index + 1).join('/')
767 | const contents = folderContents[currentSegmentPath] || { files: [], folders: [] }
768 |
769 | return (
770 |
771 |
772 |
773 | {[...contents.folders, ...contents.files].map(item => (
774 |
{
778 | if (!item.metadata?.size) {
779 | const newPath = `${currentSegmentPath}/${item.name}`
780 | setCurrentPath(newPath)
781 | router.push(`/?path=${encodeURIComponent(newPath)}`)
782 | fetchFolderContents(newPath)
783 | } else {
784 | const filePath = `${currentSegmentPath}/${item.name}`
785 | const extension = item.name.split('.').pop()?.toLowerCase();
786 | if (['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(extension || '')) {
787 | setSelectedImage({
788 | url: filePath,
789 | name: item.name
790 | });
791 | } else {
792 | setSelectedFile({
793 | url: filePath,
794 | name: item.name
795 | });
796 | }
797 | }
798 | }}
799 | >
800 | {!item.metadata?.size ? (
801 |
802 | ) : (
803 | getFileIcon(item.name)
804 | )}
805 |
806 | {item.name}
807 |
808 |
809 | {!item.metadata?.size ? (
810 | currentPath === '' && (
811 |
{
813 | e.stopPropagation();
814 | handleDeleteFolder(item.name);
815 | }}
816 | className="text-gray-400 hover:text-red-500"
817 | >
818 |
819 |
820 | )
821 | ) : (
822 | <>
823 | {['jpg', 'jpeg', 'png', 'gif', 'webp'].includes(item.name.split('.').pop()?.toLowerCase() || '') ? (
824 |
{
826 | e.stopPropagation();
827 | const filePath = currentSegmentPath ? `${currentSegmentPath}/${item.name}` : item.name;
828 | setSelectedImage({
829 | url: filePath,
830 | name: item.name
831 | });
832 | }}
833 | className="text-gray-400 hover:text-blue-500"
834 | >
835 |
836 |
837 | ) : (
838 |
e.stopPropagation()}
842 | >
843 |
844 |
845 | )}
846 |
{
848 | e.stopPropagation();
849 | handleDelete(item.name);
850 | }}
851 | className="text-gray-400 hover:text-red-500"
852 | >
853 |
854 |
855 | >
856 | )}
857 |
858 |
859 | ))}
860 | {contents.files.length === 0 && contents.folders.length === 0 && (
861 |
862 | No files or folders
863 |
864 | )}
865 |
866 |
867 |
868 | )
869 | })}
870 |
871 |
872 | )}
873 |
874 |
875 | {/* Modals */}
876 | {selectedImage && (
877 |
setSelectedImage(null)}
882 | />
883 | )}
884 | {selectedFile && (
885 | setSelectedFile(null)}
890 | />
891 | )}
892 | setIsCreateFolderModalOpen(false)}
895 | onCreateFolder={handleCreateFolder}
896 | currentPath={currentPath}
897 | />
898 |
899 | )
900 | }
901 |
902 | export default function Home() {
903 | return (
904 | Loading...}>
905 |
906 |
907 | )
908 | }
--------------------------------------------------------------------------------
/template/app/styles/components/CreateFolderModal.css:
--------------------------------------------------------------------------------
1 | .modal-container {
2 | @apply fixed inset-0 z-50 overflow-y-auto;
3 | }
4 |
5 | .modal-backdrop {
6 | @apply fixed inset-0 bg-gray-500 bg-opacity-75 backdrop-blur-sm transition-opacity;
7 | }
8 |
9 | .modal-content {
10 | @apply relative bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-md w-full mx-auto;
11 | }
12 |
13 | .modal-header {
14 | @apply flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700;
15 | }
16 |
17 | .modal-title {
18 | @apply text-lg font-medium text-gray-900 dark:text-white;
19 | }
20 |
21 | .close-button {
22 | @apply rounded-md text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-500 dark:hover:text-gray-300;
23 | }
24 |
25 | .close-icon {
26 | @apply h-5 w-5;
27 | }
28 |
29 | .modal-body {
30 | @apply p-4;
31 | }
32 |
33 | .form-group {
34 | @apply mb-4;
35 | }
36 |
37 | .form-label {
38 | @apply block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1;
39 | }
40 |
41 | .form-input {
42 | @apply w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-800 dark:text-white;
43 | }
44 |
45 | .modal-footer {
46 | @apply flex justify-end space-x-3 p-4 border-t border-gray-200 dark:border-gray-700;
47 | }
48 |
49 | .cancel-button {
50 | @apply px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-700;
51 | }
52 |
53 | .create-button {
54 | @apply px-4 py-2 text-sm font-medium text-white bg-primary-600 border border-transparent rounded-md hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500;
55 | }
56 |
57 | .error-message {
58 | @apply mt-1 text-sm text-red-600 dark:text-red-400;
59 | }
--------------------------------------------------------------------------------
/template/app/styles/components/FileList.css:
--------------------------------------------------------------------------------
1 | .file-list {
2 | @apply space-y-2;
3 | }
4 |
5 | .file-item {
6 | @apply flex items-center justify-between p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors;
7 | }
8 |
9 | .file-item-content {
10 | @apply flex items-center space-x-2 flex-1;
11 | }
12 |
13 | .file-icon {
14 | @apply h-5 w-5 text-gray-500 dark:text-gray-400;
15 | }
16 |
17 | .file-name {
18 | @apply text-sm font-medium text-gray-900 dark:text-white truncate;
19 | }
20 |
21 | .file-actions {
22 | @apply flex items-center space-x-2;
23 | }
24 |
25 | .action-button {
26 | @apply p-1 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 rounded-md hover:bg-gray-200 dark:hover:bg-gray-700;
27 | }
28 |
29 | .action-icon {
30 | @apply h-4 w-4;
31 | }
32 |
33 | .folder-item {
34 | @apply flex items-center space-x-2 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors cursor-pointer;
35 | }
36 |
37 | .folder-icon {
38 | @apply h-5 w-5 text-gray-500 dark:text-gray-400;
39 | }
40 |
41 | .folder-name {
42 | @apply text-sm font-medium text-gray-900 dark:text-white;
43 | }
44 |
45 | .empty-state {
46 | @apply flex flex-col items-center justify-center p-8 text-center;
47 | }
48 |
49 | .empty-icon {
50 | @apply h-12 w-12 text-gray-400 dark:text-gray-500 mb-4;
51 | }
52 |
53 | .empty-text {
54 | @apply text-sm text-gray-500 dark:text-gray-400;
55 | }
--------------------------------------------------------------------------------
/template/app/styles/components/FileViewModal.css:
--------------------------------------------------------------------------------
1 | .modal-container {
2 | @apply fixed inset-0 z-50 overflow-y-auto;
3 | }
4 |
5 | .modal-backdrop {
6 | @apply fixed inset-0 bg-gray-500 bg-opacity-75 backdrop-blur-sm transition-opacity;
7 | }
8 |
9 | .modal-content {
10 | @apply relative bg-white dark:bg-gray-900 rounded-lg shadow-xl overflow-hidden;
11 | }
12 |
13 | .modal-header {
14 | @apply flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700;
15 | }
16 |
17 | .modal-title {
18 | @apply text-lg font-medium text-gray-900 dark:text-white;
19 | }
20 |
21 | .btn-icon {
22 | @apply rounded-md text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-500 dark:hover:text-gray-300;
23 | }
24 |
25 | .icon {
26 | @apply h-5 w-5;
27 | }
28 |
29 | .icon-sm {
30 | @apply h-4 w-4;
31 | }
32 |
33 | .loading-spinner {
34 | @apply h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-gray-900 dark:border-gray-600 dark:border-t-gray-300;
35 | }
36 |
37 | .loading-text {
38 | @apply text-sm text-gray-500 dark:text-gray-400;
39 | }
40 |
41 | /* Markdown content styles */
42 | .markdown-content {
43 | @apply h-full overflow-auto p-8 prose prose-slate dark:prose-invert max-w-none;
44 | }
45 |
46 | .markdown-content h1 {
47 | @apply text-3xl font-bold mb-4;
48 | }
49 |
50 | .markdown-content h2 {
51 | @apply text-2xl font-bold mb-3;
52 | }
53 |
54 | .markdown-content h3 {
55 | @apply text-xl font-bold mb-2;
56 | }
57 |
58 | .markdown-content p {
59 | @apply mb-4 leading-relaxed;
60 | }
61 |
62 | .markdown-content ul {
63 | @apply list-disc list-outside ml-4 mb-4 space-y-1;
64 | }
65 |
66 | .markdown-content ol {
67 | @apply list-decimal list-outside ml-4 mb-4 space-y-1;
68 | }
69 |
70 | .markdown-content li {
71 | @apply mb-1;
72 | }
73 |
74 | .markdown-content blockquote {
75 | @apply border-l-4 border-gray-300 dark:border-gray-600 pl-4 my-4 italic;
76 | }
77 |
78 | .markdown-content code {
79 | @apply rounded-md bg-gray-100 px-1.5 py-0.5 font-mono text-sm dark:bg-gray-800;
80 | }
81 |
82 | .markdown-content pre {
83 | @apply bg-gray-100 dark:bg-gray-800 rounded-lg p-4 mb-4 overflow-x-auto;
84 | }
85 |
86 | .markdown-content img {
87 | @apply rounded-lg max-w-full h-auto my-4;
88 | }
89 |
90 | .markdown-content a {
91 | @apply text-blue-600 hover:text-blue-800 underline dark:text-blue-400 dark:hover:text-blue-300;
92 | }
93 |
94 | .markdown-content table {
95 | @apply min-w-full border-separate border-spacing-0 my-4;
96 | }
97 |
98 | .markdown-content th {
99 | @apply border-b-2 dark:border-gray-600 p-2 text-left font-bold bg-gray-50 dark:bg-gray-900;
100 | }
101 |
102 | .markdown-content td {
103 | @apply p-2 border-b dark:border-gray-700;
104 | }
105 |
106 | .markdown-content hr {
107 | @apply my-8 border-t border-gray-300 dark:border-gray-600;
108 | }
109 |
110 | .markdown-content strong {
111 | @apply font-bold;
112 | }
113 |
114 | .markdown-content em {
115 | @apply italic;
116 | }
117 |
118 | /* Frontmatter styles */
119 | .frontmatter-container {
120 | @apply mb-8 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg;
121 | }
122 |
123 | .frontmatter-title {
124 | @apply text-2xl font-bold mb-2;
125 | }
126 |
127 | .frontmatter-meta {
128 | @apply flex items-center space-x-4 text-sm text-gray-600 dark:text-gray-400;
129 | }
130 |
131 | .frontmatter-description {
132 | @apply mt-2 text-gray-700 dark:text-gray-300;
133 | }
134 |
135 | .frontmatter-tags {
136 | @apply mt-2 flex flex-wrap gap-2;
137 | }
138 |
139 | .frontmatter-tag {
140 | @apply px-2 py-1 bg-gray-200 dark:bg-gray-700 rounded text-sm;
141 | }
--------------------------------------------------------------------------------
/template/app/styles/components/ImageModal.css:
--------------------------------------------------------------------------------
1 | .modal-container {
2 | @apply fixed inset-0 z-50 overflow-y-auto;
3 | }
4 |
5 | .modal-backdrop {
6 | @apply fixed inset-0 bg-black bg-opacity-75 backdrop-blur-sm transition-opacity;
7 | }
8 |
9 | .modal-content {
10 | @apply relative bg-white dark:bg-gray-900 rounded-lg shadow-xl overflow-hidden max-w-4xl w-full mx-auto;
11 | }
12 |
13 | .modal-header {
14 | @apply flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700;
15 | }
16 |
17 | .modal-title {
18 | @apply text-lg font-medium text-gray-900 dark:text-white;
19 | }
20 |
21 | .btn-icon {
22 | @apply rounded-md text-gray-400 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-gray-500 dark:hover:text-gray-300;
23 | }
24 |
25 | .icon {
26 | @apply h-5 w-5;
27 | }
28 |
29 | .close-button {
30 | @apply p-2 text-white hover:text-gray-300 rounded-full hover:bg-black/50 transition-colors;
31 | }
32 |
33 | .close-icon {
34 | @apply h-6 w-6;
35 | }
36 |
37 | .image-container {
38 | @apply flex items-center justify-center p-4;
39 | }
40 |
41 | .modal-image {
42 | @apply max-w-full max-h-[70vh] object-contain rounded-lg;
43 | }
44 |
45 | .loading-container {
46 | @apply flex items-center justify-center min-h-screen;
47 | }
48 |
49 | .loading-spinner {
50 | @apply h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-gray-900 dark:border-gray-600 dark:border-t-gray-300;
51 | }
52 |
53 | .loading-text {
54 | @apply text-sm text-gray-500 dark:text-gray-400;
55 | }
56 |
57 | .error-container {
58 | @apply flex flex-col items-center justify-center min-h-screen text-white;
59 | }
60 |
61 | .error-icon {
62 | @apply h-12 w-12 mb-4;
63 | }
64 |
65 | .error-text {
66 | @apply text-lg font-medium;
67 | }
68 |
69 | .error-details {
70 | @apply text-sm opacity-75 mt-2;
71 | }
--------------------------------------------------------------------------------
/template/app/styles/components/MarkdownEditor.css:
--------------------------------------------------------------------------------
1 | .editor-container {
2 | @apply flex flex-col h-full;
3 | }
4 |
5 | .toolbar {
6 | @apply flex items-center space-x-2 p-2 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900;
7 | }
8 |
9 | .toolbar-button {
10 | @apply p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800;
11 | }
12 |
13 | .toolbar-icon {
14 | @apply h-5 w-5;
15 | }
16 |
17 | .editor-content {
18 | @apply flex-1 p-4 overflow-auto;
19 | }
20 |
21 | .editor-textarea {
22 | @apply w-full h-full p-4 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-800 dark:text-white font-mono text-sm;
23 | }
24 |
25 | .preview-container {
26 | @apply h-full overflow-auto p-4 prose prose-slate dark:prose-invert max-w-none;
27 | }
28 |
29 | .preview-content {
30 | @apply h-full;
31 | }
32 |
33 | .error-message {
34 | @apply text-sm text-red-600 dark:text-red-400 mt-2;
35 | }
--------------------------------------------------------------------------------
/template/app/styles/components/RevalidateButton.css:
--------------------------------------------------------------------------------
1 | .button {
2 | @apply inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-primary-600 hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500;
3 | }
4 |
5 | .button:disabled {
6 | @apply opacity-50 cursor-not-allowed;
7 | }
8 |
9 | .icon {
10 | @apply -ml-1 mr-2 h-4 w-4;
11 | }
12 |
13 | .spinner {
14 | @apply animate-spin;
15 | }
16 |
17 | .success-message {
18 | @apply text-sm text-green-600 dark:text-green-400;
19 | }
20 |
21 | .error-message {
22 | @apply text-sm text-red-600 dark:text-red-400;
23 | }
--------------------------------------------------------------------------------
/template/app/styles/editor.css:
--------------------------------------------------------------------------------
1 | /* TipTap Editor Styles */
2 | .ProseMirror {
3 | min-height: 100%;
4 | padding: 1rem;
5 | outline: none;
6 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
7 | line-height: 1.6;
8 | color: #2c3e50;
9 | }
10 |
11 | .ProseMirror p {
12 | margin: 0.5em 0;
13 | }
14 |
15 | .ProseMirror h1 {
16 | font-size: 2em;
17 | font-weight: 600;
18 | margin: 1em 0 0.5em;
19 | color: #1a202c;
20 | }
21 |
22 | .ProseMirror h2 {
23 | font-size: 1.5em;
24 | font-weight: 600;
25 | margin: 0.8em 0 0.4em;
26 | color: #2d3748;
27 | }
28 |
29 | .ProseMirror h3 {
30 | font-size: 1.25em;
31 | font-weight: 600;
32 | margin: 0.6em 0 0.3em;
33 | color: #4a5568;
34 | }
35 |
36 | .ProseMirror ul,
37 | .ProseMirror ol {
38 | padding-left: 1.5em;
39 | margin: 0.5em 0;
40 | }
41 |
42 | .ProseMirror li {
43 | margin: 0.25em 0;
44 | }
45 |
46 | .ProseMirror blockquote {
47 | border-left: 4px solid #e2e8f0;
48 | margin: 0.5em 0;
49 | padding-left: 1em;
50 | color: #718096;
51 | }
52 |
53 | .ProseMirror code {
54 | background-color: #f7fafc;
55 | padding: 0.2em 0.4em;
56 | border-radius: 0.25em;
57 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
58 | font-size: 0.875em;
59 | }
60 |
61 | .ProseMirror pre {
62 | background-color: #f7fafc;
63 | padding: 1em;
64 | border-radius: 0.5em;
65 | margin: 0.5em 0;
66 | overflow-x: auto;
67 | }
68 |
69 | .ProseMirror pre code {
70 | background-color: transparent;
71 | padding: 0;
72 | border-radius: 0;
73 | }
74 |
75 | .ProseMirror a {
76 | color: #3182ce;
77 | text-decoration: none;
78 | }
79 |
80 | .ProseMirror a:hover {
81 | text-decoration: underline;
82 | }
83 |
84 | .ProseMirror strong {
85 | font-weight: 600;
86 | color: #1a202c;
87 | }
88 |
89 | .ProseMirror em {
90 | font-style: italic;
91 | }
92 |
93 | .ProseMirror hr {
94 | border: none;
95 | border-top: 2px solid #e2e8f0;
96 | margin: 1em 0;
97 | }
98 |
99 | /* Placeholder styles */
100 | .ProseMirror p.is-editor-empty:first-child::before {
101 | color: #a0aec0;
102 | content: attr(data-placeholder);
103 | float: left;
104 | height: 0;
105 | pointer-events: none;
106 | }
107 |
108 | /* Focus styles */
109 | .ProseMirror:focus {
110 | outline: none;
111 | }
112 |
113 | /* Selection styles */
114 | .ProseMirror ::selection {
115 | background-color: #bee3f8;
116 | color: #2c3e50;
117 | }
118 |
119 | /* Image styles */
120 | .ProseMirror img {
121 | max-width: 100%;
122 | height: auto;
123 | border-radius: 0.5rem;
124 | margin: 1rem 0;
125 | display: block;
126 | box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
127 | background-color: rgb(249 250 251);
128 | padding: 0.5rem;
129 | }
130 |
131 | .ProseMirror img:hover {
132 | box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
133 | }
--------------------------------------------------------------------------------
/template/app/styles/utilities.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer components {
6 | /* Layout */
7 | .app-container {
8 | @apply flex h-screen bg-gray-50;
9 | }
10 |
11 | .sidebar {
12 | @apply bg-white border-r border-gray-200 flex flex-col transition-smooth;
13 | }
14 |
15 | .main-content {
16 | @apply flex-1 overflow-hidden;
17 | }
18 |
19 | /* Buttons */
20 | .btn {
21 | @apply inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-70 disabled:cursor-not-allowed;
22 | }
23 |
24 | .btn-primary {
25 | @apply bg-black text-white hover:bg-gray-900 focus:ring-gray-500;
26 | }
27 |
28 | .btn-secondary {
29 | @apply bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 focus:ring-gray-500;
30 | }
31 |
32 | .btn-danger {
33 | @apply bg-red-600 text-white hover:bg-red-700 focus:ring-red-500;
34 | }
35 |
36 | .btn-icon {
37 | @apply p-1 rounded-md hover:bg-gray-100 text-gray-500;
38 | }
39 |
40 | /* Forms */
41 | .input {
42 | @apply block w-full rounded-md border border-gray-300 px-3 py-2 text-sm shadow-sm placeholder:text-gray-400 focus:border-slate-500 focus:outline-none focus:ring-1 focus:ring-slate-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:placeholder:text-gray-400 dark:focus:border-slate-500 dark:focus:ring-slate-500;
43 | }
44 |
45 | .label {
46 | @apply block text-sm font-medium text-gray-700 dark:text-gray-300;
47 | }
48 |
49 | /* Form Groups */
50 | .form-group {
51 | @apply space-y-1;
52 | }
53 |
54 | .form-group label {
55 | @apply label;
56 | }
57 |
58 | .form-group input {
59 | @apply input;
60 | }
61 |
62 | .form-group .error {
63 | @apply text-sm text-red-600 dark:text-red-400;
64 | }
65 |
66 | /* Modals */
67 | .modal-backdrop {
68 | @apply fixed inset-0 bg-black/30 backdrop-blur-sm transition-opacity;
69 | }
70 |
71 | .modal-container {
72 | @apply fixed inset-0 z-50 overflow-y-auto;
73 | }
74 |
75 | .modal-content {
76 | @apply relative w-full max-w-5xl transform overflow-hidden rounded-lg bg-white shadow-xl transition-smooth dark:bg-gray-800;
77 | }
78 |
79 | .modal-header {
80 | @apply flex items-center justify-between border-b border-gray-200 px-4 py-3 dark:border-gray-700;
81 | }
82 |
83 | .modal-title {
84 | @apply text-sm font-medium text-gray-900 dark:text-gray-100;
85 | }
86 |
87 | .modal-body {
88 | @apply p-6;
89 | }
90 |
91 | .modal-footer {
92 | @apply flex justify-end space-x-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700;
93 | }
94 |
95 | /* Tables */
96 | .table-container {
97 | @apply bg-white rounded-lg border border-gray-200;
98 | }
99 |
100 | .table {
101 | @apply min-w-full divide-y divide-gray-200;
102 | }
103 |
104 | .table-header {
105 | @apply bg-gray-50;
106 | }
107 |
108 | .table-header-cell {
109 | @apply px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider;
110 | }
111 |
112 | .table-row {
113 | @apply hover:bg-gray-50;
114 | }
115 |
116 | .table-cell {
117 | @apply px-6 py-3 whitespace-nowrap text-sm text-gray-500;
118 | }
119 |
120 | /* Alerts */
121 | .alert {
122 | @apply p-3 rounded-md text-sm;
123 | }
124 |
125 | .alert-success {
126 | @apply bg-green-50 text-green-700 border border-green-100;
127 | }
128 |
129 | .alert-error {
130 | @apply bg-red-50 text-red-700 border border-red-100;
131 | }
132 |
133 | .alert-warning {
134 | @apply bg-yellow-50 text-yellow-700 border border-yellow-100;
135 | }
136 |
137 | .alert-info {
138 | @apply bg-blue-50 text-blue-700 border border-blue-100;
139 | }
140 |
141 | /* Icons */
142 | .icon {
143 | @apply w-5 h-5;
144 | }
145 |
146 | .icon-sm {
147 | @apply w-4 h-4;
148 | }
149 |
150 | /* Loading States */
151 | .loading-spinner {
152 | @apply h-8 w-8 animate-spin rounded-full border-2 border-gray-300 border-t-gray-900 dark:border-gray-600 dark:border-t-gray-300;
153 | }
154 |
155 | .loading-container {
156 | @apply flex items-center justify-center;
157 | }
158 |
159 | .loading-text {
160 | @apply text-sm text-gray-500 dark:text-gray-400;
161 | }
162 |
163 | /* Transitions */
164 | .transition-smooth {
165 | @apply transition-all duration-300 ease-in-out;
166 | }
167 |
168 | /* Card */
169 | .card {
170 | @apply bg-white rounded-lg border border-gray-200 shadow-sm dark:bg-gray-800 dark:border-gray-700;
171 | }
172 |
173 | .card-header {
174 | @apply px-6 py-4 border-b border-gray-200 dark:border-gray-700;
175 | }
176 |
177 | .card-body {
178 | @apply p-6;
179 | }
180 |
181 | .card-footer {
182 | @apply px-6 py-4 border-t border-gray-200 dark:border-gray-700;
183 | }
184 |
185 | /* Badge */
186 | .badge {
187 | @apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
188 | }
189 |
190 | .badge-success {
191 | @apply bg-green-100 text-green-800;
192 | }
193 |
194 | .badge-error {
195 | @apply bg-red-100 text-red-800;
196 | }
197 |
198 | .badge-warning {
199 | @apply bg-yellow-100 text-yellow-800;
200 | }
201 |
202 | .badge-info {
203 | @apply bg-blue-100 text-blue-800;
204 | }
205 | }
--------------------------------------------------------------------------------
/template/app/types/markdown.ts:
--------------------------------------------------------------------------------
1 | export interface Frontmatter {
2 | title?: string
3 | date?: string
4 | author?: string
5 | avatar?: string
6 | description?: string
7 | tags?: string[]
8 | category?: string
9 | readingTime?: string
10 | }
11 |
12 | export interface ProcessedContent {
13 | frontmatter: Frontmatter | null
14 | content: string
15 | }
--------------------------------------------------------------------------------
/template/app/utils/markdown.ts:
--------------------------------------------------------------------------------
1 | import { supabase, BUCKET_NAME } from '@/lib/supabase'
2 | import yaml from 'yaml'
3 | import { Frontmatter, ProcessedContent } from '../types/markdown'
4 |
5 | export const processFrontmatter = (content: string): ProcessedContent => {
6 | const lines = content.split('\n')
7 | let inFrontmatter = false
8 | let frontmatterLines: string[] = []
9 | let contentLines: string[] = []
10 |
11 | for (const line of lines) {
12 | if (line.trim() === '---') {
13 | inFrontmatter = !inFrontmatter
14 | continue
15 | }
16 |
17 | if (inFrontmatter) {
18 | frontmatterLines.push(line)
19 | } else {
20 | contentLines.push(line)
21 | }
22 | }
23 |
24 | let frontmatter: Frontmatter | null = null
25 | if (frontmatterLines.length > 0) {
26 | try {
27 | frontmatter = yaml.parse(frontmatterLines.join('\n'))
28 | } catch (e) {
29 | console.error('Failed to parse frontmatter:', e)
30 | }
31 | }
32 |
33 | return {
34 | frontmatter,
35 | content: contentLines.join('\n')
36 | }
37 | }
38 |
39 | export const processImagePaths = (content: string, fileUrl: string): string => {
40 | const baseDir = fileUrl.substring(0, fileUrl.lastIndexOf('/'))
41 |
42 | return content.replace(
43 | /!\[([^\]]*)\]\(\.\/([^)]+)\)/g,
44 | (match, alt, relativePath) => {
45 | const fullPath = `${baseDir}/${relativePath}`
46 | const imageUrl = supabase.storage.from(BUCKET_NAME).getPublicUrl(fullPath).data?.publicUrl
47 | return ``
48 | }
49 | )
50 | }
51 |
52 | export const cleanMarkdown = (content: string): string => {
53 | return content
54 | .replace(//g, '')
55 | .replace(/<\/li>/g, '')
56 | .replace(/ /g, '---')
57 | .replace(/ /g, '---')
58 | .replace(//g, '**')
59 | .replace(/<\/strong>/g, '**')
60 | .replace(//g, '_')
61 | .replace(/<\/em>/g, '_')
62 | .replace(/&/g, '&')
63 | .replace(/</g, '<')
64 | .replace(/>/g, '>')
65 | .replace(/"/g, '"')
66 | .replace(/'/g, "'")
67 | }
--------------------------------------------------------------------------------
/template/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import { dirname } from "path";
2 | import { fileURLToPath } from "url";
3 | import { FlatCompat } from "@eslint/eslintrc";
4 |
5 | const __filename = fileURLToPath(import.meta.url);
6 | const __dirname = dirname(__filename);
7 |
8 | const compat = new FlatCompat({
9 | baseDirectory: __dirname,
10 | });
11 |
12 | const eslintConfig = [
13 | ...compat.extends("next/core-web-vitals", "next/typescript"),
14 | ];
15 |
16 | export default eslintConfig;
17 |
--------------------------------------------------------------------------------
/template/lib/supabase.ts:
--------------------------------------------------------------------------------
1 | import { createClient } from '@supabase/supabase-js'
2 |
3 | if (!process.env.NEXT_PUBLIC_SUPABASE_URL) {
4 | throw new Error('Missing env.NEXT_PUBLIC_SUPABASE_URL')
5 | }
6 | if (!process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY) {
7 | throw new Error('Missing env.NEXT_PUBLIC_SUPABASE_ANON_KEY')
8 | }
9 | if (!process.env.NEXT_PUBLIC_BUCKET_NAME) {
10 | throw new Error('Missing env.NEXT_PUBLIC_BUCKET_NAME')
11 | }
12 |
13 | export const BUCKET_NAME = process.env.NEXT_PUBLIC_BUCKET_NAME
14 |
15 | export const supabase = createClient(
16 | process.env.NEXT_PUBLIC_SUPABASE_URL,
17 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
18 | )
--------------------------------------------------------------------------------
/template/middleware.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server'
2 | import type { NextRequest } from 'next/server'
3 |
4 | export function middleware(req: NextRequest) {
5 | // Skip auth for API routes, static files, and Supabase storage operations
6 | if (
7 | req.nextUrl.pathname.startsWith('/api') ||
8 | req.nextUrl.pathname.startsWith('/_next') ||
9 | req.nextUrl.pathname.startsWith('/static') ||
10 | req.nextUrl.pathname.includes('.') ||
11 | req.nextUrl.pathname.includes('storage/v1') || // Allow Supabase storage operations
12 | req.method === 'OPTIONS' // Allow CORS preflight requests
13 | ) {
14 | return NextResponse.next()
15 | }
16 |
17 | const auth = req.headers.get('authorization')
18 |
19 | // Get credentials from environment variables
20 | const username = process.env.AUTH_USERNAME
21 | const password = process.env.AUTH_PASSWORD
22 |
23 | if (!username || !password) {
24 | console.error('Missing authentication credentials in environment variables')
25 | return new NextResponse('Server configuration error', { status: 500 })
26 | }
27 |
28 | const expected = 'Basic ' + Buffer.from(`${username}:${password}`).toString('base64')
29 |
30 | if (auth !== expected) {
31 | return new NextResponse('Unauthorized', {
32 | status: 401,
33 | headers: { 'WWW-Authenticate': 'Basic realm="Supawald Admin Area"' },
34 | })
35 | }
36 |
37 | return NextResponse.next()
38 | }
39 |
40 | export const config = {
41 | matcher: ['/((?!api|_next|static|favicon.ico).*)'],
42 | }
--------------------------------------------------------------------------------
/template/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | reactStrictMode: true,
4 | }
5 |
6 | module.exports = nextConfig
--------------------------------------------------------------------------------
/template/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "supawald",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@heroicons/react": "^2.1.1",
13 | "@supabase/supabase-js": "^2.39.3",
14 | "date-fns": "^3.3.1",
15 | "marked": "^12.0.0",
16 | "next": "14.1.0",
17 | "react": "^18",
18 | "react-dom": "^18",
19 | "react-markdown": "^10.1.0",
20 | "remark-frontmatter": "^4.0.1",
21 | "remark-gfm": "^3.0.1",
22 | "remark-parse": "^10.0.2",
23 | "unified": "^10.1.2",
24 | "vfile": "^5.3.7",
25 | "vfile-message": "^3.1.4",
26 | "yaml": "^2.3.4"
27 | },
28 | "devDependencies": {
29 | "@tailwindcss/typography": "^0.5.16",
30 | "@types/node": "^20",
31 | "@types/react": "^18",
32 | "@types/react-dom": "^18",
33 | "autoprefixer": "^10.0.1",
34 | "eslint": "^8",
35 | "eslint-config-next": "14.1.0",
36 | "postcss": "^8",
37 | "tailwindcss": "^3.3.0",
38 | "typescript": "^5"
39 | }
40 | }
--------------------------------------------------------------------------------
/template/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
--------------------------------------------------------------------------------
/template/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | const config = {
2 | plugins: ["@tailwindcss/postcss"],
3 | };
4 |
5 | export default config;
6 |
--------------------------------------------------------------------------------
/template/public/SupawaldBanner.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/StructuredLabs/supawald/3ee2414e577435dc4b7b938250a8d92018c58593/template/public/SupawaldBanner.png
--------------------------------------------------------------------------------
/template/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/StructuredLabs/supawald/3ee2414e577435dc4b7b938250a8d92018c58593/template/public/favicon.ico
--------------------------------------------------------------------------------
/template/public/images/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/StructuredLabs/supawald/3ee2414e577435dc4b7b938250a8d92018c58593/template/public/images/screenshot.png
--------------------------------------------------------------------------------
/template/public/images/viewbucket.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/StructuredLabs/supawald/3ee2414e577435dc4b7b938250a8d92018c58593/template/public/images/viewbucket.png
--------------------------------------------------------------------------------
/template/public/images/viewimage.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/StructuredLabs/supawald/3ee2414e577435dc4b7b938250a8d92018c58593/template/public/images/viewimage.png
--------------------------------------------------------------------------------
/template/public/images/viewmarkdown.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/StructuredLabs/supawald/3ee2414e577435dc4b7b938250a8d92018c58593/template/public/images/viewmarkdown.png
--------------------------------------------------------------------------------
/template/public/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/StructuredLabs/supawald/3ee2414e577435dc4b7b938250a8d92018c58593/template/public/logo.png
--------------------------------------------------------------------------------
/template/src/app/globals.css:
--------------------------------------------------------------------------------
1 | @import "tailwindcss";
2 |
3 | :root {
4 | --background: #ffffff;
5 | --foreground: #171717;
6 | }
7 |
8 | @theme inline {
9 | --color-background: var(--background);
10 | --color-foreground: var(--foreground);
11 | --font-sans: var(--font-geist-sans);
12 | --font-mono: var(--font-geist-mono);
13 | }
14 |
15 | @media (prefers-color-scheme: dark) {
16 | :root {
17 | --background: #0a0a0a;
18 | --foreground: #ededed;
19 | }
20 | }
21 |
22 | body {
23 | background: var(--background);
24 | color: var(--foreground);
25 | font-family: Arial, Helvetica, sans-serif;
26 | }
27 |
--------------------------------------------------------------------------------
/template/src/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import { Inter } from "next/font/google";
3 | import "./globals.css";
4 |
5 | const inter = Inter({
6 | subsets: ["latin"],
7 | variable: "--font-inter",
8 | });
9 |
10 | export const metadata: Metadata = {
11 | title: "Blogwald",
12 | description: "A modern blog platform built with Next.js",
13 | };
14 |
15 | export default function RootLayout({
16 | children,
17 | }: Readonly<{
18 | children: React.ReactNode;
19 | }>) {
20 | return (
21 |
22 |
23 | {children}
24 |
25 |
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/template/src/app/page.tsx:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 |
3 | export default function Home() {
4 | return (
5 |
6 |
7 |
15 |
16 |
17 | Get started by editing{" "}
18 |
19 | src/app/page.tsx
20 |
21 | .
22 |
23 |
24 | Save and see your changes instantly.
25 |
26 |
27 |
28 |
53 |
54 |
101 |
102 | );
103 | }
104 |
--------------------------------------------------------------------------------
/template/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: [
4 | './pages/**/*.{js,ts,jsx,tsx,mdx}',
5 | './components/**/*.{js,ts,jsx,tsx,mdx}',
6 | './app/**/*.{js,ts,jsx,tsx,mdx}',
7 | ],
8 | theme: {
9 | extend: {
10 | colors: {
11 | primary: {
12 | 50: '#f0fdf4',
13 | 100: '#dcfce7',
14 | 200: '#bbf7d0',
15 | 300: '#86efac',
16 | 400: '#4ade80',
17 | 500: '#22c55e',
18 | 600: '#16a34a',
19 | 700: '#15803d',
20 | 800: '#166534',
21 | 900: '#14532d',
22 | },
23 | },
24 | backgroundImage: {
25 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
26 | 'gradient-conic':
27 | 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
28 | },
29 | },
30 | },
31 | plugins: [
32 | require('@tailwindcss/typography'),
33 | ],
34 | }
--------------------------------------------------------------------------------
/template/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./*"]
23 | }
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------
/template/types/supabase.ts:
--------------------------------------------------------------------------------
1 | import { SupabaseClient } from '@supabase/supabase-js'
2 |
3 | declare global {
4 | type SupabaseSession = {
5 | user: {
6 | id: string
7 | email: string
8 | }
9 | }
10 | }
11 |
12 | export type { SupabaseSession }
--------------------------------------------------------------------------------