diff --git a/README.md b/README.md index af468ca..d4bc24b 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,9 @@
Website ยท - Issues - ยท Support + ยท + Self-Hosting

@@ -29,6 +29,14 @@ If you want to learn more about this project or have any questions, send us an e - [Postmark](https://postmarkapp.com) - [Arcjet](https://arcjet.com) +## Deployment + + + Railway + + +Try [Railway](https://railway.com?referralCode=techulus), a modern platform that makes deploying applications simple and fast. Railway provides excellent developer experience with features like automatic deployments, built-in databases, and seamless scaling. + ## Getting Started ๐Ÿš€ ### Requirements diff --git a/SELF-HOSTING.md b/SELF-HOSTING.md new file mode 100644 index 0000000..15db2a0 --- /dev/null +++ b/SELF-HOSTING.md @@ -0,0 +1,49 @@ +# Self-Hosting Changes.Page + +This guide will help you set up and deploy Changes.Page on your own infrastructure. + +## Database Setup + +You have two options for setting up the database: + +### Option 1: Local Supabase (Recommended for Development) + +Follow the official Supabase self-hosting guide using Docker: +https://supabase.com/docs/guides/self-hosting/docker + +### Option 2: Supabase Cloud + +Create a new project at [supabase.com](https://supabase.com) and use the provided connection details. + +## Application Deployment + +The repository includes Docker Compose files for easy deployment of the applications. + +1. Ensure you have Docker and Docker Compose installed +2. Set up your environment variables in the respective `.env` files (see `.env.example` files in `apps/web` and `apps/page`) +3. Run the applications using Docker Compose: + +```sh +docker-compose up -d +``` + +## Feature Limitations + +Please note the following limitations when self-hosting: + +- **Billing**: Currently only supported through Stripe integration +- **Custom Domains**: Only supported when deployed on Vercel +- **AI Features**: All AI functionality is channeled through ManagePrompt and requires their service + +## Environment Configuration + +Make sure to configure the following in your environment files: + +- Database connection details (Supabase) +- Authentication keys +- Stripe keys (if using billing features) +- ManagePrompt API keys (if using AI features) +- Any other third-party service credentials + +For detailed environment variable setup, refer to the `.env.example` files in the respective app directories. + diff --git a/apps/page/.env.example b/apps/page/.env.example new file mode 100644 index 0000000..bc346e6 --- /dev/null +++ b/apps/page/.env.example @@ -0,0 +1,9 @@ +NEXT_PUBLIC_SUPABASE_URL= +NEXT_PUBLIC_SUPABASE_ANON_KEY= +SUPABASE_SERVICE_ROLE_KEY= + +# Inngest +INNGEST_EVENT_KEY= + +# Arcjet +ARCJET_KEY= diff --git a/apps/page/.gitignore b/apps/page/.gitignore index 70683a2..3978add 100644 --- a/apps/page/.gitignore +++ b/apps/page/.gitignore @@ -35,8 +35,7 @@ yarn-error.log* .env* .flaskenv* -!.env.project -!.env.vault +!.env.example # IDE .idea diff --git a/apps/page/README.md b/apps/page/README.md index bdfa8c6..5887f1b 100644 --- a/apps/page/README.md +++ b/apps/page/README.md @@ -1,19 +1,3 @@ ## Pages App This folder contains the pages app which renders user changelog pages. - -### Environment Variables - -``` -# Supabase details from https://app.supabase.io -NEXT_PUBLIC_SUPABASE_URL= -NEXT_PUBLIC_SUPABASE_ANON_KEY= -SUPABASE_SERVICE_ROLE_KEY= - -# Inngest -INNGEST_EVENT_KEY= -INNGEST_SIGNING_KEY= - -# Arcjet -ARCJET_KEY= -``` diff --git a/apps/page/pages/_sites/[site]/index.tsx b/apps/page/pages/_sites/[site]/index.tsx index 817d562..2ed083f 100644 --- a/apps/page/pages/_sites/[site]/index.tsx +++ b/apps/page/pages/_sites/[site]/index.tsx @@ -166,9 +166,7 @@ export async function getServerSideProps({ }; } - console.time("fetchRenderData"); const { page, settings } = await fetchRenderData(site); - console.timeEnd("fetchRenderData"); if (!page || !settings || !(await isSubscriptionActive(page?.user_id))) { return { @@ -176,12 +174,10 @@ export async function getServerSideProps({ }; } - console.time("fetchPosts"); const { posts, postsCount } = await fetchPosts(String(page?.id), { pinned_post_id: settings?.pinned_post_id, limit: 10, }); - console.timeEnd("fetchPosts"); return { props: { diff --git a/apps/web/.env.example b/apps/web/.env.example new file mode 100644 index 0000000..c959e14 --- /dev/null +++ b/apps/web/.env.example @@ -0,0 +1,40 @@ +NEXT_PUBLIC_PAGES_DOMAIN=http://localhost:3000 + +# Supabase details from https://app.supabase.io +NEXT_PUBLIC_SUPABASE_URL= +NEXT_PUBLIC_SUPABASE_ANON_KEY= +SUPABASE_SERVICE_ROLE_KEY= + +# Stripe credentials from https://dashboard.stripe.com/apikeys +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY= +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= +STRIPE_PRICE_ID= +EMAIL_NOTIFICATION_STRIPE_PRICE_ID= + +# Custom domains +VERCEL_AUTH_TOKEN= +VERCEL_TEAM_ID= +VERCEL_PAGES_PROJECT_ID= + +# Postmark Emails +POSTMARK_SERVER_KEY= +POSTMARK_WEBHOOK_KEY= + +# Inngest +INNGEST_EVENT_KEY= +INNGEST_SIGNING_KEY= + +# Arcjet +ARCJET_KEY= + +# CMS +NEXT_PUBLIC_SANITY_PROJECT_ID=jeixxcw8 + +# ManagePrompt +MANAGEPROMPT_SECRET= +MANAGEPROMPT_CHANGEGPT_WORKFLOW_ID= + +# PostHog +NEXT_PUBLIC_POSTHOG_KEY= +NEXT_PUBLIC_POSTHOG_HOST= diff --git a/apps/web/.gitignore b/apps/web/.gitignore index 1035fdd..f485dfc 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -35,8 +35,7 @@ yarn-error.log* .env* .flaskenv* -!.env.project -!.env.vault +!.env.example # IDE .idea diff --git a/apps/web/README.md b/apps/web/README.md index 24fd2c0..bb14b2e 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -1,45 +1,3 @@ ## Web App This folder contains the dashboard app for the project and all marketing pages. - -### Environment Variables - -``` -NEXT_PUBLIC_PAGES_DOMAIN=http://localhost:3000 - -# Supabase details from https://app.supabase.io -NEXT_PUBLIC_SUPABASE_URL= -NEXT_PUBLIC_SUPABASE_ANON_KEY= -SUPABASE_SERVICE_ROLE_KEY= -SUPABASE_WEBHOOK_KEY= - -# Stripe credentials from https://dashboard.stripe.com/apikeys -NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY= -STRIPE_SECRET_KEY= -STRIPE_WEBHOOK_SECRET= -STRIPE_PRICE_ID= -EMAIL_NOTIFICATION_STRIPE_PRICE_ID= - -# Open AI -OPENAI_API_KEY= - -# Custom domains -VERCEL_AUTH_TOKEN= -VERCEL_TEAM_ID= -VERCEL_PAGES_PROJECT_ID= - -# Postmark Emails -POSTMARK_SERVER_KEY= -POSTMARK_WEBHOOK_KEY= - -# Inngest -INNGEST_EVENT_KEY= -INNGEST_SIGNING_KEY= - -# CMS -NEXT_PUBLIC_SANITY_PROJECT_ID= - -# ManagePrompt -MANAGEPROMPT_SECRET= -MANAGEPROMPT_CHANGEGPT_WORKFLOW_ID= -``` \ No newline at end of file diff --git a/apps/web/components/layout/footer.component.tsx b/apps/web/components/layout/footer.component.tsx index 0346674..21cf9fe 100644 --- a/apps/web/components/layout/footer.component.tsx +++ b/apps/web/components/layout/footer.component.tsx @@ -25,6 +25,14 @@ const navigation = { name: "ChangeCraftAI", href: "/free-tools/ai-changelog-generator", }, + { + name: "SemVer Calculator", + href: "/free-tools/semantic-version-calculator", + }, + { + name: "Release Calendar", + href: "/free-tools/release-calendar", + }, { name: "Blog", href: ROUTES.BLOG }, ], legal: [ diff --git a/apps/web/pages/free-tools/release-calendar.tsx b/apps/web/pages/free-tools/release-calendar.tsx new file mode 100644 index 0000000..91a2a15 --- /dev/null +++ b/apps/web/pages/free-tools/release-calendar.tsx @@ -0,0 +1,1123 @@ +import { CalendarIcon, RefreshIcon, ViewGridIcon, ViewListIcon, PresentationChartLineIcon } from "@heroicons/react/solid"; +import classNames from "classnames"; +import { InferGetServerSidePropsType } from "next"; +import Head from "next/head"; +import Link from "next/link"; +import { useCallback, useState, useEffect } from "react"; +import { + createToastWrapper, + notifyError, + notifySuccess, +} from "../../components/core/toast.component"; +import FooterComponent from "../../components/layout/footer.component"; +import MarketingHeaderComponent from "../../components/marketing/marketing-header.component"; +import usePrefersColorScheme from "../../utils/hooks/usePrefersColorScheme"; + +interface Release { + id: string; + version: string; + date: string; + title: string; + type: "major" | "minor" | "patch" | "hotfix"; + status: "planned" | "in-progress" | "released" | "delayed"; + description?: string; +} + +type ViewMode = "timeline" | "calendar"; + +export default function ReleaseCalendar({ + title, + description, + keywords, + canonicalUrl, +}: InferGetServerSidePropsType) { + const theme = usePrefersColorScheme(); + + // State + const [releases, setReleases] = useState([]); + + // Load data from localStorage on mount + useEffect(() => { + if (typeof window !== 'undefined') { + try { + const savedReleases = localStorage.getItem('release-calendar-data'); + if (savedReleases) { + setReleases(JSON.parse(savedReleases)); + } + } catch (error) { + console.error('Failed to load releases from localStorage:', error); + } + } + }, []); + + // Save data to localStorage whenever releases change + useEffect(() => { + if (typeof window !== 'undefined') { + try { + localStorage.setItem('release-calendar-data', JSON.stringify(releases)); + } catch (error) { + console.error('Failed to save releases to localStorage:', error); + } + } + }, [releases]); + const [viewMode, setViewMode] = useState("timeline"); + const [showAddForm, setShowAddForm] = useState(false); + const [editingRelease, setEditingRelease] = useState(null); + const [presentationMode, setPresentationMode] = useState(false); + + // Form state + const [formData, setFormData] = useState({ + version: "", + date: "", + title: "", + type: "minor" as Release["type"], + status: "planned" as Release["status"], + description: "", + }); + + + // Generate unique ID for releases + const generateId = useCallback(() => { + return Date.now().toString(36) + Math.random().toString(36).substr(2); + }, []); + + // Add or update release + const handleSaveRelease = useCallback(() => { + if (!formData.version.trim() || !formData.date.trim() || !formData.title.trim()) { + notifyError("Please fill in version, date, and title fields"); + return; + } + + const releaseData: Release = { + id: editingRelease?.id || generateId(), + version: formData.version.trim(), + date: formData.date, + title: formData.title.trim(), + type: formData.type, + status: formData.status, + description: formData.description.trim(), + }; + + if (editingRelease) { + setReleases(prev => prev.map(r => r.id === editingRelease.id ? releaseData : r)); + notifySuccess("Release updated successfully"); + } else { + setReleases(prev => [...prev, releaseData]); + notifySuccess("Release added successfully"); + } + + // Reset form + setFormData({ + version: "", + date: "", + title: "", + type: "minor", + status: "planned", + description: "", + }); + setShowAddForm(false); + setEditingRelease(null); + }, [formData, editingRelease, generateId]); + + // Edit release + const handleEditRelease = useCallback((release: Release) => { + setFormData({ + version: release.version, + date: release.date, + title: release.title, + type: release.type, + status: release.status, + description: release.description || "", + }); + setEditingRelease(release); + setShowAddForm(true); + }, []); + + // Delete release + const handleDeleteRelease = useCallback((id: string) => { + setReleases(prev => prev.filter(r => r.id !== id)); + notifySuccess("Release deleted successfully"); + }, []); + + // Reset all data + const resetData = useCallback(() => { + setReleases([]); + setFormData({ + version: "", + date: "", + title: "", + type: "minor", + status: "planned", + description: "", + }); + setShowAddForm(false); + setEditingRelease(null); + setPresentationMode(false); + // Clear localStorage + if (typeof window !== 'undefined') { + localStorage.removeItem('release-calendar-data'); + } + notifySuccess("All data cleared"); + }, []); + + // Toggle presentation mode + const togglePresentationMode = useCallback(() => { + setPresentationMode(prev => !prev); + if (!presentationMode) { + // Hide forms when entering presentation mode + setShowAddForm(false); + setEditingRelease(null); + } + }, [presentationMode]); + + // Exit presentation mode with Escape key + useEffect(() => { + const handleKeyPress = (event: KeyboardEvent) => { + if (event.key === 'Escape' && presentationMode) { + setPresentationMode(false); + } + }; + + document.addEventListener('keydown', handleKeyPress); + return () => document.removeEventListener('keydown', handleKeyPress); + }, [presentationMode]); + + // Generate sample data + const generateSampleData = useCallback(() => { + const today = new Date(); + const sampleReleases: Release[] = [ + { + id: generateId(), + version: "2.1.0", + date: new Date(today.getTime() + 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], + title: "New Dashboard Features", + type: "minor", + status: "planned", + description: "Enhanced analytics dashboard with real-time metrics" + }, + { + id: generateId(), + version: "2.0.1", + date: new Date(today.getTime() - 5 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], + title: "Bug Fixes", + type: "patch", + status: "released", + description: "Fixed authentication issues and improved performance" + }, + { + id: generateId(), + version: "2.2.0", + date: new Date(today.getTime() + 21 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], + title: "API v3 Launch", + type: "major", + status: "in-progress", + description: "Complete API overhaul with breaking changes" + }, + { + id: generateId(), + version: "2.1.1", + date: new Date(today.getTime() + 14 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], + title: "Security Patch", + type: "hotfix", + status: "planned", + description: "Critical security vulnerability fix" + } + ]; + setReleases(sampleReleases); + notifySuccess("Sample data loaded"); + }, [generateId]); + + + + // Sort releases by date + const sortedReleases = [...releases].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()); + + // Get release type styles and emojis + const getReleaseTypeStyles = (type: Release["type"]) => { + switch (type) { + case "major": return "bg-red-600 text-white font-semibold"; + case "minor": return "bg-blue-600 text-white font-semibold"; + case "patch": return "bg-green-600 text-white font-semibold"; + case "hotfix": return "bg-orange-600 text-white font-semibold"; + default: return "bg-gray-600 text-white font-semibold"; + } + }; + + const getReleaseTypeEmoji = (type: Release["type"]) => { + switch (type) { + case "major": return "๐Ÿš€"; + case "minor": return "โœจ"; + case "patch": return "๐Ÿ”ง"; + case "hotfix": return "๐Ÿšจ"; + default: return "๐Ÿ“ฆ"; + } + }; + + // Get status styles and emojis + const getStatusStyles = (status: Release["status"]) => { + switch (status) { + case "planned": return "bg-gray-700 text-white border-gray-600"; + case "in-progress": return "bg-yellow-600 text-white border-yellow-500"; + case "released": return "bg-green-600 text-white border-green-500"; + case "delayed": return "bg-red-600 text-white border-red-500"; + default: return "bg-gray-700 text-white border-gray-600"; + } + }; + + const getStatusEmoji = (status: Release["status"]) => { + switch (status) { + case "planned": return "๐Ÿ“…"; + case "in-progress": return "โšก"; + case "released": return "โœ…"; + case "delayed": return "โฐ"; + default: return "๐Ÿ“‹"; + } + }; + + if (presentationMode) { + return ( +
+ + {title} + + {createToastWrapper(theme)} + + {/* Presentation Header */} +
+
+
+

Release Timeline - Presentation Mode

+

Press ESC to exit โ€ข Use view toggle to switch between timeline and calendar

+
+
+ {/* View Mode Toggle */} +
+ + +
+ +
+
+
+ + {/* Presentation Content */} +
+ {releases.length === 0 ? ( +
+ +

No releases to present

+

Exit presentation mode to add releases

+
+ ) : viewMode === "timeline" ? ( +
+

Release Timeline

+
+ {/* Main timeline line */} +
+ +
+ {sortedReleases.map((release, index) => ( +
+ {/* Timeline dot */} +
+ + {/* Content */} +
+
+ {/* Tape effect */} +
+
+ + {/* Shadow effect */} +
+ +
+
+
+ + {getReleaseTypeEmoji(release.type)} + {release.type} + + + {getStatusEmoji(release.status)} + {release.status.replace("-", " ")} + +
+

+ {release.version} - {release.title} +

+

+ {new Date(release.date).toLocaleDateString('en-US', { + weekday: 'long', + year: 'numeric', + month: 'long', + day: 'numeric' + })} +

+ {release.description && ( +

+ {release.description} +

+ )} +
+
+ + {/* Sticky note lines */} +
+
+
+
+
+
+
+
+ ))} +
+
+
+ ) : ( + // Calendar View +
+

Release Calendar

+ {(() => { + // Get current month or the month with releases + const today = new Date(); + const releaseMonths = sortedReleases.length > 0 + ? [...new Set(sortedReleases.map(r => new Date(r.date).toISOString().slice(0, 7)))] + : [today.toISOString().slice(0, 7)]; + + return releaseMonths.map(monthStr => { + const [year, month] = monthStr.split('-').map(Number); + const firstDay = new Date(year, month - 1, 1); + const lastDay = new Date(year, month, 0); + const startDate = new Date(firstDay); + startDate.setDate(startDate.getDate() - firstDay.getDay()); // Start from Sunday + + const monthReleases = sortedReleases.filter(r => + new Date(r.date).toISOString().slice(0, 7) === monthStr + ); + + // Generate calendar days + const calendarDays = []; + const currentDate = new Date(startDate); + + for (let i = 0; i < 42; i++) { // 6 weeks * 7 days + const dayReleases = monthReleases.filter(r => + new Date(r.date).toDateString() === currentDate.toDateString() + ); + + calendarDays.push({ + date: new Date(currentDate), + isCurrentMonth: currentDate.getMonth() === month - 1, + isToday: currentDate.toDateString() === today.toDateString(), + releases: dayReleases + }); + + currentDate.setDate(currentDate.getDate() + 1); + } + + return ( +
+ {/* Calendar Header */} +
+

+ {firstDay.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })} +

+
+ + {/* Days of Week Header */} +
+ {['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'].map(day => ( +
+ {day} +
+ ))} +
+ + {/* Calendar Grid */} +
+ {calendarDays.map((day, index) => ( +
0 ? "bg-indigo-900/20" : "" + )} + > + {/* Date Number */} +
+ {day.date.getDate()} +
+ + {/* Releases for this day */} +
+ {day.releases.slice(0, 3).map((release, idx) => ( +
+ {getReleaseTypeEmoji(release.type)} +
+
{release.version}
+
{release.title}
+
+
+ ))} + {day.releases.length > 3 && ( +
+ +{day.releases.length - 3} more +
+ )} +
+
+ ))} +
+ + {/* Month Legend */} + {monthReleases.length > 0 && ( +
+

Releases this month:

+
+ {monthReleases.map(release => ( +
+
+ + {getReleaseTypeEmoji(release.type)} + {release.type} + +
+
+ {release.version} - {release.title} +
+
+ {new Date(release.date).toLocaleDateString('en-US', { + month: 'long', + day: 'numeric' + })} +
+ {release.description && ( +
+ {release.description} +
+ )} +
+
+
+ ))} +
+
+ )} +
+ ); + }); + })()} +
+ )} +
+
+ ); + } + + return ( +
+ + {title} + + + + + {/* Open Graph Meta Tags */} + + + + + + + {/* Twitter Card Meta Tags */} + + + + + + {/* Additional SEO Meta Tags */} + + + + + {/* Structured Data */} +