mirror of
https://github.com/LukeHagar/Sveltey.git
synced 2025-12-06 04:21:38 +00:00
Overhaul
This commit is contained in:
2
.env.example
Normal file
2
.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
PUBLIC_SUPABASE_URL="YOUR_SUPABASE_URL"
|
||||
PUBLIC_SUPABASE_ANON_KEY="YOUR_SUPABASE_ANON_KEY"
|
||||
6
.prettierignore
Normal file
6
.prettierignore
Normal file
@@ -0,0 +1,6 @@
|
||||
# Package Managers
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
bun.lock
|
||||
bun.lockb
|
||||
15
.prettierrc
Normal file
15
.prettierrc
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"useTabs": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.svelte",
|
||||
"options": {
|
||||
"parser": "svelte"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
39
README.md
39
README.md
@@ -1 +1,38 @@
|
||||
# Sassy
|
||||
# sv
|
||||
|
||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||
|
||||
## Creating a project
|
||||
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
|
||||
```bash
|
||||
# create a new project in the current directory
|
||||
npx sv create
|
||||
|
||||
# create a new project in my-app
|
||||
npx sv create my-app
|
||||
```
|
||||
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To create a production version of your app:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||
|
||||
191
docs/AUTHENTICATION_SETUP.md
Normal file
191
docs/AUTHENTICATION_SETUP.md
Normal file
@@ -0,0 +1,191 @@
|
||||
# SvelteKit Supabase Authentication Setup
|
||||
|
||||
This project uses Supabase for authentication with full server-side rendering (SSR) support through SvelteKit hooks.
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ Email/Password authentication
|
||||
- ✅ GitHub OAuth integration
|
||||
- ✅ Server-side authentication with `hooks.server.ts`
|
||||
- ✅ Protected routes with automatic redirects
|
||||
- ✅ Session management across client and server
|
||||
- ✅ TypeScript support with proper types
|
||||
|
||||
## Setup Instructions
|
||||
|
||||
### 1. Environment Variables
|
||||
|
||||
Create a `.env.local` file in your project root:
|
||||
|
||||
```bash
|
||||
PUBLIC_SUPABASE_URL=your_supabase_project_url
|
||||
PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
|
||||
```
|
||||
|
||||
Get these values from your Supabase project dashboard under Settings > API.
|
||||
|
||||
### 2. Supabase Configuration
|
||||
|
||||
#### Enable GitHub OAuth Provider
|
||||
|
||||
1. Go to your Supabase project dashboard
|
||||
2. Navigate to **Authentication > Providers**
|
||||
3. Enable the **GitHub** provider
|
||||
4. Add your GitHub OAuth credentials:
|
||||
- **Client ID**: From your GitHub OAuth app
|
||||
- **Client Secret**: From your GitHub OAuth app
|
||||
|
||||
#### GitHub OAuth App Setup
|
||||
|
||||
1. Go to GitHub Settings > Developer settings > OAuth Apps
|
||||
2. Click "New OAuth App"
|
||||
3. Fill in the details:
|
||||
- **Application name**: Your app name
|
||||
- **Homepage URL**: `http://localhost:5173` (for development)
|
||||
- **Authorization callback URL**: `https://your-project-ref.supabase.co/auth/v1/callback`
|
||||
4. Note the Client ID and Client Secret for Supabase configuration
|
||||
|
||||
### 3. Supabase Authentication Settings
|
||||
|
||||
In your Supabase project settings:
|
||||
|
||||
1. Go to **Authentication > Settings**
|
||||
2. Add your site URLs:
|
||||
- **Site URL**: `http://localhost:5173` (development) / `https://yourdomain.com` (production)
|
||||
- **Redirect URLs**: Add both your local and production URLs
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
### Server-Side Authentication (`hooks.server.ts`)
|
||||
|
||||
The authentication is handled through SvelteKit hooks:
|
||||
|
||||
```typescript
|
||||
// src/hooks.server.ts
|
||||
export const handle: Handle = sequence(supabase, authGuard);
|
||||
```
|
||||
|
||||
#### Features:
|
||||
- **`supabase` hook**: Creates server-side Supabase client with cookie management
|
||||
- **`authGuard` hook**: Protects routes and handles redirects
|
||||
- **Session validation**: Validates JWTs on every request
|
||||
- **Cookie management**: Automatic token refresh and cookie handling
|
||||
|
||||
### Protected Routes
|
||||
|
||||
Routes are automatically protected based on path patterns:
|
||||
|
||||
- **`/dashboard/*`**: Requires authentication, redirects to `/auth/login` if not authenticated
|
||||
- **`/auth/*`**: Redirects authenticated users to `/dashboard`
|
||||
|
||||
### Client-Side Integration (`+layout.svelte`)
|
||||
|
||||
The root layout handles client-side authentication state:
|
||||
|
||||
```typescript
|
||||
// Syncs server-side session with client-side
|
||||
$effect(() => {
|
||||
session = data.session;
|
||||
});
|
||||
|
||||
// Handles auth state changes
|
||||
supabase.auth.onAuthStateChange((event, newSession) => {
|
||||
if (newSession?.expires_at !== session?.expires_at) {
|
||||
invalidateAll();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── hooks.server.ts # Server-side authentication hooks
|
||||
├── app.d.ts # TypeScript definitions for Supabase
|
||||
├── lib/
|
||||
│ ├── index.ts # Exports (toaster)
|
||||
│ └── supabaseClient.ts # Browser Supabase client
|
||||
├── routes/
|
||||
│ ├── +layout.svelte # Root layout with auth state
|
||||
│ ├── +layout.server.ts # Server layout with session data
|
||||
│ ├── auth/
|
||||
│ │ ├── login/+page.svelte # Login page with GitHub OAuth
|
||||
│ │ ├── signup/+page.svelte # Signup page with GitHub OAuth
|
||||
│ │ └── logout/+page.server.ts # Logout action
|
||||
│ └── dashboard/
|
||||
│ ├── +layout.server.ts # Dashboard layout (protected)
|
||||
│ └── +page.svelte # Dashboard page
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Login Page Features
|
||||
|
||||
- Email/password authentication
|
||||
- GitHub OAuth login
|
||||
- Form validation and error handling
|
||||
- Loading states
|
||||
- Automatic redirect after login
|
||||
|
||||
### Signup Page Features
|
||||
|
||||
- Email/password registration
|
||||
- GitHub OAuth signup
|
||||
- Email confirmation handling
|
||||
- Error handling with toast notifications
|
||||
|
||||
### Dashboard Protection
|
||||
|
||||
The dashboard is automatically protected and will:
|
||||
- Redirect unauthenticated users to login
|
||||
- Display user information for authenticated users
|
||||
- Handle session expiration gracefully
|
||||
|
||||
## TypeScript Support
|
||||
|
||||
Full TypeScript support with proper types:
|
||||
|
||||
```typescript
|
||||
// app.d.ts
|
||||
interface Locals {
|
||||
supabase: SupabaseClient;
|
||||
safeGetSession(): Promise<{ session: Session | null; user: User | null }>;
|
||||
session: Session | null;
|
||||
user: User | null;
|
||||
}
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
1. Start your development server:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
2. Test authentication:
|
||||
- Visit `/auth/signup` to create an account
|
||||
- Try GitHub OAuth login
|
||||
- Visit `/dashboard` to see protected content
|
||||
- Test logout functionality
|
||||
|
||||
## Security Features
|
||||
|
||||
- **JWT validation**: Server-side validation of authentication tokens
|
||||
- **Automatic refresh**: Tokens are refreshed automatically
|
||||
- **Secure cookies**: HTTP-only cookies for session management
|
||||
- **Route protection**: Server-side route guards
|
||||
- **CSRF protection**: Built into Supabase auth flow
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **"Cannot redirect during render"**: Usually caused by trying to redirect in a `load` function without throwing the redirect
|
||||
2. **OAuth callback errors**: Check your GitHub OAuth app callback URL matches Supabase settings
|
||||
3. **Session not persisting**: Ensure cookies are configured correctly in `hooks.server.ts`
|
||||
|
||||
### Debug Tips
|
||||
|
||||
- Check browser Network tab for auth requests
|
||||
- Verify Supabase project settings match your configuration
|
||||
- Test OAuth flow in incognito mode to avoid cached sessions
|
||||
392
docs/BLOG_SETUP.md
Normal file
392
docs/BLOG_SETUP.md
Normal file
@@ -0,0 +1,392 @@
|
||||
# Markdown Blog Setup with MDSvex (Svelte 5 Compatible)
|
||||
|
||||
This project uses a static markdown-based blog system powered by MDSvex for SvelteKit. This implementation is optimized for Svelte 5 and uses component-based rendering instead of HTML content strings.
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ **Markdown Support**: Write posts in markdown with full syntax support
|
||||
- ✅ **Frontmatter Metadata**: YAML frontmatter for post metadata
|
||||
- ✅ **Featured Posts**: Mark posts as featured for special display
|
||||
- ✅ **Tags & Categories**: Organize posts with tags
|
||||
- ✅ **SEO Optimized**: Automatic meta tags and Open Graph support
|
||||
- ✅ **Responsive Design**: Mobile-friendly blog layout
|
||||
- ✅ **Syntax Highlighting**: Code blocks with Shiki highlighting
|
||||
- ✅ **Table of Contents**: Automatic TOC generation
|
||||
- ✅ **Svelte 5 Compatible**: Uses component-based rendering
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── lib/
|
||||
│ ├── posts/ # Markdown blog posts
|
||||
│ │ ├── getting-started.md
|
||||
│ │ ├── advanced-features.md
|
||||
│ │ └── building-integrations.md
|
||||
│ ├── components/
|
||||
│ │ └── MDXLayout.svelte # Layout for mdx content
|
||||
│ ├── blog.ts # Blog utilities and types
|
||||
│ └── blog-utils.ts # Helper functions
|
||||
├── routes/
|
||||
│ └── blog/
|
||||
│ ├── +page.svelte # Blog index page
|
||||
│ ├── +page.server.ts # Load all posts
|
||||
│ └── [slug]/
|
||||
│ ├── +page.svelte # Individual post page
|
||||
│ └── +page.server.ts # Load single post
|
||||
└── docs/
|
||||
└── BLOG_SETUP.md # This documentation
|
||||
```
|
||||
|
||||
## Creating Blog Posts
|
||||
|
||||
### 1. Create a New Markdown File
|
||||
|
||||
Create a new `.md` file in `src/lib/posts/`:
|
||||
|
||||
```bash
|
||||
touch src/lib/posts/my-new-post.md
|
||||
```
|
||||
|
||||
### 2. Add Frontmatter
|
||||
|
||||
Start your post with YAML frontmatter:
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: "My Awesome Blog Post"
|
||||
slug: "my-awesome-blog-post"
|
||||
excerpt: "A compelling description of your blog post that will appear in listings and meta tags."
|
||||
publishedAt: "2024-01-30"
|
||||
author: "Your Name"
|
||||
tags: ["tutorial", "sveltekit", "web-development"]
|
||||
featured: true
|
||||
---
|
||||
|
||||
# My Awesome Blog Post
|
||||
|
||||
Your content goes here...
|
||||
```
|
||||
|
||||
### 3. Write Your Content
|
||||
|
||||
Use standard Markdown syntax for your blog post content:
|
||||
|
||||
```markdown
|
||||
## Section Heading
|
||||
|
||||
This is a paragraph with **bold** and *italic* text.
|
||||
|
||||
### Code Examples
|
||||
|
||||
```javascript
|
||||
const example = () => {
|
||||
console.log('Hello from my blog!');
|
||||
};
|
||||
```
|
||||
|
||||
### Lists
|
||||
|
||||
- Item 1
|
||||
- Item 2
|
||||
- Item 3
|
||||
|
||||
### Links
|
||||
|
||||
Check out [SvelteKit](https://kit.svelte.dev) for more information.
|
||||
```
|
||||
|
||||
## Frontmatter Reference
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `title` | string | ✅ | Post title displayed in listings and page title |
|
||||
| `slug` | string | ✅ | URL-friendly identifier for the post |
|
||||
| `excerpt` | string | ✅ | Short description for listings and meta tags |
|
||||
| `publishedAt` | string | ✅ | Publication date in YYYY-MM-DD format |
|
||||
| `author` | string | ✅ | Author name |
|
||||
| `tags` | array | ✅ | Array of tags for categorization |
|
||||
| `featured` | boolean | ❌ | Whether to feature this post (default: false) |
|
||||
|
||||
## Svelte 5 Integration
|
||||
|
||||
### Component-Based Rendering
|
||||
|
||||
This implementation uses Svelte 5's component system. Each markdown file is compiled to a Svelte component:
|
||||
|
||||
```typescript
|
||||
export interface BlogPost {
|
||||
title: string;
|
||||
slug: string;
|
||||
excerpt: string;
|
||||
publishedAt: string;
|
||||
author: string;
|
||||
tags: string[];
|
||||
featured: boolean;
|
||||
component?: any; // The Svelte component for rendering
|
||||
}
|
||||
```
|
||||
|
||||
### Rendering Posts
|
||||
|
||||
Posts are rendered using `svelte:component`:
|
||||
|
||||
```svelte
|
||||
<!-- In the blog post page -->
|
||||
{#if post.component}
|
||||
<svelte:component this={post.component} />
|
||||
{:else}
|
||||
<p>Content not available.</p>
|
||||
{/if}
|
||||
```
|
||||
|
||||
## Blog Utilities
|
||||
|
||||
### Available Functions
|
||||
|
||||
The blog system provides several utility functions in `src/lib/blog.ts`:
|
||||
|
||||
```typescript
|
||||
// Get all published posts
|
||||
const posts = await getAllPosts();
|
||||
|
||||
// Get a specific post by slug
|
||||
const post = await getPostBySlug('my-post-slug');
|
||||
|
||||
// Get featured posts only
|
||||
const featured = await getFeaturedPosts();
|
||||
|
||||
// Get posts with a specific tag
|
||||
const tagged = await getPostsByTag('tutorial');
|
||||
|
||||
// Get all unique tags
|
||||
const tags = await getAllTags();
|
||||
```
|
||||
|
||||
### Helper Functions
|
||||
|
||||
Additional utilities in `src/lib/blog-utils.ts`:
|
||||
|
||||
```typescript
|
||||
// Generate URL-friendly slug from title
|
||||
const slug = generateSlug('My Blog Post Title');
|
||||
|
||||
// Create a new post template
|
||||
const template = createPostTemplate('New Post', 'Author Name');
|
||||
|
||||
// Validate post metadata
|
||||
const errors = validatePostMetadata(postData);
|
||||
|
||||
// Search posts
|
||||
const results = searchPosts(allPosts, 'keyword');
|
||||
|
||||
// Get related posts
|
||||
const related = getRelatedPosts(currentPost, allPosts, 3);
|
||||
```
|
||||
|
||||
## Styling and Layout
|
||||
|
||||
### Custom Prose Styles
|
||||
|
||||
The blog uses Tailwind's typography plugin with custom styling:
|
||||
|
||||
```css
|
||||
.prose {
|
||||
@apply text-surface-900-50-token max-w-none;
|
||||
}
|
||||
|
||||
.prose h1 {
|
||||
@apply text-3xl font-bold mb-6;
|
||||
}
|
||||
|
||||
.prose code {
|
||||
@apply bg-surface-200 dark:bg-surface-700 px-1 py-0.5 rounded;
|
||||
}
|
||||
```
|
||||
|
||||
### MDX Layout Component
|
||||
|
||||
The `MDXLayout.svelte` component provides consistent styling for markdown content and can be customized for your design needs.
|
||||
|
||||
## Configuration
|
||||
|
||||
### MDSvex Configuration
|
||||
|
||||
The markdown processing is configured in `svelte.config.js`:
|
||||
|
||||
```javascript
|
||||
const mdsvexOptions = {
|
||||
extensions: ['.md'],
|
||||
layout: {
|
||||
_: './src/lib/components/MDXLayout.svelte'
|
||||
},
|
||||
remarkPlugins: [remarkUnwrapImages, remarkToc, remarkAbbr],
|
||||
rehypePlugins: [rehypeSlug],
|
||||
highlight: {
|
||||
highlighter: async (code, lang) => {
|
||||
const shiki = await import('shiki');
|
||||
const highlighter = await shiki.getHighlighter({
|
||||
themes: ['github-dark', 'github-light'],
|
||||
langs: ['javascript', 'typescript', 'html', 'css', 'svelte', 'bash', 'json', 'yaml', 'python', 'rust', 'go']
|
||||
});
|
||||
|
||||
const html = highlighter.codeToHtml(code, {
|
||||
lang,
|
||||
themes: {
|
||||
light: 'github-light',
|
||||
dark: 'github-dark'
|
||||
}
|
||||
});
|
||||
|
||||
return html;
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### Available Plugins
|
||||
|
||||
- **remark-unwrap-images**: Removes paragraph wrappers around images
|
||||
- **remark-toc**: Generates table of contents from headings
|
||||
- **remark-abbr**: Processes abbreviation definitions
|
||||
- **rehype-slug**: Adds IDs to headings for anchor links
|
||||
- **shiki**: Syntax highlighting for code blocks
|
||||
|
||||
## SEO Features
|
||||
|
||||
### Automatic Meta Tags
|
||||
|
||||
Each blog post automatically generates:
|
||||
|
||||
- Page title with post title
|
||||
- Meta description from excerpt
|
||||
- Open Graph tags for social sharing
|
||||
- Article metadata (published date, author, tags)
|
||||
|
||||
### Example Generated Meta Tags
|
||||
|
||||
```html
|
||||
<title>My Blog Post | Blog</title>
|
||||
<meta name="description" content="Post excerpt here" />
|
||||
<meta property="og:title" content="My Blog Post" />
|
||||
<meta property="og:description" content="Post excerpt here" />
|
||||
<meta property="og:type" content="article" />
|
||||
<meta property="article:published_time" content="2024-01-30" />
|
||||
<meta property="article:author" content="Author Name" />
|
||||
<meta property="article:tag" content="tutorial" />
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### 1. Create New Post
|
||||
|
||||
```bash
|
||||
# Create new post file
|
||||
touch src/lib/posts/my-new-post.md
|
||||
|
||||
# Add frontmatter and content
|
||||
# The post will automatically appear in the blog
|
||||
```
|
||||
|
||||
### 2. Preview Posts
|
||||
|
||||
Posts are automatically available at:
|
||||
- Blog index: `/blog`
|
||||
- Individual post: `/blog/your-post-slug`
|
||||
|
||||
### 3. Manage Content
|
||||
|
||||
- Posts are automatically sorted by publication date
|
||||
- Only posts with `publishedAt` dates in the past are shown
|
||||
- Featured posts appear in a special section
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Custom Components in Markdown
|
||||
|
||||
You can use Svelte components in your markdown:
|
||||
|
||||
```markdown
|
||||
<script>
|
||||
import MyComponent from '$lib/components/MyComponent.svelte';
|
||||
</script>
|
||||
|
||||
# My Post
|
||||
|
||||
Here's a custom component:
|
||||
|
||||
<MyComponent prop="value" />
|
||||
```
|
||||
|
||||
### Dynamic Imports
|
||||
|
||||
Posts are loaded dynamically using Vite's `import.meta.glob()`:
|
||||
|
||||
```typescript
|
||||
const allPostFiles = import.meta.glob('/src/lib/posts/*.md');
|
||||
```
|
||||
|
||||
### Build-Time Processing
|
||||
|
||||
All markdown processing happens at build time, ensuring:
|
||||
- Fast page loads
|
||||
- SEO-friendly static HTML
|
||||
- No runtime markdown parsing overhead
|
||||
|
||||
## Svelte 5 Compatibility
|
||||
|
||||
### Key Differences from Previous Versions
|
||||
|
||||
1. **Component-based rendering**: Instead of HTML strings, posts are Svelte components
|
||||
2. **No server-side rendering of content**: Content is rendered client-side as components
|
||||
3. **Simplified architecture**: No need to handle HTML content strings
|
||||
|
||||
### Migration from HTML-based Systems
|
||||
|
||||
If migrating from an HTML-content based blog:
|
||||
|
||||
1. Update blog post interface to use `component` instead of `content`
|
||||
2. Replace `{@html post.content}` with `<svelte:component this={post.component} />`
|
||||
3. Remove any server-side HTML rendering logic
|
||||
|
||||
## Deployment
|
||||
|
||||
The markdown blog works with any SvelteKit deployment target:
|
||||
|
||||
- **Static**: Pre-rendered at build time
|
||||
- **Server**: Rendered on demand
|
||||
- **Hybrid**: Mix of static and server rendering
|
||||
|
||||
All posts are processed at build time, so they work perfectly with static deployment.
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Consistent Naming**: Use kebab-case for slugs and filenames
|
||||
2. **Descriptive Excerpts**: Write compelling excerpts for better SEO
|
||||
3. **Meaningful Tags**: Use consistent, meaningful tags
|
||||
4. **Proper Dates**: Always use YYYY-MM-DD format for dates
|
||||
5. **Image Optimization**: Optimize images before including in posts
|
||||
6. **Internal Linking**: Use relative links for internal content
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **Post not appearing**: Check `publishedAt` date is not in the future
|
||||
2. **Styling issues**: Ensure prose classes are applied correctly
|
||||
3. **Build errors**: Validate frontmatter YAML syntax
|
||||
4. **Component not rendering**: Verify the markdown file has proper frontmatter
|
||||
|
||||
### Debug Mode
|
||||
|
||||
In development, check the browser console for any post loading errors. The system will log issues with individual posts without breaking the entire blog.
|
||||
|
||||
### Svelte 5 Specific Issues
|
||||
|
||||
- **Component rendering errors**: Check that the markdown files are properly processed by mdsvex
|
||||
- **Missing syntax highlighting**: Ensure shiki is properly configured
|
||||
- **Layout issues**: Verify the MDXLayout component is correctly set in svelte.config.js
|
||||
|
||||
---
|
||||
|
||||
This Svelte 5 compatible markdown blog system provides a powerful, flexible, and maintainable solution for content management while leveraging the full power of Svelte's component system!
|
||||
36
eslint.config.js
Normal file
36
eslint.config.js
Normal file
@@ -0,0 +1,36 @@
|
||||
import prettier from 'eslint-config-prettier';
|
||||
import js from '@eslint/js';
|
||||
import { includeIgnoreFile } from '@eslint/compat';
|
||||
import svelte from 'eslint-plugin-svelte';
|
||||
import globals from 'globals';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import ts from 'typescript-eslint';
|
||||
import svelteConfig from './svelte.config.js';
|
||||
|
||||
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
|
||||
|
||||
export default ts.config(
|
||||
includeIgnoreFile(gitignorePath),
|
||||
js.configs.recommended,
|
||||
...ts.configs.recommended,
|
||||
...svelte.configs.recommended,
|
||||
prettier,
|
||||
...svelte.configs.prettier,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: { ...globals.browser, ...globals.node }
|
||||
},
|
||||
rules: { 'no-undef': 'off' }
|
||||
},
|
||||
{
|
||||
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
extraFileExtensions: ['.svelte'],
|
||||
parser: ts.parser,
|
||||
svelteConfig
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -1,38 +0,0 @@
|
||||
# sv
|
||||
|
||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||
|
||||
## Creating a project
|
||||
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
|
||||
```bash
|
||||
# create a new project in the current directory
|
||||
npx sv create
|
||||
|
||||
# create a new project in my-app
|
||||
npx sv create my-app
|
||||
```
|
||||
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To create a production version of your app:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||
2078
my-saas-template/package-lock.json
generated
2078
my-saas-template/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"name": "my-saas-template",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^6.0.0",
|
||||
"@sveltejs/kit": "^2.16.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^6.2.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@skeletonlabs/skeleton": "^3.1.3",
|
||||
"@skeletonlabs/skeleton-svelte": "^1.2.3",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^4.1.7"
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
/* Tailwind base, components, and utilities */
|
||||
@import 'tailwindcss';
|
||||
|
||||
/* Skeleton core and base theme system */
|
||||
@import '@skeletonlabs/skeleton';
|
||||
|
||||
/* Optional: Skeleton presets (recommended by v3 docs) */
|
||||
@import '@skeletonlabs/skeleton/optional/presets';
|
||||
|
||||
/* Skeleton chosen theme (e.g., modern) */
|
||||
@import '@skeletonlabs/skeleton/themes/theme-modern.css';
|
||||
/* You can switch 'theme-modern.css' to other available themes like 'theme-cerberus.css', etc. */
|
||||
|
||||
/* Your own global styles can go here */
|
||||
|
||||
/* The @source line from docs is a comment, so it's omitted here */
|
||||
13
my-saas-template/src/app.d.ts
vendored
13
my-saas-template/src/app.d.ts
vendored
@@ -1,13 +0,0 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -1,20 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { toastStore, type ToastSettings } from '@skeletonlabs/skeleton';
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
onMount(() => {
|
||||
const flashMessage = $page.data.flash?.message;
|
||||
const flashType = $page.data.flash?.type || 'success'; // Default to success
|
||||
|
||||
if (flashMessage) {
|
||||
const t: ToastSettings = {
|
||||
message: flashMessage,
|
||||
background: flashType === 'error' ? 'variant-filled-error' : 'variant-filled-success',
|
||||
};
|
||||
toastStore.trigger(t);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- This component doesn't render anything itself, just triggers toasts -->
|
||||
@@ -1 +0,0 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
@@ -1,12 +0,0 @@
|
||||
// src/lib/supabaseClient.ts
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
|
||||
|
||||
if (!PUBLIC_SUPABASE_URL) {
|
||||
throw new Error("VITE_SUPABASE_URL is required!");
|
||||
}
|
||||
if (!PUBLIC_SUPABASE_ANON_KEY) {
|
||||
throw new Error("VITE_SUPABASE_ANON_KEY is required!");
|
||||
}
|
||||
|
||||
export const supabase = createClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY);
|
||||
9134
package-lock.json
generated
Normal file
9134
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
74
package.json
Normal file
74
package.json
Normal file
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"name": "my-saas-template",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
<<<<<<< HEAD:my-saas-template/package.json
|
||||
"@sveltejs/adapter-auto": "^6.0.0",
|
||||
"@sveltejs/kit": "^2.16.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^6.2.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"@skeletonlabs/skeleton": "^3.1.3",
|
||||
"@skeletonlabs/skeleton-svelte": "^1.2.3",
|
||||
"@tailwindcss/forms": "^0.5.10",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^4.1.7"
|
||||
=======
|
||||
"@eslint/compat": "^1.2.5",
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@floating-ui/dom": "^1.7.0",
|
||||
"@lucide/svelte": "^0.511.0",
|
||||
"@skeletonlabs/skeleton": "^3.1.3",
|
||||
"@skeletonlabs/skeleton-svelte": "^1.2.3",
|
||||
"@supabase/supabase-js": "^2.49.8",
|
||||
"@sveltejs/adapter-auto": "^6.0.0",
|
||||
"@sveltejs/kit": "^2.16.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/forms": "^0.5.9",
|
||||
"@tailwindcss/typography": "^0.5.15",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-svelte": "^3.0.0",
|
||||
"globals": "^16.0.0",
|
||||
"lucide-svelte": "^0.511.0",
|
||||
"postcss": "^8.5.3",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-svelte": "^3.3.3",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"prism-themes": "^1.9.0",
|
||||
"svelte": "^5.25.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"svelte-preprocess": "^6.0.3",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.5.0",
|
||||
"typescript-eslint": "^8.20.0",
|
||||
"vite": "^6.2.6",
|
||||
"vite-plugin-node-polyfills": "^0.23.0",
|
||||
"@supabase/ssr": "^0.6.1",
|
||||
"mdsvex": "^0.12.6",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"remark-abbr": "^1.4.2",
|
||||
"remark-toc": "^9.0.0",
|
||||
"remark-unwrap-images": "^4.0.1",
|
||||
"shiki": "^3.4.2"
|
||||
>>>>>>> 3c705b9 (Overhaul):package.json
|
||||
}
|
||||
}
|
||||
53
src/app.css
Normal file
53
src/app.css
Normal file
@@ -0,0 +1,53 @@
|
||||
<<<<<<< HEAD:my-saas-template/src/app.css
|
||||
/* Tailwind base, components, and utilities */
|
||||
@import 'tailwindcss';
|
||||
|
||||
/* Skeleton core and base theme system */
|
||||
@import '@skeletonlabs/skeleton';
|
||||
|
||||
/* Optional: Skeleton presets (recommended by v3 docs) */
|
||||
@import '@skeletonlabs/skeleton/optional/presets';
|
||||
|
||||
/* Skeleton chosen theme (e.g., modern) */
|
||||
@import '@skeletonlabs/skeleton/themes/theme-modern.css';
|
||||
/* You can switch 'theme-modern.css' to other available themes like 'theme-cerberus.css', etc. */
|
||||
|
||||
/* Your own global styles can go here */
|
||||
|
||||
/* The @source line from docs is a comment, so it's omitted here */
|
||||
=======
|
||||
@import 'tailwindcss';
|
||||
@plugin '@tailwindcss/forms';
|
||||
@plugin '@tailwindcss/typography';
|
||||
|
||||
@source '../node_modules/@skeletonlabs/skeleton-svelte/dist';
|
||||
|
||||
@import '@skeletonlabs/skeleton';
|
||||
@import '@skeletonlabs/skeleton/optional/presets';
|
||||
|
||||
/* Skeleton UI Themes */
|
||||
@import '@skeletonlabs/skeleton/themes/catppuccin';
|
||||
@import '@skeletonlabs/skeleton/themes/cerberus';
|
||||
@import '@skeletonlabs/skeleton/themes/concord';
|
||||
@import '@skeletonlabs/skeleton/themes/crimson';
|
||||
@import '@skeletonlabs/skeleton/themes/fennec';
|
||||
@import '@skeletonlabs/skeleton/themes/hamlindigo';
|
||||
@import '@skeletonlabs/skeleton/themes/legacy';
|
||||
@import '@skeletonlabs/skeleton/themes/mint';
|
||||
@import '@skeletonlabs/skeleton/themes/modern';
|
||||
@import '@skeletonlabs/skeleton/themes/mona';
|
||||
@import '@skeletonlabs/skeleton/themes/nosh';
|
||||
@import '@skeletonlabs/skeleton/themes/nouveau';
|
||||
@import '@skeletonlabs/skeleton/themes/pine';
|
||||
@import '@skeletonlabs/skeleton/themes/reign';
|
||||
@import '@skeletonlabs/skeleton/themes/rocket';
|
||||
@import '@skeletonlabs/skeleton/themes/rose';
|
||||
@import '@skeletonlabs/skeleton/themes/sahara';
|
||||
@import '@skeletonlabs/skeleton/themes/seafoam';
|
||||
@import '@skeletonlabs/skeleton/themes/terminus';
|
||||
@import '@skeletonlabs/skeleton/themes/vintage';
|
||||
@import '@skeletonlabs/skeleton/themes/vox';
|
||||
@import '@skeletonlabs/skeleton/themes/wintry';
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
>>>>>>> 3c705b9 (Overhaul):src/app.css
|
||||
24
src/app.d.ts
vendored
Normal file
24
src/app.d.ts
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
import { SupabaseClient, Session, User } from '@supabase/supabase-js';
|
||||
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
interface Locals {
|
||||
supabase: SupabaseClient;
|
||||
safeGetSession(): Promise<{ session: Session | null; user: User | null }>;
|
||||
session: Session | null;
|
||||
user: User | null;
|
||||
}
|
||||
interface PageData {
|
||||
session: Session | null;
|
||||
supabase: SupabaseClient;
|
||||
user: User | null;
|
||||
}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
39
src/app.html
Normal file
39
src/app.html
Normal file
@@ -0,0 +1,39 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
%sveltekit.head%
|
||||
<script>
|
||||
// Set theme immediately to prevent flashing
|
||||
(function() {
|
||||
const colorTheme = localStorage.getItem('colorTheme') || 'skeleton';
|
||||
const darkMode = localStorage.getItem('darkMode') !== 'false'; // default to true
|
||||
|
||||
// Handle dark mode class on html element
|
||||
if (darkMode) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
|
||||
// Apply color theme to body when it's available
|
||||
function applyColorTheme() {
|
||||
if (document.body) {
|
||||
document.body.setAttribute('data-theme', colorTheme);
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.body.setAttribute('data-theme', colorTheme);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
applyColorTheme();
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
87
src/hooks.server.ts
Normal file
87
src/hooks.server.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { createServerClient } from '@supabase/ssr';
|
||||
import { type Handle, redirect } from '@sveltejs/kit';
|
||||
import { sequence } from '@sveltejs/kit/hooks';
|
||||
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
|
||||
|
||||
const supabase: Handle = async ({ event, resolve }) => {
|
||||
/**
|
||||
* Creates a Supabase client specific to this server request.
|
||||
*
|
||||
* The Supabase client is configured to:
|
||||
* 1. Use cookies for session persistence
|
||||
* 2. Automatically refresh expired tokens
|
||||
* 3. Handle server-side authentication
|
||||
*/
|
||||
event.locals.supabase = createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
|
||||
cookies: {
|
||||
getAll() {
|
||||
return event.cookies.getAll();
|
||||
},
|
||||
setAll(cookiesToSet) {
|
||||
cookiesToSet.forEach(({ name, value, options }) => {
|
||||
event.cookies.set(name, value, { ...options, path: '/' });
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Unlike `supabase.auth.getSession()`, which returns the session _without_
|
||||
* validating the JWT, this function also calls `getUser()` to validate the
|
||||
* JWT before returning the session.
|
||||
*/
|
||||
event.locals.safeGetSession = async () => {
|
||||
const {
|
||||
data: { session }
|
||||
} = await event.locals.supabase.auth.getSession();
|
||||
if (!session) {
|
||||
return { session: null, user: null };
|
||||
}
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
error
|
||||
} = await event.locals.supabase.auth.getUser();
|
||||
if (error) {
|
||||
// JWT validation has failed
|
||||
return { session: null, user: null };
|
||||
}
|
||||
|
||||
return { session, user };
|
||||
};
|
||||
|
||||
return resolve(event, {
|
||||
filterSerializedResponseHeaders(name) {
|
||||
/**
|
||||
* Supabase libraries use the `content-range` and `x-supabase-api-version`
|
||||
* headers, so we need to tell SvelteKit to pass it through.
|
||||
*/
|
||||
return name === 'content-range' || name === 'x-supabase-api-version';
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const authGuard: Handle = async ({ event, resolve }) => {
|
||||
const { session, user } = await event.locals.safeGetSession();
|
||||
|
||||
event.locals.session = session;
|
||||
event.locals.user = user;
|
||||
|
||||
// Protect routes that require authentication
|
||||
if (event.url.pathname.startsWith('/app')) {
|
||||
if (!session) {
|
||||
throw redirect(303, '/auth/login');
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect authenticated users away from auth pages
|
||||
if (event.url.pathname !== "/auth/logout" && event.url.pathname.startsWith('/auth' )) {
|
||||
if (session) {
|
||||
throw redirect(303, '/app/dashboard');
|
||||
}
|
||||
}
|
||||
|
||||
return resolve(event);
|
||||
};
|
||||
|
||||
export const handle: Handle = sequence(supabase, authGuard);
|
||||
172
src/lib/blog-utils.ts
Normal file
172
src/lib/blog-utils.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import type { BlogPost } from './blog';
|
||||
|
||||
/**
|
||||
* Utility functions for blog content management
|
||||
*/
|
||||
|
||||
// Generate a URL-friendly slug from a title
|
||||
export const generateSlug = (title: string): string => {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, '') // Remove special characters
|
||||
.replace(/\s+/g, '-') // Replace spaces with hyphens
|
||||
.replace(/-+/g, '-') // Replace multiple hyphens with single
|
||||
.trim()
|
||||
.replace(/^-|-$/g, ''); // Remove leading/trailing hyphens
|
||||
};
|
||||
|
||||
// Format date for frontmatter
|
||||
export const formatDate = (date: Date): string => {
|
||||
return date.toISOString().split('T')[0];
|
||||
};
|
||||
|
||||
// Create blog post template
|
||||
export const createPostTemplate = (title: string, author: string = 'Author'): string => {
|
||||
const slug = generateSlug(title);
|
||||
const publishedAt = formatDate(new Date());
|
||||
|
||||
return `---
|
||||
title: "${title}"
|
||||
slug: "${slug}"
|
||||
excerpt: "Add a compelling excerpt for your blog post here."
|
||||
publishedAt: "${publishedAt}"
|
||||
author: "${author}"
|
||||
tags: ["tag1", "tag2"]
|
||||
featured: false
|
||||
---
|
||||
|
||||
# ${title}
|
||||
|
||||
Your blog post content goes here. You can use all standard Markdown features:
|
||||
|
||||
## Headings
|
||||
|
||||
### Subheadings
|
||||
|
||||
#### Smaller headings
|
||||
|
||||
## Lists
|
||||
|
||||
- Bullet point 1
|
||||
- Bullet point 2
|
||||
- Bullet point 3
|
||||
|
||||
1. Numbered item 1
|
||||
2. Numbered item 2
|
||||
3. Numbered item 3
|
||||
|
||||
## Code Examples
|
||||
|
||||
\`\`\`javascript
|
||||
// JavaScript code example
|
||||
const example = () => {
|
||||
console.log('Hello, world!');
|
||||
};
|
||||
\`\`\`
|
||||
|
||||
## Links and Images
|
||||
|
||||
[Link to external site](https://example.com)
|
||||
|
||||
## Blockquotes
|
||||
|
||||
> This is a blockquote. Use it for highlighting important information or quotes.
|
||||
|
||||
## Tables
|
||||
|
||||
| Column 1 | Column 2 | Column 3 |
|
||||
|----------|----------|----------|
|
||||
| Data 1 | Data 2 | Data 3 |
|
||||
| Data 4 | Data 5 | Data 6 |
|
||||
|
||||
## Conclusion
|
||||
|
||||
Wrap up your blog post with a compelling conclusion.
|
||||
`;
|
||||
};
|
||||
|
||||
// Validate blog post metadata
|
||||
export const validatePostMetadata = (metadata: Partial<BlogPost>): string[] => {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!metadata.title?.trim()) {
|
||||
errors.push('Title is required');
|
||||
}
|
||||
|
||||
if (!metadata.slug?.trim()) {
|
||||
errors.push('Slug is required');
|
||||
}
|
||||
|
||||
if (!metadata.excerpt?.trim()) {
|
||||
errors.push('Excerpt is required');
|
||||
}
|
||||
|
||||
if (!metadata.publishedAt?.trim()) {
|
||||
errors.push('Published date is required');
|
||||
} else {
|
||||
const date = new Date(metadata.publishedAt);
|
||||
if (isNaN(date.getTime())) {
|
||||
errors.push('Published date must be a valid date');
|
||||
}
|
||||
}
|
||||
|
||||
if (!metadata.author?.trim()) {
|
||||
errors.push('Author is required');
|
||||
}
|
||||
|
||||
if (!metadata.tags || !Array.isArray(metadata.tags) || metadata.tags.length === 0) {
|
||||
errors.push('At least one tag is required');
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
// Search posts by keyword
|
||||
export const searchPosts = (posts: BlogPost[], keyword: string): BlogPost[] => {
|
||||
const searchTerm = keyword.toLowerCase();
|
||||
|
||||
return posts.filter(post =>
|
||||
post.title.toLowerCase().includes(searchTerm) ||
|
||||
post.excerpt.toLowerCase().includes(searchTerm) ||
|
||||
post.tags.some(tag => tag.toLowerCase().includes(searchTerm)) ||
|
||||
post.author.toLowerCase().includes(searchTerm)
|
||||
);
|
||||
};
|
||||
|
||||
// Group posts by year
|
||||
export const groupPostsByYear = (posts: BlogPost[]): Record<string, BlogPost[]> => {
|
||||
return posts.reduce((groups, post) => {
|
||||
const year = new Date(post.publishedAt).getFullYear().toString();
|
||||
if (!groups[year]) {
|
||||
groups[year] = [];
|
||||
}
|
||||
groups[year].push(post);
|
||||
return groups;
|
||||
}, {} as Record<string, BlogPost[]>);
|
||||
};
|
||||
|
||||
// Get related posts based on tags
|
||||
export const getRelatedPosts = (currentPost: BlogPost, allPosts: BlogPost[], limit: number = 3): BlogPost[] => {
|
||||
const related = allPosts
|
||||
.filter(post => post.slug !== currentPost.slug)
|
||||
.map(post => {
|
||||
const commonTags = post.tags.filter(tag => currentPost.tags.includes(tag));
|
||||
return { post, score: commonTags.length };
|
||||
})
|
||||
.filter(item => item.score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, limit)
|
||||
.map(item => item.post);
|
||||
|
||||
// If we don't have enough related posts, fill with recent posts
|
||||
if (related.length < limit) {
|
||||
const recentPosts = allPosts
|
||||
.filter(post => post.slug !== currentPost.slug)
|
||||
.filter(post => !related.includes(post))
|
||||
.slice(0, limit - related.length);
|
||||
|
||||
related.push(...recentPosts);
|
||||
}
|
||||
|
||||
return related;
|
||||
};
|
||||
85
src/lib/blog.ts
Normal file
85
src/lib/blog.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { dev } from '$app/environment';
|
||||
|
||||
export interface BlogPost {
|
||||
title: string;
|
||||
slug: string;
|
||||
excerpt: string;
|
||||
publishedAt: string;
|
||||
author: string;
|
||||
tags: string[];
|
||||
featured: boolean;
|
||||
}
|
||||
|
||||
// Get all blog post files
|
||||
const allPostFiles = import.meta.glob('/src/lib/posts/*.md');
|
||||
|
||||
// Parse frontmatter and extract metadata only
|
||||
const parsePostMetadata = async (filename: string, module: any): Promise<BlogPost> => {
|
||||
const postModule = await module();
|
||||
const { metadata } = postModule;
|
||||
|
||||
// Extract slug from filename if not provided in frontmatter
|
||||
const slug = metadata.slug || filename.split('/').pop()?.replace('.md', '') || '';
|
||||
|
||||
return {
|
||||
title: metadata.title || 'Untitled',
|
||||
slug,
|
||||
excerpt: metadata.excerpt || '',
|
||||
publishedAt: metadata.publishedAt || new Date().toISOString().split('T')[0],
|
||||
author: metadata.author || 'Anonymous',
|
||||
tags: metadata.tags || [],
|
||||
featured: metadata.featured || false
|
||||
};
|
||||
};
|
||||
|
||||
// Get all posts metadata only
|
||||
export const getAllPosts = async (): Promise<BlogPost[]> => {
|
||||
const posts: BlogPost[] = [];
|
||||
|
||||
for (const [filename, module] of Object.entries(allPostFiles)) {
|
||||
try {
|
||||
const post = await parsePostMetadata(filename, module);
|
||||
// Only include posts with valid publish dates
|
||||
if (post.publishedAt && new Date(post.publishedAt) <= new Date()) {
|
||||
posts.push(post);
|
||||
}
|
||||
} catch (error) {
|
||||
if (dev) {
|
||||
console.error(`Error loading post ${filename}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by publication date (newest first)
|
||||
return posts.sort((a, b) => new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime());
|
||||
};
|
||||
|
||||
// Get a single post metadata by slug
|
||||
export const getPostBySlug = async (slug: string): Promise<BlogPost | null> => {
|
||||
const posts = await getAllPosts();
|
||||
return posts.find(post => post.slug === slug) || null;
|
||||
};
|
||||
|
||||
// Get featured posts metadata only
|
||||
export const getFeaturedPosts = async (): Promise<BlogPost[]> => {
|
||||
const posts = await getAllPosts();
|
||||
return posts.filter(post => post.featured);
|
||||
};
|
||||
|
||||
// Get posts by tag metadata only
|
||||
export const getPostsByTag = async (tag: string): Promise<BlogPost[]> => {
|
||||
const posts = await getAllPosts();
|
||||
return posts.filter(post => post.tags.includes(tag));
|
||||
};
|
||||
|
||||
// Get all unique tags
|
||||
export const getAllTags = async (): Promise<string[]> => {
|
||||
const posts = await getAllPosts();
|
||||
const tagSet = new Set<string>();
|
||||
|
||||
posts.forEach(post => {
|
||||
post.tags.forEach(tag => tagSet.add(tag));
|
||||
});
|
||||
|
||||
return Array.from(tagSet).sort();
|
||||
};
|
||||
80
src/lib/components/MDSvex/Boinger.svelte
Normal file
80
src/lib/components/MDSvex/Boinger.svelte
Normal file
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import { flip } from 'svelte/animate';
|
||||
import { crossfade, scale } from 'svelte/transition';
|
||||
|
||||
export let color = 'pink';
|
||||
|
||||
// @ts-expect-error - scale is a valid fallback
|
||||
const [send, receive] = crossfade({ fallback: scale });
|
||||
|
||||
let boingers = [
|
||||
{ val: 1, boinged: true },
|
||||
{ val: 2, boinged: true },
|
||||
{ val: 3, boinged: false },
|
||||
{ val: 4, boinged: true },
|
||||
{ val: 5, boinged: false }
|
||||
];
|
||||
|
||||
function toggleBoing(id: number) {
|
||||
const index = boingers.findIndex((v) => v.val === id);
|
||||
boingers[index].boinged = !boingers[index].boinged;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container">
|
||||
<div class="boingers">
|
||||
{#each boingers.filter((v) => !v.boinged) as { val } (val)}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<button
|
||||
animate:flip
|
||||
in:receive={{ key: val }}
|
||||
out:send={{ key: val }}
|
||||
style="background:{color};"
|
||||
onclick={() => toggleBoing(val)}>{val}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="boingers">
|
||||
{#each boingers.filter((v) => v.boinged) as { val } (val)}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<button
|
||||
animate:flip
|
||||
in:receive={{ key: val }}
|
||||
out:send={{ key: val }}
|
||||
style="background:{color};"
|
||||
onclick={() => toggleBoing(val)}
|
||||
>
|
||||
{val}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
width: 300px;
|
||||
height: 200px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.boingers {
|
||||
display: grid;
|
||||
grid-template-rows: repeat(3, 1fr);
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
grid-gap: 10px;
|
||||
}
|
||||
|
||||
.boingers button {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: #eee;
|
||||
font-weight: bold;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
41
src/lib/components/MDSvex/Count.svelte
Normal file
41
src/lib/components/MDSvex/Count.svelte
Normal file
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
|
||||
let {count = 0}: {count: number} = $props();
|
||||
</script>
|
||||
|
||||
<span class="outer">
|
||||
<button onclick="{() => count = count - 1}">-</button>
|
||||
<span class="inner">{count}</span>
|
||||
<button onclick="{() => count = count + 1}">+</button>
|
||||
</span>
|
||||
|
||||
<style>
|
||||
.outer {
|
||||
background: darkorange;
|
||||
height: 20px;
|
||||
font-size: 12px;
|
||||
display: inline-flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
transform: translateY(-1px);
|
||||
margin: 0 5px;
|
||||
border-radius: 3px;
|
||||
width: 65px;
|
||||
box-shadow: 0 3px 15px 1px rgba(0,0,0,0.3)
|
||||
}
|
||||
|
||||
.inner {
|
||||
margin: 0 0px;
|
||||
}
|
||||
|
||||
button {
|
||||
height: 20px;
|
||||
padding: 0px 7px 1px 7px;
|
||||
margin: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: #eee;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
3
src/lib/components/MDSvex/Section.md
Normal file
3
src/lib/components/MDSvex/Section.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# What i wrote last week
|
||||
|
||||
Why am i so smart, how is this possible.
|
||||
28
src/lib/components/MDSvex/Seriously.svelte
Normal file
28
src/lib/components/MDSvex/Seriously.svelte
Normal file
@@ -0,0 +1,28 @@
|
||||
<script lang="ts">
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<div>
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
div {
|
||||
background: pink;
|
||||
border: 23px solid orange;
|
||||
padding: 0 15px;
|
||||
width: 400px;
|
||||
text-align: center;
|
||||
transform: translateX(-200px);
|
||||
animation: 2s slide infinite alternate ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes slide {
|
||||
from {
|
||||
transform: translateX(-200px);
|
||||
}
|
||||
to {
|
||||
transform: translateX(200px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
0
src/lib/components/Navigation.svelte
Normal file
0
src/lib/components/Navigation.svelte
Normal file
192
src/lib/components/ThemeSwitch.svelte
Normal file
192
src/lib/components/ThemeSwitch.svelte
Normal file
@@ -0,0 +1,192 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { browser } from '$app/environment';
|
||||
import { Palette } from 'lucide-svelte';
|
||||
|
||||
// Available color themes
|
||||
const colorThemes = [
|
||||
{ value: 'catppuccin', label: 'Catppuccin' },
|
||||
{ value: 'cerberus', label: 'Cerberus' },
|
||||
{ value: 'concord', label: 'Concord' },
|
||||
{ value: 'crimson', label: 'Crimson' },
|
||||
{ value: 'fennec', label: 'Fennec' },
|
||||
{ value: 'hamlindigo', label: 'Hamlindigo' },
|
||||
{ value: 'legacy', label: 'Legacy' },
|
||||
{ value: 'mint', label: 'Mint' },
|
||||
{ value: 'modern', label: 'Modern' },
|
||||
{ value: 'mona', label: 'Mona' },
|
||||
{ value: 'nosh', label: 'Nosh' },
|
||||
{ value: 'nouveau', label: 'Nouveau' },
|
||||
{ value: 'pine', label: 'Pine' },
|
||||
{ value: 'reign', label: 'Reign' },
|
||||
{ value: 'rocket', label: 'Rocket' },
|
||||
{ value: 'rose', label: 'Rose' },
|
||||
{ value: 'sahara', label: 'Sahara' },
|
||||
{ value: 'seafoam', label: 'Seafoam' },
|
||||
{ value: 'terminus', label: 'Terminus' },
|
||||
{ value: 'vintage', label: 'Vintage' },
|
||||
{ value: 'vox', label: 'Vox' },
|
||||
{ value: 'wintry', label: 'Wintry' }
|
||||
];
|
||||
|
||||
let currentColorTheme = $state('skeleton');
|
||||
let isDarkMode = $state(true);
|
||||
let mounted = $state(false);
|
||||
let showDropdown = $state(false);
|
||||
|
||||
// Load theme from localStorage on mount
|
||||
onMount(() => {
|
||||
if (browser) {
|
||||
const savedColorTheme = localStorage.getItem('colorTheme') || 'skeleton';
|
||||
const savedDarkMode = localStorage.getItem('darkMode') !== 'false'; // default to true
|
||||
|
||||
currentColorTheme = savedColorTheme;
|
||||
isDarkMode = savedDarkMode;
|
||||
applyTheme(savedColorTheme, savedDarkMode);
|
||||
mounted = true;
|
||||
}
|
||||
});
|
||||
|
||||
function applyTheme(colorTheme: string, darkMode: boolean) {
|
||||
if (browser) {
|
||||
// Handle dark mode class on html element
|
||||
if (darkMode) {
|
||||
document.documentElement.classList.add('dark');
|
||||
} else {
|
||||
document.documentElement.classList.remove('dark');
|
||||
}
|
||||
|
||||
// Apply color theme to body
|
||||
document.body.setAttribute('data-theme', colorTheme);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDarkMode() {
|
||||
isDarkMode = !isDarkMode;
|
||||
|
||||
if (browser) {
|
||||
localStorage.setItem('darkMode', isDarkMode.toString());
|
||||
applyTheme(currentColorTheme, isDarkMode);
|
||||
}
|
||||
}
|
||||
|
||||
function selectColorTheme(theme: string) {
|
||||
currentColorTheme = theme;
|
||||
showDropdown = false;
|
||||
|
||||
if (browser) {
|
||||
localStorage.setItem('colorTheme', theme);
|
||||
applyTheme(theme, isDarkMode);
|
||||
}
|
||||
}
|
||||
|
||||
// Set default theme immediately if not set
|
||||
$effect(() => {
|
||||
if (browser && !mounted) {
|
||||
const savedColorTheme = localStorage.getItem('colorTheme');
|
||||
const savedDarkMode = localStorage.getItem('darkMode');
|
||||
|
||||
if (!savedColorTheme) {
|
||||
localStorage.setItem('colorTheme', 'skeleton');
|
||||
}
|
||||
if (!savedDarkMode) {
|
||||
localStorage.setItem('darkMode', 'true');
|
||||
}
|
||||
|
||||
if (!savedColorTheme || !savedDarkMode) {
|
||||
applyTheme(savedColorTheme || 'skeleton', savedDarkMode !== 'false');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
if (!target.closest('.theme-dropdown')) {
|
||||
showDropdown = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (browser) {
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
return () => document.removeEventListener('click', handleClickOutside);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex items-center space-x-3">
|
||||
<!-- Light/Dark Mode Toggle -->
|
||||
<button
|
||||
onclick={toggleDarkMode}
|
||||
class="btn btn-sm preset-outlined-surface-500 flex items-center h-8"
|
||||
title="Toggle light/dark mode"
|
||||
aria-label="Toggle light/dark mode"
|
||||
>
|
||||
<svg class="size-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
{#if !isDarkMode}
|
||||
<!-- Sun icon for dark mode (click to go light) -->
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
/>
|
||||
{:else}
|
||||
<!-- Moon icon for light mode (click to go dark) -->
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
|
||||
/>
|
||||
{/if}
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Color Theme Selector -->
|
||||
<div class="theme-dropdown relative">
|
||||
<button
|
||||
onclick={() => (showDropdown = !showDropdown)}
|
||||
class="btn btn-sm preset-outlined-surface-500 h-8 flex items-center"
|
||||
title="Select color theme"
|
||||
aria-label="Select color theme"
|
||||
aria-expanded={showDropdown}
|
||||
>
|
||||
<Palette class="size-4" />
|
||||
<span class="text-sm capitalize">{currentColorTheme}</span>
|
||||
<svg
|
||||
class="ml-1 h-3 w-3 transition-transform {showDropdown ? 'rotate-180' : ''}"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{#if showDropdown}
|
||||
<div
|
||||
class="card preset-outlined-primary-500 bg-surface-50-950 absolute left-0 z-50 mt-2 w-48 overflow-hidden rounded-lg shadow-lg"
|
||||
>
|
||||
{#each colorThemes as theme}
|
||||
<button
|
||||
onclick={() => selectColorTheme(theme.value)}
|
||||
class="hover:bg-surface-950-50 hover:text-surface-50-950 flex w-full items-center justify-between px-4 py-2 text-left text-sm transition-colors"
|
||||
>
|
||||
<span>{theme.label}</span>
|
||||
{#if currentColorTheme === theme.value}
|
||||
<svg class="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
5
src/lib/index.ts
Normal file
5
src/lib/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
|
||||
import { createToaster } from '@skeletonlabs/skeleton-svelte';
|
||||
|
||||
export const toaster = createToaster();
|
||||
345
src/lib/posts/getting-started-with-our-saas-template.md
Normal file
345
src/lib/posts/getting-started-with-our-saas-template.md
Normal file
@@ -0,0 +1,345 @@
|
||||
---
|
||||
title: "Getting Started with Our SaaS Template"
|
||||
slug: "getting-started-with-our-saas-template"
|
||||
excerpt: "Discover how our complete SvelteKit & Supabase SaaS template can accelerate your development process from idea to production in minutes."
|
||||
publishedAt: "2025-05-27"
|
||||
author: "Luke Hagar"
|
||||
tags: ["template", "sveltekit", "supabase", "saas", "startup"]
|
||||
featured: true
|
||||
---
|
||||
|
||||
# Getting Started with Our SaaS Template
|
||||
|
||||
Ready to launch your next SaaS project without the months of boilerplate development? Our comprehensive SvelteKit & Supabase template provides everything you need to go from idea to production-ready application in minutes, not months.
|
||||
|
||||
## What's Included
|
||||
|
||||
Our template comes packed with essential features that every modern SaaS needs:
|
||||
|
||||
- **Complete Authentication System** - GitHub OAuth, email/password, and session management
|
||||
- **Beautiful UI Components** - Built with Skeleton UI and fully responsive
|
||||
- **Database Integration** - Supabase configured with TypeScript types
|
||||
- **Pricing & Billing Ready** - [Pricing page](/pricing) structure ready for Stripe integration
|
||||
- **Blog System** - MDX-powered blog with syntax highlighting
|
||||
- **Dark/Light Mode** - Multiple theme options for better user experience
|
||||
- **TypeScript Throughout** - Fully type-safe development experience
|
||||
|
||||
## Quick Start Guide
|
||||
|
||||
Getting started with our template is incredibly simple:
|
||||
|
||||
### Step 1: Clone and Install
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/your-org/sassy-template.git
|
||||
cd sassy-template
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
```
|
||||
|
||||
### Step 2: Environment Setup
|
||||
|
||||
Create your `.env.local` file with your Supabase credentials:
|
||||
|
||||
```env
|
||||
PUBLIC_SUPABASE_URL=your_supabase_project_url
|
||||
PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key
|
||||
SUPABASE_SERVICE_ROLE_KEY=your_service_role_key
|
||||
```
|
||||
|
||||
### Step 3: Start Development
|
||||
|
||||
```bash
|
||||
# Start the development server
|
||||
npm run dev
|
||||
```
|
||||
|
||||
That's it! Your SaaS application is now running locally with:
|
||||
- Authentication pages at `/auth/signup` and `/auth/login`
|
||||
- A protected dashboard at `/dashboard`
|
||||
- [Pricing page](/pricing) ready for customization
|
||||
- Blog system accessible at `/blog`
|
||||
|
||||
## Key Features Walkthrough
|
||||
|
||||
### 🔐 Authentication System
|
||||
|
||||
Our template includes a complete authentication system powered by Supabase:
|
||||
|
||||
```ts
|
||||
// Example: Checking user session
|
||||
import { supabase } from '$lib/supabaseClient';
|
||||
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (session) {
|
||||
// User is authenticated
|
||||
console.log('Welcome,', session.user.email);
|
||||
}
|
||||
```
|
||||
|
||||
The authentication system includes:
|
||||
- GitHub OAuth integration (easily extensible to other providers)
|
||||
- Email/password authentication
|
||||
- Password reset functionality
|
||||
- Protected routes with automatic redirects
|
||||
- Server-side session management
|
||||
|
||||
### 🎨 Modern UI with Skeleton
|
||||
|
||||
Built on top of Skeleton UI, our template provides:
|
||||
- 10+ beautiful theme options
|
||||
- Responsive design out of the box
|
||||
- Dark/light mode toggle
|
||||
- Consistent design system
|
||||
- Accessible components
|
||||
|
||||
```svelte
|
||||
<!-- Example: Using template components -->
|
||||
<div class="card preset-outlined-surface-200-800 p-8">
|
||||
<h3 class="h3">Feature Card</h3>
|
||||
<p class="opacity-75">Beautiful, consistent styling throughout.</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 💳 Pricing Structure
|
||||
|
||||
Our [pricing page](/pricing) demonstrates a complete pricing structure with:
|
||||
- Three-tier pricing model (Starter, Pro, Enterprise)
|
||||
- Feature comparison tables
|
||||
- Call-to-action buttons
|
||||
- FAQ section
|
||||
- Contact forms for enterprise inquiries
|
||||
|
||||
The pricing structure is easily customizable and ready for payment integration:
|
||||
|
||||
```typescript
|
||||
// Example: Pricing configuration
|
||||
const plans = [
|
||||
{
|
||||
name: 'Starter',
|
||||
price: 0,
|
||||
features: ['Up to 3 projects', 'Basic analytics', 'Community support']
|
||||
},
|
||||
{
|
||||
name: 'Pro',
|
||||
price: 19,
|
||||
features: ['Unlimited projects', 'Advanced analytics', 'Priority support']
|
||||
}
|
||||
// ... more plans
|
||||
];
|
||||
```
|
||||
|
||||
### 📊 Supabase Integration
|
||||
|
||||
The template comes with Supabase fully configured:
|
||||
|
||||
```typescript
|
||||
// Real-time subscriptions example
|
||||
import { supabase } from '$lib/supabaseClient';
|
||||
|
||||
const channel = supabase
|
||||
.channel('realtime-posts')
|
||||
.on('postgres_changes',
|
||||
{ event: '*', schema: 'public', table: 'posts' },
|
||||
(payload) => {
|
||||
console.log('Change received!', payload);
|
||||
}
|
||||
)
|
||||
.subscribe();
|
||||
```
|
||||
|
||||
Features include:
|
||||
- Row-level security (RLS) policies
|
||||
- Real-time subscriptions
|
||||
- File storage configuration
|
||||
- Database migrations
|
||||
- TypeScript type generation
|
||||
|
||||
### 📝 Blog System
|
||||
|
||||
Our MDX-powered blog system supports:
|
||||
- Markdown with React components
|
||||
- Syntax highlighting
|
||||
- SEO optimization
|
||||
- Tag-based categorization
|
||||
- Featured posts
|
||||
|
||||
```markdown
|
||||
<!-- Example blog post frontmatter -->
|
||||
---
|
||||
title: "Your Blog Post"
|
||||
slug: "your-blog-post"
|
||||
excerpt: "A compelling description"
|
||||
publishedAt: "2024-01-25"
|
||||
author: "Your Name"
|
||||
tags: ["saas", "tutorial"]
|
||||
featured: true
|
||||
---
|
||||
```
|
||||
|
||||
## Production Deployment
|
||||
|
||||
The template is optimized for easy deployment:
|
||||
|
||||
### Vercel Deployment
|
||||
|
||||
```bash
|
||||
# Deploy to Vercel
|
||||
npm install -g vercel
|
||||
vercel --prod
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Set these in your production environment:
|
||||
- `PUBLIC_SUPABASE_URL`
|
||||
- `PUBLIC_SUPABASE_ANON_KEY`
|
||||
- `SUPABASE_SERVICE_ROLE_KEY`
|
||||
|
||||
### Database Setup
|
||||
|
||||
1. Create your Supabase project
|
||||
2. Run the included SQL migrations
|
||||
3. Configure authentication providers
|
||||
4. Set up Row Level Security policies
|
||||
|
||||
## Customization Examples
|
||||
|
||||
### Adding Your Branding
|
||||
|
||||
```typescript
|
||||
// Update src/app.html
|
||||
<title>Your SaaS Name</title>
|
||||
<meta name="description" content="Your SaaS description" />
|
||||
|
||||
// Customize colors in tailwind.config.js
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
500: '#your-brand-color'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Extending the Pricing Model
|
||||
|
||||
```typescript
|
||||
// Add new features to pricing plans
|
||||
const plans = [
|
||||
{
|
||||
name: 'Enterprise Plus',
|
||||
price: 199,
|
||||
features: [
|
||||
'Everything in Enterprise',
|
||||
'White-label solution',
|
||||
'Custom integrations',
|
||||
'Dedicated support team'
|
||||
]
|
||||
}
|
||||
];
|
||||
```
|
||||
|
||||
### Adding New Pages
|
||||
|
||||
```svelte
|
||||
<!-- src/routes/features/+page.svelte -->
|
||||
<script lang="ts">
|
||||
import { Database, Zap, Shield } from 'lucide-svelte';
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto py-20">
|
||||
<h1 class="h1 text-center mb-12">
|
||||
Powerful <span class="text-primary-500">Features</span>
|
||||
</h1>
|
||||
<!-- Your feature content -->
|
||||
</div>
|
||||
```
|
||||
|
||||
## Performance & Best Practices
|
||||
|
||||
Our template follows SvelteKit best practices:
|
||||
|
||||
- **Server-Side Rendering (SSR)** for better SEO
|
||||
- **Progressive Enhancement** for reliability
|
||||
- **Code Splitting** for optimal loading
|
||||
- **TypeScript** for developer experience
|
||||
- **ESLint & Prettier** for code quality
|
||||
|
||||
### Monitoring and Analytics
|
||||
|
||||
Easy integration with popular services:
|
||||
|
||||
```typescript
|
||||
// Example: Adding analytics
|
||||
import { browser } from '$app/environment';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
if (browser) {
|
||||
// Google Analytics 4
|
||||
gtag('config', 'GA_TRACKING_ID', {
|
||||
page_title: $page.route.id,
|
||||
page_location: $page.url.href
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Community and Support
|
||||
|
||||
- **GitHub Repository**: Full source code with detailed README
|
||||
- **Documentation**: Comprehensive guides and examples
|
||||
- **Community Discord**: Get help from other developers
|
||||
- **Regular Updates**: New features and security patches
|
||||
|
||||
## What's Next?
|
||||
|
||||
After setting up the template:
|
||||
|
||||
1. **Customize your branding** and color scheme
|
||||
2. **Configure your database** schema in Supabase
|
||||
3. **Set up payment processing** with Stripe
|
||||
4. **Deploy to production** with Vercel or your preferred platform
|
||||
5. **Add your unique features** using our solid foundation
|
||||
|
||||
## Pricing and Licensing
|
||||
|
||||
Our template offers flexible options for every stage of your journey:
|
||||
|
||||
- **Starter**: Free for personal projects and learning
|
||||
- **Pro**: Commercial license with priority support
|
||||
- **Enterprise**: Custom solutions and white-label options
|
||||
|
||||
Visit our [pricing page](/pricing) to choose the plan that fits your needs.
|
||||
|
||||
## Get Started Today
|
||||
|
||||
Ready to accelerate your SaaS development? Our template eliminates months of boilerplate work, letting you focus on what makes your product unique.
|
||||
|
||||
```bash
|
||||
# Get started in under 5 minutes
|
||||
git clone https://github.com/your-org/sassy-template.git
|
||||
cd sassy-template
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
**What you get:**
|
||||
- ✅ Complete authentication system
|
||||
- ✅ Beautiful, responsive UI
|
||||
- ✅ Database integration
|
||||
- ✅ Payment-ready structure
|
||||
- ✅ Blog system
|
||||
- ✅ TypeScript throughout
|
||||
- ✅ Production deployment guides
|
||||
|
||||
Don't spend months building the same features every SaaS needs. Start with our proven template and ship your unique value proposition faster.
|
||||
|
||||
[Get Started Now](/auth/signup) • [View Pricing](/pricing) • [Live Demo](/)
|
||||
|
||||
---
|
||||
|
||||
*Built with ❤️ using SvelteKit, Supabase, and Skeleton UI. Join thousands of developers who trust our template to launch their SaaS projects.*
|
||||
74
src/lib/posts/svex-up-your-markdown.md
Normal file
74
src/lib/posts/svex-up-your-markdown.md
Normal file
@@ -0,0 +1,74 @@
|
||||
---
|
||||
title: "Svex up your markdown"
|
||||
slug: "svex-up-your-markdown"
|
||||
excerpt: "Markdown is pretty good but sometimes you just need more."
|
||||
publishedAt: "2025-05-27"
|
||||
author: "Luke Hagar"
|
||||
color: cadetblue
|
||||
list: [1, 2, 3, 4, "boo"]
|
||||
tags: ["mdsvex", "ftw", "rofl"]
|
||||
---
|
||||
|
||||
<script>
|
||||
import Boinger from '$lib/components/MDSvex/Boinger.svelte';
|
||||
import Section from '$lib/components/MDSvex/Section.md';
|
||||
import Count from '$lib/components/MDSvex/Count.svelte';
|
||||
import Seriously from '$lib/components/MDSvex/Seriously.svelte';
|
||||
|
||||
let number = $state(45);
|
||||
</script>
|
||||
|
||||
# { title }
|
||||
|
||||
## Good stuff in your markdown
|
||||
|
||||
Markdown is pretty good but sometimes you just need more.
|
||||
|
||||
Sometimes you need a boinger like this:
|
||||
|
||||
<Boinger color="{ color }"/>
|
||||
|
||||
Not many people have a boinger right in their markdown.
|
||||
|
||||
## Markdown in your markdown
|
||||
|
||||
Sometimes what you wrote last week is so good that you just *have* to include it again.
|
||||
|
||||
I'm not gonna stand in the way of your egomania.
|
||||
>
|
||||
><Section />
|
||||
> <Count />
|
||||
>
|
||||
>— *Me, May 2019*
|
||||
|
||||
Yeah, thats right you can put wigdets in markdown (`.svx` files or otherwise). You can put markdown in widgets too.
|
||||
|
||||
<Seriously>
|
||||
|
||||
### I wasn't joking
|
||||
|
||||
```
|
||||
This is real life
|
||||
```
|
||||
|
||||
</Seriously>
|
||||
|
||||
Sometimes you need your widgets **inlined** (like this:<Count count="{number}"/>) because why shouldn't you.
|
||||
Obviously you have access to values defined in YAML (namespaced under `metadata`) and anything defined in an fenced `js exec` block can be referenced directly.
|
||||
|
||||
Normal markdown stuff works too:
|
||||
|
||||
| like | this |
|
||||
|-------|------|
|
||||
| table | here |
|
||||
|
||||
And *this* and **THIS**. And other stuff. You can also use all your favorite Svelte features, like `each` blocks:
|
||||
|
||||
<ul>
|
||||
{#each list as item}
|
||||
<li>{item}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
and all the other good Svelte stuff.
|
||||
|
||||
12
src/lib/supabaseClient.ts
Normal file
12
src/lib/supabaseClient.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// src/lib/supabaseClient.ts
|
||||
import { createBrowserClient, isBrowser, parse } from '@supabase/ssr';
|
||||
import { PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY } from '$env/static/public';
|
||||
|
||||
if (!PUBLIC_SUPABASE_URL) {
|
||||
throw new Error("PUBLIC_SUPABASE_URL is required!");
|
||||
}
|
||||
if (!PUBLIC_SUPABASE_ANON_KEY) {
|
||||
throw new Error("PUBLIC_SUPABASE_ANON_KEY is required!");
|
||||
}
|
||||
|
||||
export const supabase = createBrowserClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY);
|
||||
108
src/routes/+error.svelte
Normal file
108
src/routes/+error.svelte
Normal file
@@ -0,0 +1,108 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { AlertTriangle, Home, RefreshCw, ArrowLeft } from 'lucide-svelte';
|
||||
|
||||
$: status = $page.status;
|
||||
$: message = $page.error?.message;
|
||||
|
||||
const getErrorInfo = (status: number) => {
|
||||
switch (status) {
|
||||
case 404:
|
||||
return {
|
||||
title: 'Page Not Found',
|
||||
description: 'The page you\'re looking for doesn\'t exist or has been moved.',
|
||||
icon: AlertTriangle,
|
||||
color: 'text-warning-500'
|
||||
};
|
||||
case 500:
|
||||
return {
|
||||
title: 'Server Error',
|
||||
description: 'Something went wrong on our end. Please try again later.',
|
||||
icon: AlertTriangle,
|
||||
color: 'text-error-500'
|
||||
};
|
||||
case 403:
|
||||
return {
|
||||
title: 'Access Forbidden',
|
||||
description: 'You don\'t have permission to access this page.',
|
||||
icon: AlertTriangle,
|
||||
color: 'text-error-500'
|
||||
};
|
||||
default:
|
||||
return {
|
||||
title: 'Something went wrong',
|
||||
description: 'An unexpected error occurred. Please try again.',
|
||||
icon: AlertTriangle,
|
||||
color: 'text-error-500'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
$: errorInfo = getErrorInfo(status);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Error {status} - Something went wrong</title>
|
||||
<meta name="description" content="An error occurred while loading the page. Please try again or return to the homepage." />
|
||||
</svelte:head>
|
||||
|
||||
<div class="container mx-auto py-20">
|
||||
<div class="max-w-2xl mx-auto text-center space-y-8">
|
||||
<!-- Error Icon and Status -->
|
||||
<div class="space-y-4">
|
||||
<svelte:component this={errorInfo.icon} class="size-20 mx-auto {errorInfo.color}" />
|
||||
<div class="space-y-2">
|
||||
<h1 class="h1">
|
||||
<span class="text-7xl font-bold {errorInfo.color}">{status}</span>
|
||||
</h1>
|
||||
<h2 class="h2">{errorInfo.title}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Description -->
|
||||
<div class="card preset-outlined-surface-200-800 p-8 space-y-4">
|
||||
<p class="text-lg opacity-75">{errorInfo.description}</p>
|
||||
{#if message}
|
||||
<div class="card preset-outlined-error-500 p-4">
|
||||
<p class="text-error-600 dark:text-error-400 text-sm font-mono">{message}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<button
|
||||
onclick={() => window.history.back()}
|
||||
class="btn preset-outlined-surface-200-800 flex items-center gap-2"
|
||||
>
|
||||
<ArrowLeft class="size-4" />
|
||||
<span>Go Back</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={() => window.location.reload()}
|
||||
class="btn preset-outlined-surface-200-800 flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw class="size-4" />
|
||||
<span>Try Again</span>
|
||||
</button>
|
||||
|
||||
<a href="/" class="btn preset-filled-primary-500 flex items-center gap-2">
|
||||
<Home class="size-4" />
|
||||
<span>Go Home</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Help Section -->
|
||||
<div class="text-sm opacity-50 space-y-2">
|
||||
<p>Still having trouble? Here are some helpful links:</p>
|
||||
<div class="flex items-center justify-center gap-4">
|
||||
<a href="/blog" class="hover:opacity-75 transition-opacity">Blog</a>
|
||||
<span>•</span>
|
||||
<a href="/contact" class="hover:opacity-75 transition-opacity">Contact Support</a>
|
||||
<span>•</span>
|
||||
<a href="/help" class="hover:opacity-75 transition-opacity">Help Center</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
12
src/routes/+layout.server.ts
Normal file
12
src/routes/+layout.server.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// src/routes/+layout.server.ts
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals, cookies }) => {
|
||||
const { session, user } = await locals.safeGetSession();
|
||||
|
||||
return {
|
||||
session,
|
||||
user,
|
||||
cookies: cookies.getAll()
|
||||
};
|
||||
};
|
||||
234
src/routes/+layout.svelte
Normal file
234
src/routes/+layout.svelte
Normal file
@@ -0,0 +1,234 @@
|
||||
<script lang="ts">
|
||||
import { invalidate } from '$app/navigation';
|
||||
import { toaster } from '$lib';
|
||||
import ThemeSwitch from '$lib/components/ThemeSwitch.svelte';
|
||||
import { Avatar, Modal, Toaster } from '@skeletonlabs/skeleton-svelte';
|
||||
import { BookOpen, DollarSign, Home, LayoutDashboard, LogOut, User } from 'lucide-svelte';
|
||||
import 'prism-themes/themes/prism-vsc-dark-plus.css';
|
||||
import { onMount } from 'svelte';
|
||||
import '../app.css';
|
||||
|
||||
let { data, children } = $props();
|
||||
let { session, supabase } = $derived(data);
|
||||
|
||||
onMount(() => {
|
||||
// Sync client-side session with server-side on mount
|
||||
const { data } = supabase.auth.onAuthStateChange((event, newSession) => {
|
||||
if (newSession?.expires_at !== session?.expires_at) {
|
||||
invalidate('supabase:auth');
|
||||
}
|
||||
});
|
||||
|
||||
return () => data.subscription.unsubscribe();
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Skeleton Toasts and Modals -->
|
||||
<Toaster {toaster}></Toaster>
|
||||
<Modal />
|
||||
|
||||
<!-- Navigation Header -->
|
||||
<header class="bg-surface-50-950-token border-surface-200-700-token border-b">
|
||||
<nav class="container mx-auto px-6 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<!-- Left side - Brand and Main navigation -->
|
||||
<div class="flex items-center space-x-8">
|
||||
<!-- Brand -->
|
||||
<a href="/" class="flex items-center gap-2 transition-opacity hover:opacity-75">
|
||||
<div class="bg-primary-500 flex h-8 w-8 items-center justify-center rounded-lg">
|
||||
<span class="text-lg font-bold text-white">S</span>
|
||||
</div>
|
||||
<span class="text-xl font-bold">Sassy</span>
|
||||
</a>
|
||||
|
||||
<!-- Main Navigation -->
|
||||
<div class="hidden items-center space-x-1 md:flex">
|
||||
<a href="/" class="btn preset-ghost-surface-200-800 flex items-center gap-2">
|
||||
<Home class="size-4" />
|
||||
<span>Home</span>
|
||||
</a>
|
||||
<a href="/pricing" class="btn preset-ghost-surface-200-800 flex items-center gap-2">
|
||||
<DollarSign class="size-4" />
|
||||
<span>Pricing</span>
|
||||
</a>
|
||||
<a href="/blog" class="btn preset-ghost-surface-200-800 flex items-center gap-2">
|
||||
<BookOpen class="size-4" />
|
||||
<span>Blog</span>
|
||||
</a>
|
||||
{#if session}
|
||||
<a href="/dashboard" class="btn preset-ghost-surface-200-800 flex items-center gap-2">
|
||||
<LayoutDashboard class="size-4" />
|
||||
<span>Dashboard</span>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right side - User actions and theme switcher -->
|
||||
<div class="flex items-center space-x-3">
|
||||
<ThemeSwitch />
|
||||
|
||||
{#if session}
|
||||
<!-- User Profile Section -->
|
||||
<div class="border-surface-300-600-token flex items-center gap-3 border-l pl-3">
|
||||
<Avatar
|
||||
size="size-8"
|
||||
src={session.user.user_metadata.avatar_url}
|
||||
name={session.user.user_metadata.full_name || session.user.email}
|
||||
/>
|
||||
<div class="hidden md:block">
|
||||
<p class="text-sm font-medium">
|
||||
{session.user.user_metadata.full_name || session.user.email?.split('@')[0]}
|
||||
</p>
|
||||
<p class="text-xs opacity-75">{session.user.email}</p>
|
||||
</div>
|
||||
<form action="/auth/logout" method="POST" style="display: inline;">
|
||||
<button
|
||||
type="submit"
|
||||
class="btn preset-outlined-surface-200-800 btn-sm flex items-center gap-2"
|
||||
title="Sign Out"
|
||||
>
|
||||
<LogOut class="size-4" />
|
||||
<span class="hidden sm:inline">Sign Out</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Authentication Buttons -->
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="/auth" class="btn preset-outlined-surface-500 h-8 flex items-center gap-2">
|
||||
<User class="size-4" />
|
||||
<span>Sign In</span>
|
||||
</a>
|
||||
<a href="/auth?mode=signup" class="btn preset-filled-primary-500 flex items-center gap-2">
|
||||
<span>Get Started</span>
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Navigation Menu (Hidden by default, would need JS to toggle) -->
|
||||
<div class="border-surface-300-600-token mt-4 border-t pt-4 md:hidden">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<a href="/" class="btn preset-ghost-surface-200-800 flex items-center justify-start gap-2">
|
||||
<Home class="size-4" />
|
||||
<span>Home</span>
|
||||
</a>
|
||||
<a
|
||||
href="/pricing"
|
||||
class="btn preset-ghost-surface-200-800 flex items-center justify-start gap-2"
|
||||
>
|
||||
<DollarSign class="size-4" />
|
||||
<span>Pricing</span>
|
||||
</a>
|
||||
<a
|
||||
href="/blog"
|
||||
class="btn preset-ghost-surface-200-800 flex items-center justify-start gap-2"
|
||||
>
|
||||
<BookOpen class="size-4" />
|
||||
<span>Blog</span>
|
||||
</a>
|
||||
{#if session}
|
||||
<a
|
||||
href="/dashboard"
|
||||
class="btn preset-ghost-surface-200-800 flex items-center justify-start gap-2"
|
||||
>
|
||||
<LayoutDashboard class="size-4" />
|
||||
<span>Dashboard</span>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="min-h-screen">
|
||||
{@render children()}
|
||||
<!-- Pass session to child pages -->
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="bg-surface-100-850-token border-surface-200-700-token mt-20 border-t">
|
||||
<div class="container mx-auto px-6 py-12">
|
||||
<div class="grid grid-cols-1 gap-8 md:grid-cols-4">
|
||||
<!-- Brand Column -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="bg-primary-500 flex h-8 w-8 items-center justify-center rounded-lg">
|
||||
<span class="text-lg font-bold text-white">S</span>
|
||||
</div>
|
||||
<span class="text-xl font-bold">Sassy</span>
|
||||
</div>
|
||||
<p class="text-sm opacity-75">
|
||||
The complete SvelteKit & Supabase SaaS template. Launch your next project in minutes, not
|
||||
months.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Product Links -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="font-semibold">Product</h3>
|
||||
<div class="space-y-2 text-sm">
|
||||
<a href="/pricing" class="block opacity-75 transition-opacity hover:opacity-100"
|
||||
>Pricing</a
|
||||
>
|
||||
<a href="/blog" class="block opacity-75 transition-opacity hover:opacity-100">Blog</a>
|
||||
<a href="/dashboard" class="block opacity-75 transition-opacity hover:opacity-100"
|
||||
>Dashboard</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Support Links -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="font-semibold">Support</h3>
|
||||
<div class="space-y-2 text-sm">
|
||||
<a href="/docs" class="block opacity-75 transition-opacity hover:opacity-100"
|
||||
>Documentation</a
|
||||
>
|
||||
<a href="/contact" class="block opacity-75 transition-opacity hover:opacity-100"
|
||||
>Contact</a
|
||||
>
|
||||
<a href="/help" class="block opacity-75 transition-opacity hover:opacity-100"
|
||||
>Help Center</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Legal Links -->
|
||||
<div class="space-y-4">
|
||||
<h3 class="font-semibold">Legal</h3>
|
||||
<div class="space-y-2 text-sm">
|
||||
<a href="/privacy" class="block opacity-75 transition-opacity hover:opacity-100"
|
||||
>Privacy Policy</a
|
||||
>
|
||||
<a href="/terms" class="block opacity-75 transition-opacity hover:opacity-100"
|
||||
>Terms of Service</a
|
||||
>
|
||||
<a href="/cookies" class="block opacity-75 transition-opacity hover:opacity-100"
|
||||
>Cookie Policy</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer Bottom -->
|
||||
<div
|
||||
class="border-surface-300-600-token mt-8 flex flex-col items-center justify-between border-t pt-8 md:flex-row"
|
||||
>
|
||||
<p class="text-sm opacity-50">© 2025 Sassy. All rights reserved.</p>
|
||||
<div class="mt-4 flex items-center gap-4 md:mt-0">
|
||||
<span class="text-sm opacity-50">Built with</span>
|
||||
<div class="flex items-center gap-2 text-sm opacity-75">
|
||||
<span>SvelteKit</span>
|
||||
<span>•</span>
|
||||
<span>Supabase</span>
|
||||
<span>•</span>
|
||||
<span>Skeleton UI</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
43
src/routes/+layout.ts
Normal file
43
src/routes/+layout.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { createBrowserClient, createServerClient, isBrowser } from '@supabase/ssr'
|
||||
import { PUBLIC_SUPABASE_ANON_KEY, PUBLIC_SUPABASE_URL } from '$env/static/public'
|
||||
import type { LayoutLoad } from './$types'
|
||||
|
||||
export const load: LayoutLoad = async ({ data, depends, fetch }) => {
|
||||
/**
|
||||
* Declare a dependency so the layout can be invalidated, for example, on
|
||||
* session refresh.
|
||||
*/
|
||||
depends('supabase:auth')
|
||||
|
||||
const supabase = isBrowser()
|
||||
? createBrowserClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
|
||||
global: {
|
||||
fetch,
|
||||
},
|
||||
})
|
||||
: createServerClient(PUBLIC_SUPABASE_URL, PUBLIC_SUPABASE_ANON_KEY, {
|
||||
global: {
|
||||
fetch,
|
||||
},
|
||||
cookies: {
|
||||
getAll() {
|
||||
return data.cookies
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* It's fine to use `getSession` here, because on the client, `getSession` is
|
||||
* safe, and on the server, it reads `session` from the `LayoutData`, which
|
||||
* safely checked the session using `safeGetSession`.
|
||||
*/
|
||||
const {
|
||||
data: { session },
|
||||
} = await supabase.auth.getSession()
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser()
|
||||
|
||||
return { session, supabase, user }
|
||||
}
|
||||
154
src/routes/+page.svelte
Normal file
154
src/routes/+page.svelte
Normal file
@@ -0,0 +1,154 @@
|
||||
<script lang="ts">
|
||||
// Icons
|
||||
import { Rocket, Database, Zap, Star, Users, GitBranch } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
// Props are passed from the root +layout.svelte
|
||||
data: any; // Contains session from +layout.server.ts, then updated by +layout.svelte
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto py-20 space-y-20">
|
||||
<!-- Hero Section -->
|
||||
<header class="grid grid-cols-1 lg:grid-cols-[1fr_auto] gap-8 items-center">
|
||||
<div class="order-2 lg:order-1 max-w-3xl space-y-8">
|
||||
<h1 class="h1">Launch Your SaaS in <span class="text-primary-500">Minutes</span></h1>
|
||||
<p class="text-2xl opacity-75">
|
||||
The complete SvelteKit & Supabase template with authentication, payments, and beautiful UI components.
|
||||
Skip the boilerplate and focus on what matters most.
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-4">
|
||||
{#if data.session}
|
||||
<a href="/app/dashboard" class="btn btn-lg preset-filled-primary-500">
|
||||
<Rocket class="size-5" />
|
||||
<span>Go to Dashboard</span>
|
||||
</a>
|
||||
{:else}
|
||||
<a href="/auth/signup" class="btn btn-lg preset-filled-primary-500">
|
||||
<Star class="size-5" />
|
||||
<span>Get Started Free</span>
|
||||
</a>
|
||||
<a href="/auth/login" class="btn btn-lg preset-outlined-surface-200-800">
|
||||
<span>Sign In</span>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Hero Image/Graphic -->
|
||||
<div class="order-1 lg:order-2 flex justify-center">
|
||||
<div class="w-64 h-64 lg:w-96 lg:h-96 bg-gradient-to-br from-primary-500/20 to-secondary-500/20 rounded-full flex items-center justify-center shadow-2xl">
|
||||
<Rocket class="size-24 lg:size-32 text-primary-500" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<hr class="hr max-w-48" />
|
||||
|
||||
<!-- Stats Section -->
|
||||
<section>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-10 text-center md:text-left">
|
||||
<div class="space-y-1">
|
||||
<span class="font-bold text-7xl">99%</span>
|
||||
<p class="text-primary-500">Faster Development</p>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<span class="font-bold text-7xl">50+</span>
|
||||
<p class="text-primary-500">Components Ready</p>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<span class="font-bold text-7xl">100%</span>
|
||||
<p class="text-primary-500">Type Safe</p>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<span class="font-bold text-7xl">∞</span>
|
||||
<p class="text-primary-500">Possibilities</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<hr class="hr max-w-48" />
|
||||
|
||||
<!-- Features Section -->
|
||||
<section class="grid grid-cols-1 md:grid-cols-3 gap-4 lg:gap-8">
|
||||
<div class="card preset-outlined-surface-200-800 p-4 md:p-8 space-y-4">
|
||||
<Database class="stroke-primary-500 size-10" />
|
||||
<h3 class="h3">Supabase Ready</h3>
|
||||
<p class="opacity-75">
|
||||
Authentication, real-time database, and file storage configured out of the box.
|
||||
Just add your Supabase credentials and you're ready to go.
|
||||
</p>
|
||||
</div>
|
||||
<div class="card preset-outlined-surface-200-800 p-4 md:p-8 space-y-4">
|
||||
<Zap class="stroke-primary-500 size-10" />
|
||||
<h3 class="h3">Lightning Fast</h3>
|
||||
<p class="opacity-75">
|
||||
Built with SvelteKit for maximum performance. Server-side rendering,
|
||||
optimistic updates, and blazing fast navigation included.
|
||||
</p>
|
||||
</div>
|
||||
<div class="card preset-outlined-surface-200-800 p-4 md:p-8 space-y-4">
|
||||
<GitBranch class="stroke-primary-500 size-10" />
|
||||
<h3 class="h3">Developer Experience</h3>
|
||||
<p class="opacity-75">
|
||||
TypeScript, ESLint, Prettier, and comprehensive documentation.
|
||||
Built by developers, for developers with love and attention to detail.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<hr class="hr max-w-48" />
|
||||
|
||||
<!-- Technologies Section -->
|
||||
<section class="text-center space-y-8">
|
||||
<h2 class="h2">Built With Modern Tools</h2>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-8 items-center opacity-60">
|
||||
<div class="space-y-2">
|
||||
<div class="mx-auto w-16 h-16 bg-orange-500 rounded-lg flex items-center justify-center">
|
||||
<span class="text-white font-bold text-xl">S</span>
|
||||
</div>
|
||||
<p class="text-sm">SvelteKit</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="mx-auto w-16 h-16 bg-green-500 rounded-lg flex items-center justify-center">
|
||||
<span class="text-white font-bold text-xl">S</span>
|
||||
</div>
|
||||
<p class="text-sm">Supabase</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="mx-auto w-16 h-16 bg-blue-500 rounded-lg flex items-center justify-center">
|
||||
<span class="text-white font-bold text-xl">T</span>
|
||||
</div>
|
||||
<p class="text-sm">TypeScript</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="mx-auto w-16 h-16 bg-purple-500 rounded-lg flex items-center justify-center">
|
||||
<span class="text-white font-bold text-xl">S</span>
|
||||
</div>
|
||||
<p class="text-sm">Skeleton UI</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<hr class="hr max-w-48" />
|
||||
|
||||
<!-- Call to Action Section -->
|
||||
<section class="grid grid-cols-1 md:grid-cols-[1fr_auto] items-center gap-4">
|
||||
<div class="text-center md:text-left space-y-2">
|
||||
<h3 class="h3">Ready to build your next big idea?</h3>
|
||||
<p class="opacity-75">Join thousands of developers who trust our template to kickstart their SaaS projects.</p>
|
||||
</div>
|
||||
{#if !data.session}
|
||||
<a href="/auth/signup" class="btn md:btn-lg preset-filled-primary-500">
|
||||
<Users class="size-5" />
|
||||
<span>Start Building Today</span>
|
||||
</a>
|
||||
{:else}
|
||||
<a href="/pricing" class="btn md:btn-lg preset-filled-secondary-500">
|
||||
<Star class="size-5" />
|
||||
<span>View Pricing</span>
|
||||
</a>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
13
src/routes/app/dashboard/+layout.server.ts
Normal file
13
src/routes/app/dashboard/+layout.server.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
// src/routes/dashboard/+layout.server.ts
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ locals: { safeGetSession } }) => {
|
||||
// The session and authentication check is already handled in hooks.server.ts
|
||||
// This will only run if the user is authenticated due to the authGuard hook
|
||||
const { session, user } = await safeGetSession();
|
||||
|
||||
return {
|
||||
session,
|
||||
user
|
||||
};
|
||||
};
|
||||
217
src/routes/app/dashboard/+page.svelte
Normal file
217
src/routes/app/dashboard/+page.svelte
Normal file
@@ -0,0 +1,217 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
User,
|
||||
Settings,
|
||||
CreditCard,
|
||||
Key,
|
||||
BarChart,
|
||||
Shield,
|
||||
Star,
|
||||
Users,
|
||||
Zap
|
||||
} from 'lucide-svelte';
|
||||
|
||||
// The session is managed by the root layout and available through page.data
|
||||
const { data } = $props();
|
||||
|
||||
const session = $derived(data.session);
|
||||
const user = $derived(session?.user);
|
||||
|
||||
// Mock dashboard data - in a real app, this would come from your backend
|
||||
const dashboardStats = [
|
||||
{ label: 'Projects', value: '3', icon: BarChart, color: 'text-primary-500' },
|
||||
{ label: 'API Calls', value: '12.4k', icon: Zap, color: 'text-success-500' },
|
||||
{ label: 'Users', value: '24', icon: Users, color: 'text-warning-500' },
|
||||
{ label: 'Uptime', value: '99.9%', icon: Shield, color: 'text-error-500' }
|
||||
];
|
||||
|
||||
const quickActions = [
|
||||
{
|
||||
title: 'My Profile',
|
||||
description: 'View and manage your personal information and application preferences.',
|
||||
icon: User,
|
||||
action: 'Go to Profile',
|
||||
href: '/app/dashboard/profile',
|
||||
available: false
|
||||
},
|
||||
{
|
||||
title: 'Subscription',
|
||||
description: 'Check your current plan, billing history, and manage your subscription.',
|
||||
icon: CreditCard,
|
||||
action: 'Manage Subscription',
|
||||
href: '/dashboard/billing',
|
||||
available: false
|
||||
},
|
||||
{
|
||||
title: 'API Keys',
|
||||
description: 'Manage your API keys for programmatic access to our services.',
|
||||
icon: Key,
|
||||
action: 'Manage Keys',
|
||||
href: '/dashboard/api-keys',
|
||||
available: false
|
||||
},
|
||||
{
|
||||
title: 'Usage Analytics',
|
||||
description: 'View detailed usage statistics and analytics for your account.',
|
||||
icon: BarChart,
|
||||
action: 'View Analytics',
|
||||
href: '/dashboard/analytics',
|
||||
available: false
|
||||
},
|
||||
{
|
||||
title: 'Settings',
|
||||
description: 'Configure your account settings, notifications, and preferences.',
|
||||
icon: Settings,
|
||||
action: 'Open Settings',
|
||||
href: '/dashboard/settings',
|
||||
available: false
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Dashboard - Welcome Back</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Manage your account, view analytics, and access all your SaaS features from your personalized dashboard."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<div class="container mx-auto space-y-20 py-20">
|
||||
{#if session}
|
||||
<!-- Welcome Header -->
|
||||
<header class="mx-auto max-w-3xl space-y-4 text-center">
|
||||
<h1 class="h1">
|
||||
Welcome back, <span class="text-primary-500">{user?.user_metadata.full_name || 'User'}</span>!
|
||||
</h1>
|
||||
<p class="text-xl opacity-75">
|
||||
Manage your account, monitor usage, and access all your SaaS features from your personalized
|
||||
dashboard.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<hr class="hr mx-auto max-w-48" />
|
||||
|
||||
<!-- Stats Overview -->
|
||||
<section class="space-y-8">
|
||||
<h2 class="h2 text-center">Account Overview</h2>
|
||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{#each dashboardStats as stat}
|
||||
<div
|
||||
class="card preset-outlined-surface-200-800 space-y-4 p-6 text-center transition-all duration-300 hover:scale-105"
|
||||
>
|
||||
<stat.icon class={`mx-auto size-8 ${stat.color}`} />
|
||||
<div class="space-y-1">
|
||||
<div class="text-3xl font-bold">{stat.value}</div>
|
||||
<p class="text-sm opacity-75">{stat.label}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<hr class="hr mx-auto max-w-48" />
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<section class="space-y-8">
|
||||
<h2 class="h2 text-center">Quick Actions</h2>
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{#each quickActions as action}
|
||||
<div
|
||||
class="card preset-outlined-surface-200-800 group space-y-4 p-6 transition-all duration-300 hover:scale-105 md:p-8"
|
||||
>
|
||||
<action.icon
|
||||
class="text-primary-500 size-10 transition-transform group-hover:scale-110"
|
||||
/>
|
||||
<h3 class="h4 group-hover:text-primary-500 transition-colors">{action.title}</h3>
|
||||
<p class="text-sm opacity-75">{action.description}</p>
|
||||
|
||||
{#if action.available}
|
||||
<a href={action.href} class="btn preset-filled-primary-500 w-full">
|
||||
{action.action}
|
||||
</a>
|
||||
{:else}
|
||||
<button class="btn preset-outlined-surface-200-800 w-full opacity-50" disabled>
|
||||
{action.action} (Coming Soon)
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<hr class="hr mx-auto max-w-48" />
|
||||
|
||||
<!-- Account Status & Upgrade -->
|
||||
<section class="space-y-8">
|
||||
<div class="card preset-outlined-primary-500 space-y-6 p-8 text-center">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<Star class="text-primary-500 size-8" />
|
||||
<h3 class="h3 text-primary-500">Current Plan: Starter</h3>
|
||||
</div>
|
||||
<p class="mx-auto max-w-2xl opacity-75">
|
||||
You're currently on our free Starter plan. Upgrade to unlock advanced features, higher
|
||||
limits, and priority support.
|
||||
</p>
|
||||
<div class="flex flex-col justify-center gap-4 sm:flex-row">
|
||||
<a href="/pricing" class="btn preset-filled-primary-500">
|
||||
<Star class="size-5" />
|
||||
<span>Upgrade Plan</span>
|
||||
</a>
|
||||
<button class="btn preset-outlined-surface-200-800" disabled>
|
||||
<BarChart class="size-5" />
|
||||
<span>View Usage</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Recent Activity (Placeholder) -->
|
||||
<section class="space-y-8">
|
||||
<h2 class="h2 text-center">Recent Activity</h2>
|
||||
<div class="card preset-outlined-surface-200-800 space-y-4 p-8 text-center">
|
||||
<BarChart class="text-primary-500 mx-auto size-16 opacity-50" />
|
||||
<h3 class="h4">No recent activity</h3>
|
||||
<p class="opacity-75">
|
||||
Start using our services to see your activity here. Create your first project or make an
|
||||
API call to get started.
|
||||
</p>
|
||||
<div class="flex flex-col justify-center gap-4 sm:flex-row">
|
||||
<button class="btn preset-filled-primary-500" disabled>
|
||||
Create Project (Coming Soon)
|
||||
</button>
|
||||
<button class="btn preset-outlined-surface-200-800" disabled>
|
||||
View Documentation (Coming Soon)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{:else if session === null}
|
||||
<!-- Access Denied -->
|
||||
<div class="card preset-outlined-error-500 mx-auto max-w-2xl space-y-6 p-8 text-center md:p-12">
|
||||
<Shield class="text-error-500 mx-auto size-16" />
|
||||
<h2 class="h3">Access Denied</h2>
|
||||
<p class="opacity-75">
|
||||
You need to be logged in to access your dashboard. Please sign in to continue.
|
||||
</p>
|
||||
<div class="flex flex-col justify-center gap-4 sm:flex-row">
|
||||
<a href="/auth/login" class="btn preset-filled-primary-500">
|
||||
<User class="size-5" />
|
||||
<span>Sign In</span>
|
||||
</a>
|
||||
<a href="/auth/signup" class="btn preset-outlined-surface-200-800">
|
||||
<span>Create Account</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Loading State -->
|
||||
<div
|
||||
class="card preset-outlined-surface-200-800 mx-auto max-w-2xl space-y-4 p-8 text-center md:p-12"
|
||||
>
|
||||
<div class="border-primary-500 mx-auto h-12 w-12 animate-spin rounded-full border-b-2"></div>
|
||||
<h3 class="h4">Loading your dashboard...</h3>
|
||||
<p class="opacity-75">Please wait while we prepare your personalized experience.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
361
src/routes/auth/+page.svelte
Normal file
361
src/routes/auth/+page.svelte
Normal file
@@ -0,0 +1,361 @@
|
||||
<script lang="ts">
|
||||
import { supabase } from '$lib/supabaseClient';
|
||||
import { goto } from '$app/navigation';
|
||||
import { toaster } from '$lib';
|
||||
import { Mail, Lock, LogIn, UserPlus, Github, Chrome, MessageCircle, Twitter, Star, Eye, EyeOff } from 'lucide-svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let activeTab = $state('login'); // 'login' or 'signup'
|
||||
let showPassword = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
// Check URL parameters to set initial tab
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const mode = urlParams.get('mode');
|
||||
if (mode === 'signup') {
|
||||
activeTab = 'signup';
|
||||
}
|
||||
});
|
||||
|
||||
let formData = $state({
|
||||
email: '',
|
||||
password: ''
|
||||
});
|
||||
|
||||
let loading = $state(false);
|
||||
let oauthLoading = $state('');
|
||||
let message = $state('');
|
||||
|
||||
// OAuth providers configuration
|
||||
const oauthProviders = [
|
||||
{
|
||||
name: 'GitHub',
|
||||
provider: 'github',
|
||||
icon: Github,
|
||||
color: 'bg-[#333] hover:bg-[#555] text-white',
|
||||
description: 'Continue with GitHub'
|
||||
},
|
||||
{
|
||||
name: 'Google',
|
||||
provider: 'google',
|
||||
icon: Chrome,
|
||||
color: 'bg-white hover:bg-gray-50 text-gray-900 border border-gray-300',
|
||||
description: 'Continue with Google'
|
||||
},
|
||||
{
|
||||
name: 'Discord',
|
||||
provider: 'discord',
|
||||
icon: MessageCircle,
|
||||
color: 'bg-[#5865F2] hover:bg-[#4752C4] text-white',
|
||||
description: 'Continue with Discord'
|
||||
},
|
||||
{
|
||||
name: 'Twitter',
|
||||
provider: 'twitter',
|
||||
icon: Twitter,
|
||||
color: 'bg-[#1DA1F2] hover:bg-[#1A91DA] text-white',
|
||||
description: 'Continue with Twitter'
|
||||
}
|
||||
];
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
loading = true;
|
||||
message = '';
|
||||
|
||||
try {
|
||||
if (activeTab === 'login') {
|
||||
const { error } = await supabase.auth.signInWithPassword({
|
||||
email: formData.email,
|
||||
password: formData.password
|
||||
});
|
||||
if (error) throw error;
|
||||
|
||||
toaster.create({
|
||||
type: 'info',
|
||||
title: 'Welcome back!',
|
||||
description: 'You have been logged in successfully.'
|
||||
});
|
||||
} else {
|
||||
const { data, error } = await supabase.auth.signUp({
|
||||
email: formData.email,
|
||||
password: formData.password
|
||||
});
|
||||
if (error) throw error;
|
||||
|
||||
if (data.session) {
|
||||
toaster.create({
|
||||
type: 'info',
|
||||
title: 'Welcome aboard!',
|
||||
description: 'Your account has been created and you are now logged in.'
|
||||
});
|
||||
} else if (data.user && !data.session) {
|
||||
toaster.create({
|
||||
type: 'info',
|
||||
title: 'Account created successfully!',
|
||||
description: 'Please check your email to confirm your account.'
|
||||
});
|
||||
return; // Don't redirect if email confirmation is needed
|
||||
}
|
||||
}
|
||||
|
||||
goto('/dashboard');
|
||||
} catch (error: any) {
|
||||
toaster.create({
|
||||
type: 'error',
|
||||
title: activeTab === 'login' ? 'Login failed' : 'Signup failed',
|
||||
description: error.message
|
||||
});
|
||||
message = error.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOAuth(provider: string) {
|
||||
oauthLoading = provider;
|
||||
try {
|
||||
const { error } = await supabase.auth.signInWithOAuth({
|
||||
provider: provider as any,
|
||||
options: {
|
||||
redirectTo: `${window.location.origin}/dashboard`
|
||||
}
|
||||
});
|
||||
if (error) throw error;
|
||||
} catch (error: any) {
|
||||
toaster.create({
|
||||
type: 'error',
|
||||
title: `${provider} ${activeTab} failed`,
|
||||
description: error.message
|
||||
});
|
||||
} finally {
|
||||
oauthLoading = '';
|
||||
}
|
||||
}
|
||||
|
||||
function switchTab(tab: string) {
|
||||
activeTab = tab;
|
||||
message = '';
|
||||
formData = { email: '', password: '' };
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{activeTab === 'login' ? 'Sign In' : 'Create Account'} - Sassy</title>
|
||||
<meta name="description" content={activeTab === 'login'
|
||||
? 'Sign in to your account to access your dashboard and manage your projects.'
|
||||
: 'Create your account to start building with our comprehensive SaaS template.'} />
|
||||
</svelte:head>
|
||||
|
||||
<div class="container mx-auto py-20">
|
||||
<div class="max-w-md mx-auto space-y-8">
|
||||
<!-- Header -->
|
||||
<header class="text-center space-y-4">
|
||||
<div class="flex items-center justify-center gap-2 mb-4">
|
||||
{#if activeTab === 'login'}
|
||||
<LogIn class="size-8 text-primary-500" />
|
||||
<h1 class="h1">Welcome <span class="text-primary-500">Back</span></h1>
|
||||
{:else}
|
||||
<Star class="size-8 text-primary-500" />
|
||||
<h1 class="h1">Get <span class="text-primary-500">Started</span></h1>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-lg opacity-75">
|
||||
{#if activeTab === 'login'}
|
||||
Sign in to your account to access your dashboard and manage your projects.
|
||||
{:else}
|
||||
Create your account to start building with our comprehensive SaaS template.
|
||||
{/if}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- Tab Switcher -->
|
||||
<div class="card preset-outlined-primary-500 p-2">
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="btn flex-1 {activeTab === 'login' ? 'preset-filled-primary-500' : 'preset-ghost-primary-500'}"
|
||||
onclick={() => switchTab('login')}
|
||||
disabled={loading || oauthLoading !== ''}
|
||||
>
|
||||
<LogIn class="size-4" />
|
||||
Sign In
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn flex-1 {activeTab === 'signup' ? 'preset-filled-primary-500' : 'preset-ghost-primary-500'}"
|
||||
onclick={() => switchTab('signup')}
|
||||
disabled={loading || oauthLoading !== ''}
|
||||
>
|
||||
<UserPlus class="size-4" />
|
||||
Sign Up
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auth Form Card -->
|
||||
<div class="card preset-outlined-primary-500 p-8 space-y-6">
|
||||
<!-- Error Message -->
|
||||
{#if message}
|
||||
<div class="card preset-outlined-error-500 p-4 text-center">
|
||||
<p class="text-error-600 dark:text-error-400 text-sm">{message}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Email/Password Form -->
|
||||
<form onsubmit={handleSubmit} class="space-y-6">
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<label class="label font-medium" for="email">
|
||||
<Mail class="size-4 inline mr-2" />
|
||||
Email Address
|
||||
</label>
|
||||
<input
|
||||
class="input preset-outlined-primary-500"
|
||||
type="email"
|
||||
id="email"
|
||||
bind:value={formData.email}
|
||||
placeholder="Enter your email"
|
||||
required
|
||||
disabled={loading || oauthLoading !== ''}
|
||||
autocomplete="email"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="label font-medium" for="password">
|
||||
<Lock class="size-4 inline mr-2" />
|
||||
Password
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
class="input preset-outlined-primary-500 pr-10"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
id="password"
|
||||
bind:value={formData.password}
|
||||
placeholder={activeTab === 'login' ? 'Enter your password' : 'Create a strong password'}
|
||||
required
|
||||
disabled={loading || oauthLoading !== ''}
|
||||
minlength={activeTab === 'signup' ? 6 : undefined}
|
||||
autocomplete={activeTab === 'login' ? 'current-password' : 'new-password'}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-surface-500 hover:text-surface-700 dark:hover:text-surface-300"
|
||||
onclick={() => showPassword = !showPassword}
|
||||
disabled={loading || oauthLoading !== ''}
|
||||
>
|
||||
{#if showPassword}
|
||||
<EyeOff class="size-4" />
|
||||
{:else}
|
||||
<Eye class="size-4" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{#if activeTab === 'signup'}
|
||||
<p class="text-xs opacity-50">Must be at least 6 characters long</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn preset-filled-primary-500 w-full flex items-center justify-center gap-2"
|
||||
disabled={loading || oauthLoading !== ''}
|
||||
>
|
||||
{#if loading}
|
||||
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
{activeTab === 'login' ? 'Signing you in...' : 'Creating your account...'}
|
||||
{:else}
|
||||
{#if activeTab === 'login'}
|
||||
<LogIn class="size-4" />
|
||||
Sign In
|
||||
{:else}
|
||||
<UserPlus class="size-4" />
|
||||
Create Account
|
||||
{/if}
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Terms Notice for Signup -->
|
||||
{#if activeTab === 'signup'}
|
||||
<p class="text-xs opacity-50 text-center">
|
||||
By creating an account, you agree to our
|
||||
<a href="/terms" class="text-primary-500 hover:text-primary-600 transition-colors">Terms of Service</a>
|
||||
and
|
||||
<a href="/privacy" class="text-primary-500 hover:text-primary-600 transition-colors">Privacy Policy</a>.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<!-- Forgot Password for Login -->
|
||||
{#if activeTab === 'login'}
|
||||
<div class="text-center">
|
||||
<a href="/auth/reset-password" class="text-sm text-primary-500 hover:text-primary-600 transition-colors">
|
||||
Forgot your password?
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="flex items-center">
|
||||
<hr class="flex-grow opacity-30" />
|
||||
<span class="px-4 text-sm opacity-50">or continue with</span>
|
||||
<hr class="flex-grow opacity-30" />
|
||||
</div>
|
||||
|
||||
<!-- OAuth Providers -->
|
||||
<div class="space-y-3">
|
||||
{#each oauthProviders as provider}
|
||||
<button
|
||||
type="button"
|
||||
class="btn w-full flex items-center justify-center gap-3 {provider.color}"
|
||||
onclick={() => handleOAuth(provider.provider)}
|
||||
disabled={loading || oauthLoading !== ''}
|
||||
>
|
||||
{#if oauthLoading === provider.provider}
|
||||
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-current"></div>
|
||||
Connecting...
|
||||
{:else}
|
||||
<svelte:component this={provider.icon} class="size-4" />
|
||||
{provider.description}
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer Links -->
|
||||
<div class="text-center space-y-4">
|
||||
{#if activeTab === 'login'}
|
||||
<p class="text-sm opacity-75">
|
||||
Don't have an account?
|
||||
<button
|
||||
type="button"
|
||||
class="text-primary-500 hover:text-primary-600 transition-colors font-medium"
|
||||
onclick={() => switchTab('signup')}
|
||||
>
|
||||
Create one here
|
||||
</button>
|
||||
</p>
|
||||
{:else}
|
||||
<p class="text-sm opacity-75">
|
||||
Already have an account?
|
||||
<button
|
||||
type="button"
|
||||
class="text-primary-500 hover:text-primary-600 transition-colors font-medium"
|
||||
onclick={() => switchTab('login')}
|
||||
>
|
||||
Sign in here
|
||||
</button>
|
||||
</p>
|
||||
{/if}
|
||||
<div class="flex items-center justify-center gap-4 text-sm opacity-50">
|
||||
<a href="/privacy" class="hover:opacity-75 transition-opacity">Privacy Policy</a>
|
||||
<span>•</span>
|
||||
<a href="/terms" class="hover:opacity-75 transition-opacity">Terms of Service</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
5
src/routes/auth/login/+page.ts
Normal file
5
src/routes/auth/login/+page.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export function load() {
|
||||
throw redirect(302, '/auth');
|
||||
}
|
||||
10
src/routes/auth/logout/+page.server.ts
Normal file
10
src/routes/auth/logout/+page.server.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// src/routes/auth/logout/+page.server.ts
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { Actions } from './$types';
|
||||
|
||||
export const actions: Actions = {
|
||||
default: async ({ locals: { supabase } }) => {
|
||||
await supabase.auth.signOut();
|
||||
throw redirect(303, '/');
|
||||
}
|
||||
};
|
||||
136
src/routes/auth/reset-password/+page.svelte
Normal file
136
src/routes/auth/reset-password/+page.svelte
Normal file
@@ -0,0 +1,136 @@
|
||||
<script lang="ts">
|
||||
import { supabase } from '$lib/supabaseClient';
|
||||
import { toaster } from '$lib';
|
||||
import { Mail, Send, ArrowLeft, KeyRound } from 'lucide-svelte';
|
||||
|
||||
let email = $state('');
|
||||
let loading = $state(false);
|
||||
let sent = $state(false);
|
||||
|
||||
async function handleReset(e: Event) {
|
||||
e.preventDefault();
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
const { error } = await supabase.auth.resetPasswordForEmail(email, {
|
||||
redirectTo: `${window.location.origin}/auth/update-password`
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
sent = true;
|
||||
toaster.create({
|
||||
type: 'info',
|
||||
title: 'Reset email sent!',
|
||||
description: 'Check your email for password reset instructions.'
|
||||
});
|
||||
} catch (error: any) {
|
||||
toaster.create({
|
||||
type: 'error',
|
||||
title: 'Reset failed',
|
||||
description: error.message
|
||||
});
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Reset Password - Sassy</title>
|
||||
<meta name="description" content="Reset your password to regain access to your account." />
|
||||
</svelte:head>
|
||||
|
||||
<div class="container mx-auto py-20">
|
||||
<div class="max-w-md mx-auto space-y-8">
|
||||
<!-- Header -->
|
||||
<header class="text-center space-y-4">
|
||||
<div class="flex items-center justify-center gap-2 mb-4">
|
||||
<KeyRound class="size-8 text-primary-500" />
|
||||
<h1 class="h1">Reset <span class="text-primary-500">Password</span></h1>
|
||||
</div>
|
||||
<p class="text-lg opacity-75">
|
||||
Enter your email address and we'll send you a link to reset your password.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- Reset Form Card -->
|
||||
<div class="card preset-outlined-primary-500 p-8 space-y-6">
|
||||
{#if !sent}
|
||||
<form onsubmit={handleReset} class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<label class="label font-medium" for="email">
|
||||
<Mail class="size-4 inline mr-2" />
|
||||
Email Address
|
||||
</label>
|
||||
<input
|
||||
class="input preset-outlined-secondary-500"
|
||||
type="email"
|
||||
id="email"
|
||||
bind:value={email}
|
||||
placeholder="Enter your email address"
|
||||
required
|
||||
disabled={loading}
|
||||
autocomplete="email"
|
||||
/>
|
||||
<p class="text-xs opacity-75">
|
||||
We'll send password reset instructions to this email address.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn preset-filled-primary-500 w-full flex items-center justify-center gap-2"
|
||||
disabled={loading}
|
||||
>
|
||||
{#if loading}
|
||||
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
Sending reset email...
|
||||
{:else}
|
||||
<Send class="size-4" />
|
||||
Send Reset Email
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
{:else}
|
||||
<!-- Success State -->
|
||||
<div class="text-center space-y-4">
|
||||
<div class="w-16 h-16 bg-success-500 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Mail class="size-8 text-white" />
|
||||
</div>
|
||||
<h2 class="h3">Check your email</h2>
|
||||
<p class="opacity-75">
|
||||
We've sent password reset instructions to <strong>{email}</strong>
|
||||
</p>
|
||||
<p class="text-sm opacity-50">
|
||||
Didn't receive the email? Check your spam folder or try again.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="btn preset-outlined-surface-200-800 w-full"
|
||||
onclick={() => { sent = false; email = ''; }}
|
||||
>
|
||||
Try Different Email
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Back to Login -->
|
||||
<div class="text-center">
|
||||
<a href="/auth" class="btn preset-ghost-surface-200-800 flex items-center justify-center gap-2">
|
||||
<ArrowLeft class="size-4" />
|
||||
Back to Sign In
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Help Section -->
|
||||
<div class="text-center space-y-4">
|
||||
<div class="flex items-center justify-center gap-4 text-sm opacity-50">
|
||||
<a href="/contact" class="hover:opacity-75 transition-opacity">Need Help?</a>
|
||||
<span>•</span>
|
||||
<a href="/privacy" class="hover:opacity-75 transition-opacity">Privacy Policy</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
5
src/routes/auth/signup/+page.ts
Normal file
5
src/routes/auth/signup/+page.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export function load() {
|
||||
throw redirect(302, '/auth?mode=signup');
|
||||
}
|
||||
227
src/routes/auth/update-password/+page.svelte
Normal file
227
src/routes/auth/update-password/+page.svelte
Normal file
@@ -0,0 +1,227 @@
|
||||
<script lang="ts">
|
||||
import { supabase } from '$lib/supabaseClient';
|
||||
import { goto } from '$app/navigation';
|
||||
import { toaster } from '$lib';
|
||||
import { Lock, Save, Eye, EyeOff, KeyRound } from 'lucide-svelte';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let password = $state('');
|
||||
let confirmPassword = $state('');
|
||||
let loading = $state(false);
|
||||
let showPassword = $state(false);
|
||||
let showConfirmPassword = $state(false);
|
||||
let session = $state(null);
|
||||
|
||||
onMount(async () => {
|
||||
// Check if user is authenticated (came from reset link)
|
||||
const { data: { session: currentSession } } = await supabase.auth.getSession();
|
||||
session = currentSession;
|
||||
|
||||
if (!session) {
|
||||
toaster.create({
|
||||
type: 'error',
|
||||
title: 'Invalid reset link',
|
||||
description: 'Please request a new password reset link.'
|
||||
});
|
||||
goto('/auth/reset-password');
|
||||
}
|
||||
});
|
||||
|
||||
async function handleUpdatePassword(e: Event) {
|
||||
e.preventDefault();
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
toaster.create({
|
||||
type: 'error',
|
||||
title: 'Passwords don\'t match',
|
||||
description: 'Please ensure both passwords are the same.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
toaster.create({
|
||||
type: 'error',
|
||||
title: 'Password too short',
|
||||
description: 'Password must be at least 6 characters long.'
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
const { error } = await supabase.auth.updateUser({
|
||||
password: password
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
toaster.create({
|
||||
type: 'info',
|
||||
title: 'Password updated!',
|
||||
description: 'Your password has been successfully updated.'
|
||||
});
|
||||
|
||||
goto('/dashboard');
|
||||
} catch (error: any) {
|
||||
toaster.create({
|
||||
type: 'error',
|
||||
title: 'Update failed',
|
||||
description: error.message
|
||||
});
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Update Password - Sassy</title>
|
||||
<meta name="description" content="Set your new password to secure your account." />
|
||||
</svelte:head>
|
||||
|
||||
<div class="container mx-auto py-20">
|
||||
<div class="max-w-md mx-auto space-y-8">
|
||||
<!-- Header -->
|
||||
<header class="text-center space-y-4">
|
||||
<div class="flex items-center justify-center gap-2 mb-4">
|
||||
<KeyRound class="size-8 text-primary-500" />
|
||||
<h1 class="h1">Update <span class="text-primary-500">Password</span></h1>
|
||||
</div>
|
||||
<p class="text-lg opacity-75">
|
||||
Choose a strong password to secure your account.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- Update Form Card -->
|
||||
<div class="card preset-outlined-surface-200-800 p-8 space-y-6">
|
||||
<form onsubmit={handleUpdatePassword} class="space-y-6">
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<label class="label font-medium" for="password">
|
||||
<Lock class="size-4 inline mr-2" />
|
||||
New Password
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
class="input preset-outlined-surface-200-800 pr-10"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
id="password"
|
||||
bind:value={password}
|
||||
placeholder="Create a strong password"
|
||||
required
|
||||
disabled={loading}
|
||||
minlength="6"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-surface-500 hover:text-surface-700 dark:hover:text-surface-300"
|
||||
onclick={() => showPassword = !showPassword}
|
||||
disabled={loading}
|
||||
>
|
||||
{#if showPassword}
|
||||
<EyeOff class="size-4" />
|
||||
{:else}
|
||||
<Eye class="size-4" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
<p class="text-xs opacity-50">Must be at least 6 characters long</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="label font-medium" for="confirmPassword">
|
||||
<Lock class="size-4 inline mr-2" />
|
||||
Confirm New Password
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
class="input preset-outlined-surface-200-800 pr-10"
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
id="confirmPassword"
|
||||
bind:value={confirmPassword}
|
||||
placeholder="Confirm your password"
|
||||
required
|
||||
disabled={loading}
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-surface-500 hover:text-surface-700 dark:hover:text-surface-300"
|
||||
onclick={() => showConfirmPassword = !showConfirmPassword}
|
||||
disabled={loading}
|
||||
>
|
||||
{#if showConfirmPassword}
|
||||
<EyeOff class="size-4" />
|
||||
{:else}
|
||||
<Eye class="size-4" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Password Strength Indicator -->
|
||||
{#if password.length > 0}
|
||||
<div class="space-y-2">
|
||||
<p class="text-sm font-medium">Password Strength:</p>
|
||||
<div class="flex gap-1">
|
||||
<div class="h-2 flex-1 rounded {password.length >= 6 ? 'bg-success-500' : 'bg-surface-300'}"></div>
|
||||
<div class="h-2 flex-1 rounded {password.length >= 8 ? 'bg-success-500' : 'bg-surface-300'}"></div>
|
||||
<div class="h-2 flex-1 rounded {password.length >= 10 && /[A-Z]/.test(password) && /[0-9]/.test(password) ? 'bg-success-500' : 'bg-surface-300'}"></div>
|
||||
</div>
|
||||
<p class="text-xs opacity-50">
|
||||
{#if password.length < 6}
|
||||
Weak - Add more characters
|
||||
{:else if password.length < 8}
|
||||
Good - Consider adding more characters
|
||||
{:else if password.length >= 10 && /[A-Z]/.test(password) && /[0-9]/.test(password)}
|
||||
Strong - Great password!
|
||||
{:else}
|
||||
Good - Consider adding uppercase letters and numbers
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Password Match Indicator -->
|
||||
{#if confirmPassword.length > 0}
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
{#if password === confirmPassword}
|
||||
<div class="w-2 h-2 bg-success-500 rounded-full"></div>
|
||||
<span class="text-success-600 dark:text-success-400">Passwords match</span>
|
||||
{:else}
|
||||
<div class="w-2 h-2 bg-error-500 rounded-full"></div>
|
||||
<span class="text-error-600 dark:text-error-400">Passwords don't match</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn preset-filled-primary-500 w-full flex items-center justify-center gap-2"
|
||||
disabled={loading || password !== confirmPassword || password.length < 6}
|
||||
>
|
||||
{#if loading}
|
||||
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
Updating password...
|
||||
{:else}
|
||||
<Save class="size-4" />
|
||||
Update Password
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Help Section -->
|
||||
<div class="text-center space-y-4">
|
||||
<div class="flex items-center justify-center gap-4 text-sm opacity-50">
|
||||
<a href="/contact" class="hover:opacity-75 transition-opacity">Need Help?</a>
|
||||
<span>•</span>
|
||||
<a href="/privacy" class="hover:opacity-75 transition-opacity">Privacy Policy</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
12
src/routes/blog/+page.server.ts
Normal file
12
src/routes/blog/+page.server.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// src/routes/blog/+page.server.ts
|
||||
import { getAllPosts } from '$lib/blog';
|
||||
|
||||
export const load = async () => {
|
||||
try {
|
||||
const posts = await getAllPosts();
|
||||
return { posts };
|
||||
} catch (error) {
|
||||
console.error('Error loading blog posts:', error);
|
||||
return { posts: [], error: 'Failed to load blog posts.' };
|
||||
}
|
||||
};
|
||||
148
src/routes/blog/+page.svelte
Normal file
148
src/routes/blog/+page.svelte
Normal file
@@ -0,0 +1,148 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import type { BlogPost } from '$lib/blog';
|
||||
import { Calendar, User, Tag, BookOpen, Star } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
data: PageData & { posts: BlogPost[] };
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Blog - Insights & Tutorials</title>
|
||||
<meta name="description" content="Discover insights, tutorials, and updates from our team. Learn about SaaS development, best practices, and product updates." />
|
||||
</svelte:head>
|
||||
|
||||
<div class="container mx-auto py-20 space-y-20">
|
||||
<!-- Header -->
|
||||
<header class="text-center space-y-4 max-w-3xl mx-auto">
|
||||
<div class="flex items-center justify-center gap-2 mb-4">
|
||||
<BookOpen class="size-8 text-primary-500" />
|
||||
<h1 class="h1">Our <span class="text-primary-500">Blog</span></h1>
|
||||
</div>
|
||||
<p class="text-2xl opacity-75">
|
||||
Insights, tutorials, and updates from our team. Stay up to date with the latest in SaaS development and best practices.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{#if data.error}
|
||||
<div class="card preset-outlined-error-500 p-8 text-center max-w-2xl mx-auto">
|
||||
<p class="text-error-600 dark:text-error-400">{data.error}</p>
|
||||
</div>
|
||||
{:else if data.posts && data.posts.length > 0}
|
||||
<hr class="hr max-w-48 mx-auto" />
|
||||
|
||||
<!-- Featured Posts -->
|
||||
{@const featuredPosts = data.posts.filter(post => post.featured)}
|
||||
{#if featuredPosts.length > 0}
|
||||
<section class="space-y-8">
|
||||
<div class="flex items-center gap-2">
|
||||
<Star class="size-6 text-primary-500" />
|
||||
<h2 class="h2">Featured Posts</h2>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{#each featuredPosts as post}
|
||||
<article class="card preset-outlined-surface-200-800 p-6 md:p-8 space-y-4 hover:scale-105 transition-all duration-300 group">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="badge preset-filled-primary-500 flex items-center gap-1">
|
||||
<Star class="size-3" />
|
||||
Featured
|
||||
</span>
|
||||
<div class="flex items-center gap-1 text-sm opacity-75">
|
||||
<Calendar class="size-4" />
|
||||
{formatDate(post.publishedAt)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="h3 group-hover:text-primary-500 transition-colors">
|
||||
<a href="/blog/{post.slug}" class="block">
|
||||
{post.title}
|
||||
</a>
|
||||
</h3>
|
||||
|
||||
<p class="opacity-75">{post.excerpt}</p>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<User class="size-4 opacity-50" />
|
||||
<span class="text-sm opacity-75">{post.author}</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each post.tags.slice(0, 2) as tag}
|
||||
<span class="badge preset-outlined-surface-200-800 text-xs flex items-center gap-1">
|
||||
<Tag class="size-3" />
|
||||
{tag}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<hr class="hr max-w-48 mx-auto" />
|
||||
{/if}
|
||||
|
||||
<!-- All Posts -->
|
||||
{@const regularPosts = data.posts.filter(post => !post.featured)}
|
||||
{#if regularPosts.length > 0}
|
||||
<section class="space-y-8">
|
||||
<h2 class="h2 text-center">Recent Posts</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{#each regularPosts as post}
|
||||
<article class="card preset-outlined-surface-200-800 p-4 md:p-6 space-y-4 hover:scale-105 transition-all duration-300 group">
|
||||
<div class="flex items-center gap-1 text-sm opacity-75">
|
||||
<Calendar class="size-4" />
|
||||
{formatDate(post.publishedAt)}
|
||||
</div>
|
||||
|
||||
<h3 class="h4 group-hover:text-primary-500 transition-colors">
|
||||
<a href="/blog/{post.slug}" class="block">
|
||||
{post.title}
|
||||
</a>
|
||||
</h3>
|
||||
|
||||
<p class="opacity-75 text-sm">{post.excerpt}</p>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<User class="size-4 opacity-50" />
|
||||
<span class="text-sm opacity-75">{post.author}</span>
|
||||
</div>
|
||||
{#if post.tags.length > 0}
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each post.tags.slice(0, 3) as tag}
|
||||
<span class="badge preset-outlined-surface-200-800 text-xs flex items-center gap-1">
|
||||
<Tag class="size-3" />
|
||||
{tag}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="card preset-outlined-surface-200-800 p-8 md:p-12 text-center max-w-2xl mx-auto space-y-4">
|
||||
<BookOpen class="size-16 mx-auto text-primary-500 opacity-50" />
|
||||
<h2 class="h3">No blog posts yet</h2>
|
||||
<p class="opacity-75">
|
||||
We're working on some great content for you. Check back soon for insights, tutorials, and updates!
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
18
src/routes/blog/[slug]/+page.server.ts
Normal file
18
src/routes/blog/[slug]/+page.server.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// src/routes/blog/[slug]/+page.server.ts
|
||||
import { getAllPosts } from '$lib/blog';
|
||||
import { error } from '@sveltejs/kit';
|
||||
|
||||
export const load = async ({ params }) => {
|
||||
// Get only the metadata for the post
|
||||
const posts = await getAllPosts();
|
||||
const post = posts.find(p => p.slug === params.slug);
|
||||
|
||||
if (!post) {
|
||||
throw error(404, 'Blog post not found');
|
||||
}
|
||||
|
||||
return {
|
||||
post,
|
||||
slug: params.slug // Pass the slug so we can load the component client-side
|
||||
};
|
||||
};
|
||||
218
src/routes/blog/[slug]/+page.svelte
Normal file
218
src/routes/blog/[slug]/+page.svelte
Normal file
@@ -0,0 +1,218 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import type { BlogPost } from '$lib/blog';
|
||||
import { onMount } from 'svelte';
|
||||
import { Calendar, User, Tag, Share2, ArrowLeft, Star, Twitter, Linkedin } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
data: PageData & { post: BlogPost; slug: string };
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
let post = $derived(data.post);
|
||||
let component: any = $state(null);
|
||||
let loading = $state(true);
|
||||
let error = $state(false);
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
// Dynamically import the markdown component
|
||||
const module = await import(`../../../lib/posts/${data.slug}.md`);
|
||||
component = module.default;
|
||||
} catch (err) {
|
||||
console.error('Failed to load blog post component:', err);
|
||||
error = true;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{post.title} | Blog</title>
|
||||
<meta name="description" content={post.excerpt} />
|
||||
<meta property="og:title" content={post.title} />
|
||||
<meta property="og:description" content={post.excerpt} />
|
||||
<meta property="og:type" content="article" />
|
||||
<meta property="article:published_time" content={post.publishedAt} />
|
||||
<meta property="article:author" content={post.author} />
|
||||
{#each post.tags as tag}
|
||||
<meta property="article:tag" content={tag} />
|
||||
{/each}
|
||||
</svelte:head>
|
||||
|
||||
<div class="container mx-auto py-20 space-y-12 max-w-4xl">
|
||||
<!-- Breadcrumb Navigation -->
|
||||
<nav class="flex items-center gap-2 text-sm">
|
||||
<a href="/" class="hover:text-primary-500 transition-colors">Home</a>
|
||||
<span class="opacity-50">/</span>
|
||||
<a href="/blog" class="hover:text-primary-500 transition-colors">Blog</a>
|
||||
<span class="opacity-50">/</span>
|
||||
<span class="opacity-75">{post.title}</span>
|
||||
</nav>
|
||||
|
||||
<!-- Article Header -->
|
||||
<header class="space-y-8">
|
||||
<!-- Featured Badge & Meta Info -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
{#if post.featured}
|
||||
<span class="badge preset-filled-primary-500 flex items-center gap-1">
|
||||
<Star class="size-3" />
|
||||
Featured
|
||||
</span>
|
||||
{/if}
|
||||
<div class="flex items-center gap-1 text-sm opacity-75">
|
||||
<Calendar class="size-4" />
|
||||
{formatDate(post.publishedAt)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Back to Blog -->
|
||||
<a href="/blog" class="btn preset-outlined-surface-200-800 flex items-center gap-2">
|
||||
<ArrowLeft class="size-4" />
|
||||
<span>Back to Blog</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Title and Excerpt -->
|
||||
<div class="space-y-4">
|
||||
<h1 class="h1">{post.title}</h1>
|
||||
<p class="text-xl opacity-75 leading-relaxed">{post.excerpt}</p>
|
||||
</div>
|
||||
|
||||
<!-- Author and Tags -->
|
||||
<div class="card preset-outlined-surface-200-800 p-6 space-y-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<User class="size-5 text-primary-500" />
|
||||
<span class="font-medium">{post.author}</span>
|
||||
</div>
|
||||
|
||||
<!-- Share Buttons -->
|
||||
<div class="flex items-center gap-2">
|
||||
<Share2 class="size-4 opacity-50" />
|
||||
<span class="text-sm opacity-75 mr-2">Share:</span>
|
||||
<a
|
||||
href="https://twitter.com/intent/tweet?text={encodeURIComponent(post.title)}&url={encodeURIComponent(typeof window !== 'undefined' ? window.location.href : '')}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn btn-sm preset-outlined-surface-200-800"
|
||||
title="Share on Twitter"
|
||||
>
|
||||
<Twitter class="size-4" />
|
||||
</a>
|
||||
<a
|
||||
href="https://www.linkedin.com/sharing/share-offsite/?url={encodeURIComponent(typeof window !== 'undefined' ? window.location.href : '')}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn btn-sm preset-outlined-surface-200-800"
|
||||
title="Share on LinkedIn"
|
||||
>
|
||||
<Linkedin class="size-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if post.tags.length > 0}
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<Tag class="size-4 text-primary-500" />
|
||||
<span class="text-sm font-medium">Tags:</span>
|
||||
{#each post.tags as tag}
|
||||
<span class="badge preset-outlined-surface-200-800 text-xs flex items-center gap-1">
|
||||
{tag}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<hr class="hr" />
|
||||
|
||||
<!-- Article Content -->
|
||||
<article class="prose dark:prose-invert prose-lg max-w-none">
|
||||
{#if loading}
|
||||
<div class="card preset-outlined-surface-200-800 p-8 text-center space-y-4">
|
||||
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500 mx-auto"></div>
|
||||
<span class="text-sm opacity-75">Loading article content...</span>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="card preset-outlined-error-500 p-8 text-center space-y-4">
|
||||
<p class="text-error-600 dark:text-error-400">Failed to load blog post content.</p>
|
||||
<a href="/blog" class="btn preset-filled-primary-500">
|
||||
<ArrowLeft class="size-4" />
|
||||
Return to Blog
|
||||
</a>
|
||||
</div>
|
||||
{:else if component}
|
||||
<div class="prose dark:prose-invert prose-lg max-w-none prose-headings:text-primary-500 prose-links:text-primary-500 prose-code:text-primary-500">
|
||||
{@render component()}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="card preset-outlined-surface-200-800 p-8 text-center">
|
||||
<p class="opacity-75">Content not available.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</article>
|
||||
|
||||
<hr class="hr" />
|
||||
|
||||
<!-- Article Footer -->
|
||||
<footer class="space-y-8">
|
||||
<!-- Navigation -->
|
||||
<div class="flex flex-col sm:flex-row justify-between items-center gap-4">
|
||||
<a href="/blog" class="btn preset-filled-primary-500 flex items-center gap-2">
|
||||
<ArrowLeft class="size-4" />
|
||||
<span>More Articles</span>
|
||||
</a>
|
||||
|
||||
<!-- Share Section (Repeated for convenience) -->
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium opacity-75">Share this article:</span>
|
||||
<a
|
||||
href="https://twitter.com/intent/tweet?text={encodeURIComponent(post.title)}&url={encodeURIComponent(typeof window !== 'undefined' ? window.location.href : '')}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn btn-sm preset-outlined-surface-200-800"
|
||||
>
|
||||
<Twitter class="size-4" />
|
||||
<span>Twitter</span>
|
||||
</a>
|
||||
<a
|
||||
href="https://www.linkedin.com/sharing/share-offsite/?url={encodeURIComponent(typeof window !== 'undefined' ? window.location.href : '')}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn btn-sm preset-outlined-surface-200-800"
|
||||
>
|
||||
<Linkedin class="size-4" />
|
||||
<span>LinkedIn</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Call to Action -->
|
||||
<div class="card preset-outlined-primary-500 p-8 text-center space-y-4">
|
||||
<h3 class="h3 text-primary-500">Ready to get started?</h3>
|
||||
<p class="opacity-75">
|
||||
Start building your SaaS with our comprehensive template and join thousands of developers.
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<a href="/auth/signup" class="btn preset-filled-primary-500">
|
||||
Get Started Free
|
||||
</a>
|
||||
<a href="/pricing" class="btn preset-outlined-surface-200-800">
|
||||
View Pricing
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
245
src/routes/contact/+page.svelte
Normal file
245
src/routes/contact/+page.svelte
Normal file
@@ -0,0 +1,245 @@
|
||||
<script lang="ts">
|
||||
import { Mail, Phone, MapPin, Send, MessageCircle, Clock } from 'lucide-svelte';
|
||||
import { toaster } from '$lib';
|
||||
|
||||
let formData = $state({
|
||||
name: '',
|
||||
email: '',
|
||||
subject: '',
|
||||
message: ''
|
||||
});
|
||||
let loading = $state(false);
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
// Simulate API call
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
toaster.create({
|
||||
type: 'info',
|
||||
title: 'Message sent!',
|
||||
description: 'Thank you for contacting us. We\'ll get back to you soon.'
|
||||
});
|
||||
|
||||
// Reset form
|
||||
formData = {
|
||||
name: '',
|
||||
email: '',
|
||||
subject: '',
|
||||
message: ''
|
||||
};
|
||||
} catch (error) {
|
||||
toaster.create({
|
||||
type: 'error',
|
||||
title: 'Error',
|
||||
description: 'Failed to send message. Please try again.'
|
||||
});
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Contact Us - Get in Touch</title>
|
||||
<meta name="description" content="Get in touch with our team. We're here to help with any questions about our SaaS platform." />
|
||||
</svelte:head>
|
||||
|
||||
<div class="container mx-auto py-20">
|
||||
<div class="max-w-6xl mx-auto space-y-16">
|
||||
<!-- Header -->
|
||||
<header class="text-center space-y-4">
|
||||
<div class="flex items-center justify-center gap-2 mb-4">
|
||||
<MessageCircle class="size-8 text-primary-500" />
|
||||
<h1 class="h1">Get in <span class="text-primary-500">Touch</span></h1>
|
||||
</div>
|
||||
<p class="text-xl opacity-75 max-w-2xl mx-auto">
|
||||
Have questions? We'd love to hear from you. Send us a message and we'll respond as soon as possible.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-12">
|
||||
<!-- Contact Information -->
|
||||
<div class="space-y-8">
|
||||
<!-- Contact Methods -->
|
||||
<div class="space-y-6">
|
||||
<div class="card preset-outlined-surface-200-800 p-6 space-y-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-primary-500 rounded-lg flex items-center justify-center">
|
||||
<Mail class="size-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold">Email Us</h3>
|
||||
<p class="text-sm opacity-75">support@sassy.dev</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card preset-outlined-surface-200-800 p-6 space-y-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-secondary-500 rounded-lg flex items-center justify-center">
|
||||
<Phone class="size-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold">Call Us</h3>
|
||||
<p class="text-sm opacity-75">+1 (555) 123-4567</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card preset-outlined-surface-200-800 p-6 space-y-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 bg-tertiary-500 rounded-lg flex items-center justify-center">
|
||||
<MapPin class="size-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-semibold">Visit Us</h3>
|
||||
<p class="text-sm opacity-75">San Francisco, CA</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Response Time -->
|
||||
<div class="card preset-filled-primary-500 p-6 text-center">
|
||||
<Clock class="size-8 mx-auto mb-3" />
|
||||
<h3 class="h4 mb-2">Quick Response</h3>
|
||||
<p class="text-sm opacity-90">
|
||||
We typically respond within 24 hours during business days.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contact Form -->
|
||||
<div class="lg:col-span-2">
|
||||
<div class="card preset-outlined-surface-200-800 p-8">
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h2 class="h3 mb-2">Send us a message</h2>
|
||||
<p class="opacity-75">Fill out the form below and we'll get back to you.</p>
|
||||
</div>
|
||||
|
||||
<form onsubmit={handleSubmit} class="space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="space-y-2">
|
||||
<label class="label font-medium" for="name">
|
||||
Name *
|
||||
</label>
|
||||
<input
|
||||
class="input preset-outlined-surface-200-800"
|
||||
type="text"
|
||||
id="name"
|
||||
bind:value={formData.name}
|
||||
placeholder="Your name"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="label font-medium" for="email">
|
||||
Email *
|
||||
</label>
|
||||
<input
|
||||
class="input preset-outlined-surface-200-800"
|
||||
type="email"
|
||||
id="email"
|
||||
bind:value={formData.email}
|
||||
placeholder="your@email.com"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="label font-medium" for="subject">
|
||||
Subject *
|
||||
</label>
|
||||
<input
|
||||
class="input preset-outlined-surface-200-800"
|
||||
type="text"
|
||||
id="subject"
|
||||
bind:value={formData.subject}
|
||||
placeholder="What can we help you with?"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="label font-medium" for="message">
|
||||
Message *
|
||||
</label>
|
||||
<textarea
|
||||
class="textarea preset-outlined-surface-200-800"
|
||||
id="message"
|
||||
bind:value={formData.message}
|
||||
placeholder="Tell us more about your question or feedback..."
|
||||
rows="6"
|
||||
required
|
||||
disabled={loading}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="btn preset-filled-primary-500 w-full flex items-center justify-center gap-2"
|
||||
disabled={loading}
|
||||
>
|
||||
{#if loading}
|
||||
<div class="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
Sending...
|
||||
{:else}
|
||||
<Send class="size-4" />
|
||||
Send Message
|
||||
{/if}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FAQ Section -->
|
||||
<section class="space-y-8">
|
||||
<div class="text-center">
|
||||
<h2 class="h2 mb-4">Frequently Asked <span class="text-primary-500">Questions</span></h2>
|
||||
<p class="opacity-75">Quick answers to common questions.</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="card preset-outlined-surface-200-800 p-6 space-y-3">
|
||||
<h3 class="font-semibold">How can I get started?</h3>
|
||||
<p class="text-sm opacity-75">
|
||||
Simply sign up for an account and you'll have immediate access to our platform. Check out our pricing page for plan details.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card preset-outlined-surface-200-800 p-6 space-y-3">
|
||||
<h3 class="font-semibold">Do you offer technical support?</h3>
|
||||
<p class="text-sm opacity-75">
|
||||
Yes! We provide comprehensive technical support via email and our help center. Premium plans include priority support.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card preset-outlined-surface-200-800 p-6 space-y-3">
|
||||
<h3 class="font-semibold">Can I cancel my subscription anytime?</h3>
|
||||
<p class="text-sm opacity-75">
|
||||
Absolutely. You can cancel your subscription at any time from your dashboard. Your access will continue until the end of your billing period.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card preset-outlined-surface-200-800 p-6 space-y-3">
|
||||
<h3 class="font-semibold">Is there a free trial?</h3>
|
||||
<p class="text-sm opacity-75">
|
||||
Yes! We offer a 14-day free trial for all new users. No credit card required to get started.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
198
src/routes/pricing/+page.svelte
Normal file
198
src/routes/pricing/+page.svelte
Normal file
@@ -0,0 +1,198 @@
|
||||
<script lang="ts">
|
||||
// Icons
|
||||
import { Check, X, Star, Zap, Crown, Mail } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
// Assuming session might be useful here, though not directly used in this basic structure
|
||||
data: any;
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
const plans = [
|
||||
{
|
||||
name: 'Starter',
|
||||
price: 0,
|
||||
period: 'mo',
|
||||
description: 'Perfect for side projects and getting started',
|
||||
features: [
|
||||
{ name: 'Up to 3 projects', included: true },
|
||||
{ name: 'Basic analytics', included: true },
|
||||
{ name: 'Community support', included: true },
|
||||
{ name: 'Advanced integrations', included: false },
|
||||
{ name: 'Priority support', included: false },
|
||||
{ name: 'Custom domains', included: false }
|
||||
],
|
||||
popular: false,
|
||||
buttonText: data.session ? 'Current Plan' : 'Get Started',
|
||||
buttonClass: 'preset-outlined-surface-200-800'
|
||||
},
|
||||
{
|
||||
name: 'Pro',
|
||||
price: 19,
|
||||
period: 'mo',
|
||||
description: 'Best for growing businesses and teams',
|
||||
features: [
|
||||
{ name: 'Unlimited projects', included: true },
|
||||
{ name: 'Advanced analytics', included: true },
|
||||
{ name: 'Priority support', included: true },
|
||||
{ name: 'Advanced integrations', included: true },
|
||||
{ name: 'Custom domains', included: true },
|
||||
{ name: 'Team collaboration', included: false }
|
||||
],
|
||||
popular: true,
|
||||
buttonText: data.session ? 'Upgrade to Pro' : 'Start Pro Trial',
|
||||
buttonClass: 'preset-filled-primary-500'
|
||||
},
|
||||
{
|
||||
name: 'Enterprise',
|
||||
price: 99,
|
||||
period: 'mo',
|
||||
description: 'For large teams with advanced needs',
|
||||
features: [
|
||||
{ name: 'Everything in Pro', included: true },
|
||||
{ name: 'Team collaboration', included: true },
|
||||
{ name: 'Advanced security', included: true },
|
||||
{ name: 'Custom integrations', included: true },
|
||||
{ name: 'Dedicated support', included: true },
|
||||
{ name: 'SLA guarantee', included: true }
|
||||
],
|
||||
popular: false,
|
||||
buttonText: 'Contact Sales',
|
||||
buttonClass: 'preset-outlined-primary-500'
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Pricing - Choose Your Plan</title>
|
||||
<meta name="description" content="Simple, transparent pricing for every stage of your journey. Start free and scale as you grow." />
|
||||
</svelte:head>
|
||||
|
||||
<div class="container mx-auto py-20 space-y-20">
|
||||
<!-- Header -->
|
||||
<header class="text-center space-y-4 max-w-3xl mx-auto">
|
||||
<h1 class="h1">Simple, <span class="text-primary-500">Transparent</span> Pricing</h1>
|
||||
<p class="text-2xl opacity-75">
|
||||
Choose the perfect plan for your needs. Start free and scale as you grow.
|
||||
No hidden fees, no surprises.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<hr class="hr max-w-48 mx-auto" />
|
||||
|
||||
<!-- Pricing Cards -->
|
||||
<section class="grid grid-cols-1 lg:grid-cols-3 gap-8 max-w-7xl mx-auto">
|
||||
{#each plans as plan}
|
||||
<div class="card {plan.popular ? 'preset-outlined-primary-500 relative' : 'preset-outlined-surface-200-800'} p-6 md:p-8 space-y-6 {plan.popular ? 'scale-105 shadow-2xl' : 'hover:scale-105'} transition-all duration-300">
|
||||
|
||||
{#if plan.popular}
|
||||
<div class="absolute -top-4 left-1/2 transform -translate-x-1/2">
|
||||
<span class="badge preset-filled-primary-500 flex items-center gap-1">
|
||||
<Star class="size-3" />
|
||||
Most Popular
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Plan Header -->
|
||||
<div class="text-center space-y-2">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
{#if plan.name === 'Starter'}
|
||||
<Zap class="size-6 text-primary-500" />
|
||||
{:else if plan.name === 'Pro'}
|
||||
<Star class="size-6 text-primary-500" />
|
||||
{:else}
|
||||
<Crown class="size-6 text-primary-500" />
|
||||
{/if}
|
||||
<h3 class="h3 {plan.popular ? 'text-primary-500' : ''}">{plan.name}</h3>
|
||||
</div>
|
||||
<p class="opacity-75 text-sm">{plan.description}</p>
|
||||
</div>
|
||||
|
||||
<!-- Price -->
|
||||
<div class="text-center space-y-1">
|
||||
<div class="flex items-baseline justify-center gap-1">
|
||||
<span class="text-5xl font-bold">${plan.price}</span>
|
||||
<span class="text-lg opacity-75">/{plan.period}</span>
|
||||
</div>
|
||||
{#if plan.price > 0}
|
||||
<p class="text-sm opacity-50">Billed monthly</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Features -->
|
||||
<ul class="space-y-3">
|
||||
{#each plan.features as feature}
|
||||
<li class="flex items-center gap-3">
|
||||
{#if feature.included}
|
||||
<Check class="size-5 text-success-500 flex-shrink-0" />
|
||||
{:else}
|
||||
<X class="size-5 text-error-500 flex-shrink-0" />
|
||||
{/if}
|
||||
<span class="{feature.included ? '' : 'opacity-50'}">{feature.name}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<!-- CTA Button -->
|
||||
<button class="btn w-full {plan.buttonClass}">
|
||||
{plan.buttonText}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</section>
|
||||
|
||||
<hr class="hr max-w-48 mx-auto" />
|
||||
|
||||
<!-- FAQ Section -->
|
||||
<section class="max-w-4xl mx-auto space-y-8">
|
||||
<h2 class="h2 text-center">Frequently Asked Questions</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
<div class="space-y-4">
|
||||
<h3 class="h4 text-primary-500">Can I change plans anytime?</h3>
|
||||
<p class="opacity-75">
|
||||
Yes! You can upgrade, downgrade, or cancel your subscription at any time.
|
||||
Changes take effect on your next billing cycle.
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<h3 class="h4 text-primary-500">Is there a free trial?</h3>
|
||||
<p class="opacity-75">
|
||||
Our Starter plan is completely free forever. Pro plans include a 14-day free trial
|
||||
with no credit card required.
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<h3 class="h4 text-primary-500">What payment methods do you accept?</h3>
|
||||
<p class="opacity-75">
|
||||
We accept all major credit cards, PayPal, and bank transfers for Enterprise plans.
|
||||
All payments are processed securely through Stripe.
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<h3 class="h4 text-primary-500">Do you offer refunds?</h3>
|
||||
<p class="opacity-75">
|
||||
Yes! We offer a 30-day money-back guarantee for all paid plans.
|
||||
No questions asked.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<hr class="hr max-w-48 mx-auto" />
|
||||
|
||||
<!-- Contact Section -->
|
||||
<section class="grid grid-cols-1 md:grid-cols-[1fr_auto] items-center gap-4 max-w-4xl mx-auto">
|
||||
<div class="text-center md:text-left space-y-2">
|
||||
<h3 class="h3">Need a custom solution?</h3>
|
||||
<p class="opacity-75">
|
||||
Enterprise teams with specific requirements can get in touch for custom pricing and features.
|
||||
</p>
|
||||
</div>
|
||||
<a href="mailto:sales@example.com" class="btn btn-lg preset-filled-secondary-500 text-surface-50-950">
|
||||
<Mail class="size-5" />
|
||||
<span>Contact Sales</span>
|
||||
</a>
|
||||
</section>
|
||||
</div>
|
||||
187
src/routes/privacy/+page.svelte
Normal file
187
src/routes/privacy/+page.svelte
Normal file
@@ -0,0 +1,187 @@
|
||||
<script lang="ts">
|
||||
import { Shield, Calendar, Mail, FileText } from 'lucide-svelte';
|
||||
|
||||
const lastUpdated = 'May 27, 2025';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Privacy Policy - How We Protect Your Data</title>
|
||||
<meta name="description" content="Learn how we collect, use, and protect your personal information in our comprehensive privacy policy." />
|
||||
</svelte:head>
|
||||
|
||||
<aside class="bg-primary-50-950 p-8 text-center">
|
||||
This privacy policy is a placeholder, and only an example of styling one for the template.
|
||||
</aside>
|
||||
|
||||
<div class="container mx-auto py-20">
|
||||
<div class="max-w-4xl mx-auto space-y-12">
|
||||
<!-- Header -->
|
||||
<header class="text-center space-y-4">
|
||||
<div class="flex items-center justify-center gap-2 mb-4">
|
||||
<Shield class="size-8 text-primary-500" />
|
||||
<h1 class="h1">Privacy <span class="text-primary-500">Policy</span></h1>
|
||||
</div>
|
||||
<p class="text-xl opacity-75 max-w-2xl mx-auto">
|
||||
We take your privacy seriously. This policy explains how we collect, use, and protect your personal information.
|
||||
</p>
|
||||
<div class="flex items-center justify-center gap-2 text-sm opacity-50">
|
||||
<Calendar class="size-4" />
|
||||
<span>Last updated: {lastUpdated}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="prose prose-lg max-w-none">
|
||||
<div class="card text-surface-950-50 p-8 space-y-8">
|
||||
<section class="space-y-4">
|
||||
<h2 class="h3 flex items-center gap-2">
|
||||
<FileText class="size-5 text-primary-500" />
|
||||
Information We Collect
|
||||
</h2>
|
||||
<p class="opacity-75">
|
||||
We collect information you provide directly to us, such as when you create an account,
|
||||
update your profile, or contact us for support.
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<h4 class="font-semibold h4">Personal Information:</h4>
|
||||
<ul class="list-disc list-inside space-y-1 opacity-75 ml-4">
|
||||
<li>Name and email address</li>
|
||||
<li>Profile information and preferences</li>
|
||||
<li>Payment and billing information</li>
|
||||
<li>Communication history with our support team</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<h4 class="font-semibold h4">Usage Information:</h4>
|
||||
<ul class="list-disc list-inside space-y-1 opacity-75 ml-4">
|
||||
<li>Log data and device information</li>
|
||||
<li>Usage patterns and feature interactions</li>
|
||||
<li>Performance and error data</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4">
|
||||
<h2 class="h3 flex items-center gap-2">
|
||||
<Shield class="size-5 text-primary-500" />
|
||||
How We Use Your Information
|
||||
</h2>
|
||||
<p class="opacity-75">
|
||||
We use the information we collect to provide, maintain, and improve our services.
|
||||
</p>
|
||||
<ul class="list-disc list-inside space-y-2 opacity-75 ml-4">
|
||||
<li>Provide and operate our SaaS platform</li>
|
||||
<li>Process transactions and send related information</li>
|
||||
<li>Send technical notices and support messages</li>
|
||||
<li>Respond to customer service requests</li>
|
||||
<li>Improve our services and develop new features</li>
|
||||
<li>Monitor usage and prevent fraud or abuse</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4">
|
||||
<h2 class="h3">Information Sharing</h2>
|
||||
<p class="opacity-75">
|
||||
We do not sell, trade, or otherwise transfer your personal information to third parties
|
||||
except as described in this policy.
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<h4 class="font-semibold h4">We may share your information:</h4>
|
||||
<ul class="list-disc list-inside space-y-1 opacity-75 ml-4">
|
||||
<li>With service providers who assist our operations</li>
|
||||
<li>To comply with legal obligations</li>
|
||||
<li>To protect our rights and prevent fraud</li>
|
||||
<li>In connection with a business transfer</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4">
|
||||
<h2 class="h3">Data Security</h2>
|
||||
<p class="opacity-75">
|
||||
We implement appropriate security measures to protect your personal information against
|
||||
unauthorized access, alteration, disclosure, or destruction.
|
||||
</p>
|
||||
<ul class="list-disc list-inside space-y-2 opacity-75 ml-4">
|
||||
<li>Encryption of data in transit and at rest</li>
|
||||
<li>Regular security assessments and monitoring</li>
|
||||
<li>Access controls and authentication measures</li>
|
||||
<li>Employee training on data protection</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4">
|
||||
<h2 class="h3">Your Rights and Choices</h2>
|
||||
<p class="opacity-75">
|
||||
You have certain rights regarding your personal information:
|
||||
</p>
|
||||
<ul class="list-disc list-inside space-y-2 opacity-75 ml-4">
|
||||
<li><strong class="font-semibold text-primary-500">Access:</strong> Request a copy of your personal information</li>
|
||||
<li><strong class="font-semibold text-primary-500">Update:</strong> Correct inaccurate or incomplete information</li>
|
||||
<li><strong class="font-semibold text-primary-500">Delete:</strong> Request deletion of your personal information</li>
|
||||
<li><strong class="font-semibold text-primary-500">Portability:</strong> Receive your data in a portable format</li>
|
||||
<li><strong class="font-semibold text-primary-500">Opt-out:</strong> Unsubscribe from marketing communications</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4">
|
||||
<h2 class="h3">Data Retention</h2>
|
||||
<p class="opacity-75">
|
||||
We retain your personal information for as long as necessary to provide our services
|
||||
and fulfill the purposes outlined in this policy, unless a longer retention period
|
||||
is required by law.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4">
|
||||
<h2 class="h3">Cookies and Tracking</h2>
|
||||
<p class="opacity-75">
|
||||
We use cookies and similar technologies to enhance your experience, analyze usage,
|
||||
and provide personalized content.
|
||||
</p>
|
||||
<p class="opacity-75">
|
||||
You can control cookie settings through your browser preferences. However,
|
||||
disabling cookies may affect the functionality of our service.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4">
|
||||
<h2 class="h3">International Data Transfers</h2>
|
||||
<p class="opacity-75">
|
||||
Your information may be transferred to and processed in countries other than your own.
|
||||
We ensure appropriate safeguards are in place to protect your data.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4">
|
||||
<h2 class="h3">Changes to This Policy</h2>
|
||||
<p class="opacity-75">
|
||||
We may update this privacy policy from time to time. We will notify you of any
|
||||
material changes by posting the new policy on this page and updating the
|
||||
"last updated" date.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contact Information -->
|
||||
<div class="card preset-filled-primary-500 p-8 text-center">
|
||||
<div class="flex items-center justify-center gap-2 mb-4">
|
||||
<Mail class="size-6" />
|
||||
<h2 class="h3">Questions About Privacy?</h2>
|
||||
</div>
|
||||
<p class="opacity-90 mb-6">
|
||||
If you have any questions about this privacy policy or our data practices,
|
||||
please don't hesitate to contact us.
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<a href="/contact" class="btn preset-filled-surface-200-800">
|
||||
Contact Us
|
||||
</a>
|
||||
<a href="mailto:privacy@sassy.dev" class="btn preset-ghost-surface-200-800">
|
||||
privacy@sassy.dev
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
232
src/routes/terms/+page.svelte
Normal file
232
src/routes/terms/+page.svelte
Normal file
@@ -0,0 +1,232 @@
|
||||
<script lang="ts">
|
||||
import { FileText, Calendar, Mail, Scale } from 'lucide-svelte';
|
||||
|
||||
const lastUpdated = 'December 15, 2024';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Terms of Service - User Agreement</title>
|
||||
<meta name="description" content="Read our terms of service to understand your rights and obligations when using our SaaS platform." />
|
||||
</svelte:head>
|
||||
|
||||
<aside class="bg-primary-50-950 p-8 text-center">
|
||||
These terms of service are a placeholder, and only an example of styling one for the template.
|
||||
</aside>
|
||||
|
||||
<div class="container mx-auto py-20">
|
||||
<div class="max-w-4xl mx-auto space-y-12">
|
||||
<!-- Header -->
|
||||
<header class="text-center space-y-4">
|
||||
<div class="flex items-center justify-center gap-2 mb-4">
|
||||
<Scale class="size-8 text-primary-500" />
|
||||
<h1 class="h1">Terms of <span class="text-primary-500">Service</span></h1>
|
||||
</div>
|
||||
<p class="text-xl opacity-75 max-w-2xl mx-auto">
|
||||
These terms govern your use of our platform. Please read them carefully before using our services.
|
||||
</p>
|
||||
<div class="flex items-center justify-center gap-2 text-sm opacity-50">
|
||||
<Calendar class="size-4" />
|
||||
<span>Last updated: {lastUpdated}</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="prose prose-lg max-w-none">
|
||||
<div class="card preset-outlined-surface-200-800 p-8 space-y-8">
|
||||
<section class="space-y-4">
|
||||
<h2 class="h3 flex items-center gap-2">
|
||||
<FileText class="size-5 text-primary-500" />
|
||||
Acceptance of Terms
|
||||
</h2>
|
||||
<p class="opacity-75">
|
||||
By accessing or using our SaaS platform ("Service"), you agree to be bound by these
|
||||
Terms of Service ("Terms"). If you disagree with any part of these terms, you may not access the Service.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4">
|
||||
<h2 class="h3">Description of Service</h2>
|
||||
<p class="opacity-75">
|
||||
Our platform provides software-as-a-service solutions including but not limited to:
|
||||
</p>
|
||||
<ul class="list-disc list-inside space-y-1 opacity-75 ml-4">
|
||||
<li>User authentication and account management</li>
|
||||
<li>Data storage and processing capabilities</li>
|
||||
<li>API access and integrations</li>
|
||||
<li>Customer support and documentation</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4">
|
||||
<h2 class="h3">User Accounts</h2>
|
||||
<div class="space-y-3">
|
||||
<h4 class="font-semibold">Account Creation</h4>
|
||||
<p class="opacity-75">
|
||||
You must provide accurate and complete information when creating an account.
|
||||
You are responsible for maintaining the confidentiality of your account credentials.
|
||||
</p>
|
||||
|
||||
<h4 class="font-semibold">Account Responsibility</h4>
|
||||
<ul class="list-disc list-inside space-y-1 opacity-75 ml-4">
|
||||
<li>You are responsible for all activities under your account</li>
|
||||
<li>Notify us immediately of any unauthorized use</li>
|
||||
<li>Maintain accurate and up-to-date account information</li>
|
||||
<li>Comply with all applicable laws and regulations</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4">
|
||||
<h2 class="h3">Acceptable Use</h2>
|
||||
<p class="opacity-75">
|
||||
You agree not to use the Service for any unlawful purpose or in any way that could damage,
|
||||
disable, overburden, or impair the Service.
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<h4 class="font-semibold">Prohibited Activities:</h4>
|
||||
<ul class="list-disc list-inside space-y-1 opacity-75 ml-4">
|
||||
<li>Violating any applicable laws or regulations</li>
|
||||
<li>Infringing on intellectual property rights</li>
|
||||
<li>Transmitting harmful or malicious code</li>
|
||||
<li>Attempting to gain unauthorized access</li>
|
||||
<li>Interfering with other users' use of the Service</li>
|
||||
<li>Using the Service for spam or unsolicited communications</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4">
|
||||
<h2 class="h3">Payment Terms</h2>
|
||||
<div class="space-y-3">
|
||||
<h4 class="font-semibold">Subscription Fees</h4>
|
||||
<p class="opacity-75">
|
||||
Subscription fees are billed in advance on a monthly or annual basis.
|
||||
All fees are non-refundable except as expressly stated in these Terms.
|
||||
</p>
|
||||
|
||||
<h4 class="font-semibold">Payment Processing</h4>
|
||||
<ul class="list-disc list-inside space-y-1 opacity-75 ml-4">
|
||||
<li>Payment is due upon subscription or renewal</li>
|
||||
<li>We may suspend access for overdue payments</li>
|
||||
<li>Price changes require 30 days advance notice</li>
|
||||
<li>Taxes are your responsibility unless otherwise stated</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4">
|
||||
<h2 class="h3">Data and Privacy</h2>
|
||||
<p class="opacity-75">
|
||||
We respect your privacy and handle your data in accordance with our Privacy Policy.
|
||||
By using the Service, you consent to our collection and use of information as described.
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<h4 class="font-semibold">Your Data:</h4>
|
||||
<ul class="list-disc list-inside space-y-1 opacity-75 ml-4">
|
||||
<li>You retain ownership of your data</li>
|
||||
<li>We may access data to provide technical support</li>
|
||||
<li>We implement security measures to protect your data</li>
|
||||
<li>You can export your data at any time</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4">
|
||||
<h2 class="h3">Intellectual Property</h2>
|
||||
<p class="opacity-75">
|
||||
The Service and its original content, features, and functionality are owned by us and
|
||||
are protected by international copyright, trademark, patent, and other intellectual property laws.
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<h4 class="font-semibold">License Grant:</h4>
|
||||
<p class="opacity-75">
|
||||
We grant you a limited, non-exclusive, non-transferable license to use the Service
|
||||
for your internal business purposes in accordance with these Terms.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4">
|
||||
<h2 class="h3">Service Availability</h2>
|
||||
<p class="opacity-75">
|
||||
We strive to maintain high availability but do not guarantee uninterrupted access to the Service.
|
||||
We may perform maintenance, updates, or modifications that temporarily affect availability.
|
||||
</p>
|
||||
<ul class="list-disc list-inside space-y-1 opacity-75 ml-4">
|
||||
<li>We aim for 99.9% uptime on our paid plans</li>
|
||||
<li>Scheduled maintenance will be announced in advance</li>
|
||||
<li>We provide status updates during outages</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4">
|
||||
<h2 class="h3">Termination</h2>
|
||||
<div class="space-y-3">
|
||||
<h4 class="font-semibold">By You:</h4>
|
||||
<p class="opacity-75">
|
||||
You may terminate your account at any time through your account settings.
|
||||
Your access will continue until the end of your current billing period.
|
||||
</p>
|
||||
|
||||
<h4 class="font-semibold">By Us:</h4>
|
||||
<p class="opacity-75">
|
||||
We may terminate or suspend your account immediately for violations of these Terms,
|
||||
illegal activities, or non-payment of fees.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4">
|
||||
<h2 class="h3">Limitation of Liability</h2>
|
||||
<p class="opacity-75">
|
||||
To the maximum extent permitted by law, we shall not be liable for any indirect,
|
||||
incidental, special, consequential, or punitive damages arising from your use of the Service.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4">
|
||||
<h2 class="h3">Indemnification</h2>
|
||||
<p class="opacity-75">
|
||||
You agree to indemnify and hold us harmless from any claims, damages, or expenses
|
||||
arising from your use of the Service or violation of these Terms.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4">
|
||||
<h2 class="h3">Governing Law</h2>
|
||||
<p class="opacity-75">
|
||||
These Terms shall be governed by and construed in accordance with the laws of
|
||||
[Your Jurisdiction], without regard to its conflict of law provisions.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="space-y-4">
|
||||
<h2 class="h3">Changes to Terms</h2>
|
||||
<p class="opacity-75">
|
||||
We reserve the right to modify these Terms at any time. We will notify users of
|
||||
material changes via email or through the Service. Continued use constitutes acceptance of updated Terms.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contact Information -->
|
||||
<div class="card preset-filled-primary-500 p-8 text-center">
|
||||
<div class="flex items-center justify-center gap-2 mb-4">
|
||||
<Mail class="size-6" />
|
||||
<h2 class="h3">Questions About These Terms?</h2>
|
||||
</div>
|
||||
<p class="opacity-90 mb-6">
|
||||
If you have any questions about these Terms of Service, please contact our legal team.
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<a href="/contact" class="btn preset-outlined-surface-200-800">
|
||||
Contact Us
|
||||
</a>
|
||||
<a href="mailto:legal@sassy.dev" class="btn preset-ghost-surface-200-800">
|
||||
legal@sassy.dev
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -1,11 +1,27 @@
|
||||
import adapter from '@sveltejs/adapter-auto';
|
||||
<<<<<<< HEAD:my-saas-template/svelte.config.js
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
=======
|
||||
import { mdsvex } from 'mdsvex';
|
||||
import remarkUnwrapImages from 'remark-unwrap-images';
|
||||
import remarkToc from 'remark-toc';
|
||||
import remarkAbbr from 'remark-abbr';
|
||||
import rehypeSlug from 'rehype-slug';
|
||||
|
||||
/** @type {import('mdsvex').MdsvexOptions} */
|
||||
const mdsvexOptions = {
|
||||
extensions: ['.md'],
|
||||
remarkPlugins: [remarkUnwrapImages, remarkToc, remarkAbbr],
|
||||
rehypePlugins: [rehypeSlug]
|
||||
};
|
||||
>>>>>>> 3c705b9 (Overhaul):svelte.config.js
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
// Consult https://svelte.dev/docs/kit/integrations
|
||||
// for more information about preprocessors
|
||||
preprocess: vitePreprocess(),
|
||||
extensions: ['.svelte', '.md'],
|
||||
preprocess: [mdsvex(mdsvexOptions)],
|
||||
|
||||
kit: {
|
||||
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
|
||||
8
vite.config.ts
Normal file
8
vite.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
import { nodePolyfills } from 'vite-plugin-node-polyfills';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit(), nodePolyfills({ protocolImports: true })]
|
||||
});
|
||||
Reference in New Issue
Block a user